Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b627ed04b7 | ||
|
|
d8e3250af9 | ||
|
|
8bd2ac7a9a | ||
|
|
c1c529d4bd | ||
|
|
aca048e593 | ||
|
|
440f85d89f | ||
|
|
bf2e59a14e | ||
|
|
19f0dcae5d | ||
|
|
c3401ed61c | ||
|
|
e880500ed8 | ||
|
|
0121aa5b1d | ||
|
|
cf3c4e96cd | ||
|
|
0dd7e07aa0 | ||
|
|
2b2a31ecda | ||
|
|
0f75d2638b | ||
|
|
9fe1ebb0fb | ||
|
|
951ad9cd6d | ||
|
|
6cb2f62627 | ||
|
|
427e87c6e6 | ||
|
|
dce83005ea | ||
|
|
1ac486cc4b | ||
|
|
549ef1dfbf | ||
|
|
164b5fb25d | ||
|
|
6afcfd49f1 | ||
|
|
5f77cac7ea | ||
|
|
20a6548ac8 | ||
|
|
9c35712573 | ||
|
|
4342c6ab6d | ||
|
|
57bad5c74c | ||
|
|
ebf833d0bd | ||
|
|
2f24a9878c | ||
|
|
2313671965 | ||
|
|
e1b80d9f89 | ||
|
|
99bb6adf80 | ||
|
|
06ee010f07 | ||
|
|
5f07adef2f | ||
|
|
bfc05795f0 | ||
|
|
9de5f3f00d | ||
|
|
bfdbd49aa1 | ||
|
|
844a3c22ff | ||
|
|
87a08a4d24 | ||
|
|
dc0637c37c | ||
|
|
381eca6660 | ||
|
|
36018b3e52 | ||
|
|
3c0324c071 | ||
|
|
a70108dff8 | ||
|
|
83447ab68a | ||
|
|
4ac937c54f | ||
|
|
b5f28929d7 | ||
|
|
d729a928ea | ||
|
|
01a4ad742b | ||
|
|
6f77a5e12e | ||
|
|
e29c38d88c | ||
|
|
04f509fc4c | ||
|
|
b6061f1384 | ||
|
|
38c3a82b54 | ||
|
|
d3f1409503 | ||
|
|
3475a073ad | ||
|
|
643c61db65 | ||
|
|
1734b7863c | ||
|
|
d45d4f443b | ||
|
|
994fa010b8 | ||
|
|
5c20c5f6eb | ||
|
|
c4bb7ae0b2 | ||
|
|
0c985a280c | ||
|
|
17ebb9fd42 | ||
|
|
cadc2b14db | ||
|
|
1fdca903be | ||
|
|
5e7658aa80 | ||
|
|
bea31032fb | ||
|
|
f7c1dbdbc4 | ||
|
|
a1ed010963 | ||
|
|
7be6b7ac31 | ||
|
|
20c20b71ea | ||
|
|
0fb3df0b3a | ||
|
|
e10b09a9b5 | ||
|
|
806a576380 | ||
|
|
3c365c6792 | ||
|
|
4fb4a85ac4 | ||
|
|
e7312e5d6e | ||
|
|
12c2de7ed8 | ||
|
|
8a3cc95c66 | ||
|
|
9c06ffdb48 | ||
|
|
870a903015 | ||
|
|
b5768e3b17 | ||
|
|
02b33dbf07 | ||
|
|
a0c0b875f0 | ||
|
|
512bf8c746 | ||
|
|
04257e8a66 | ||
|
|
c04b2ecee2 |
@@ -21,9 +21,9 @@ labels:
|
|||||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||||
- Android version:
|
- Android version:
|
||||||
- Device:
|
- Device:
|
||||||
- Installed from: <!-- official F-Droid / the self-hosted repo / built from source -->
|
- Installed from: <!-- official F-Droid / the self-hosted repo / Google Play / Codeberg APK or Obtainium / built from source -->
|
||||||
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
|
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
|
||||||
subscribed/WebCal, birthdays — provider behaviour differs
|
subscribed/WebCal, birthdays. Provider behaviour differs
|
||||||
a lot per account type, so this often points straight at
|
a lot per account type, so this often points straight at
|
||||||
the cause -->
|
the cause -->
|
||||||
- Time zone: <!-- only if the problem involves dates or all-day events -->
|
- Time zone: <!-- only if the problem involves dates or all-day events -->
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ contact_links:
|
|||||||
- name: Translate Calendula
|
- name: Translate Calendula
|
||||||
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
|
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
|
||||||
about: >-
|
about: >-
|
||||||
Translations are managed on Weblate, not here — it owns every values-*
|
Translations are managed on Weblate. It owns every values-* file, so a
|
||||||
file, so a hand-edited translation gets overwritten on the next sync.
|
hand-edited translation gets overwritten on the next sync.
|
||||||
No coding needed: pick or request a language and translate in the browser.
|
No coding needed: pick or request a language and translate in the browser.
|
||||||
|
|
||||||
- name: Contributing guide
|
- name: Contributing guide
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Crash report
|
name: Crash report
|
||||||
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
|
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash). It copies the report to your clipboard and prefills this form.
|
||||||
title: "Crash: "
|
title: "Crash: "
|
||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
@@ -11,10 +11,10 @@ labels:
|
|||||||
<!--
|
<!--
|
||||||
Thanks for reporting a crash in Calendula!
|
Thanks for reporting a crash in Calendula!
|
||||||
|
|
||||||
If the app prefilled this for you, the crash report is already below — just add
|
If the app prefilled this for you, the crash report is already below. Add
|
||||||
what you were doing and submit. Otherwise, paste the report from your clipboard
|
what you were doing and submit. Otherwise, paste the report from your clipboard
|
||||||
into the code block. The report contains only app/Android/device versions and the
|
into the code block. The report contains only app/Android/device versions and the
|
||||||
stack trace — no personal data or calendar content.
|
stack trace, with no personal data or calendar content.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
### What happened
|
### What happened
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ labels:
|
|||||||
### What would you like Calendula to do?
|
### What would you like Calendula to do?
|
||||||
|
|
||||||
|
|
||||||
### Why — what problem does it solve?
|
### What problem does it solve?
|
||||||
|
|
||||||
|
|
||||||
### Anything else
|
### Anything else
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Please skim CONTRIBUTING.md if you haven't:
|
|||||||
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
|
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
|
||||||
|
|
||||||
Two things it's easy to get wrong:
|
Two things it's easy to get wrong:
|
||||||
• Features need a discussed issue first — an undiscussed feature PR may be
|
• Features need a discussed issue first. An undiscussed feature PR may be
|
||||||
closed unmerged even when the code is good.
|
closed unmerged even when the code is good.
|
||||||
• Target the release branch for your issue's milestone (milestone 2.18.0 →
|
• Target the release branch for your issue's milestone (milestone 2.18.0 →
|
||||||
release/v2.18.0), not main. If you targeted main, just say so below and it
|
release/v2.18.0), not main. If you targeted main, just say so below and it
|
||||||
@@ -17,7 +17,7 @@ Two things it's easy to get wrong:
|
|||||||
|
|
||||||
### Why
|
### Why
|
||||||
|
|
||||||
<!-- Closes #123 — link the issue this implements or fixes. -->
|
<!-- Closes #123: link the issue this implements or fixes. -->
|
||||||
|
|
||||||
|
|
||||||
### How it was tested
|
### How it was tested
|
||||||
@@ -34,7 +34,7 @@ are especially useful for UI changes.
|
|||||||
### Checklist
|
### Checklist
|
||||||
|
|
||||||
- [ ] There's an issue for this, and (for a feature) it got a go-ahead
|
- [ ] There's an issue for this, and (for a feature) it got a go-ahead
|
||||||
- [ ] Targeting the release branch for that issue's milestone — or `main`, noted above
|
- [ ] Targeting the release branch for that issue's milestone (or `main`, noted above)
|
||||||
- [ ] `./gradlew lint test assembleDebug` passes locally
|
- [ ] `./gradlew lint test assembleDebug` passes locally
|
||||||
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
||||||
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
|
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
|
||||||
|
|||||||
@@ -37,12 +37,40 @@ jobs:
|
|||||||
- name: Reproducible-release invariant
|
- name: Reproducible-release invariant
|
||||||
run: bash scripts/check_reproducible_release.sh
|
run: bash scripts/check_reproducible_release.sh
|
||||||
|
|
||||||
|
# versionCode must match versionName (the official F-Droid repo builds the
|
||||||
|
# tag as committed), and a beta must never reach main.
|
||||||
|
- name: Committed version is well-formed
|
||||||
|
env:
|
||||||
|
BASE: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
bash scripts/version_info.sh --check
|
||||||
|
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
|
||||||
|
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
|
||||||
|
echo "Set the stable version (and its versionCode) before merging into main." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Play rejects a "What's New" over 500 characters, which would fail the
|
# Play rejects a "What's New" over 500 characters, which would fail the
|
||||||
# upload after the release had already shipped everywhere else. Cheap, so
|
# upload after the release had already shipped everywhere else. Cheap, so
|
||||||
# it runs on every PR rather than only on the release merge.
|
# it runs on every PR rather than only on the release merge. A beta ships
|
||||||
|
# no What's New, so only the older files are checked for one.
|
||||||
- name: Changelog length invariant
|
- name: Changelog length invariant
|
||||||
run: bash scripts/check_changelog_lengths.sh
|
run: bash scripts/check_changelog_lengths.sh
|
||||||
|
|
||||||
|
# The whole listing goes to Play with every release; a field over its cap
|
||||||
|
# or an image Play rejects would fail that upload.
|
||||||
|
- name: Store listing invariant
|
||||||
|
run: |
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update && apt-get install -y python3
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
apk add --no-cache python3
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
python3 scripts/check_store_listing.py
|
||||||
|
|
||||||
# Decide whether anything that affects the app build changed. Docs, store
|
# Decide whether anything that affects the app build changed. Docs, store
|
||||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||||
# the SDK + Gradle work below but still report a green `ci`.
|
# the SDK + Gradle work below but still report a green `ci`.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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: light
|
||||||
|
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: gradle
|
||||||
|
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
|
||||||
+34
-257
@@ -7,7 +7,9 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
|||||||
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
|
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
|
||||||
# direct-download channel — the tag is an output of the pipeline, not its
|
# direct-download channel — the tag is an output of the pipeline, not its
|
||||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||||
# nothing.
|
# nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
|
||||||
|
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
|
||||||
|
# one that reaches main.
|
||||||
#
|
#
|
||||||
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
|
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
|
||||||
# and separate because Play is the only channel that can reject a good build for
|
# and separate because Play is the only channel that can reject a good build for
|
||||||
@@ -41,7 +43,7 @@ jobs:
|
|||||||
# secrets. repository_owner differs between the two forges regardless of
|
# secrets. repository_owner differs between the two forges regardless of
|
||||||
# URL, proxy or instance rename, so this closes it permanently.
|
# URL, proxy or instance rename, so this closes it permanently.
|
||||||
if: github.repository_owner == 'makiolaj'
|
if: github.repository_owner == 'makiolaj'
|
||||||
runs-on: docker
|
runs-on: light
|
||||||
outputs:
|
outputs:
|
||||||
is_release: ${{ steps.v.outputs.is_release }}
|
is_release: ${{ steps.v.outputs.is_release }}
|
||||||
version: ${{ steps.v.outputs.version }}
|
version: ${{ steps.v.outputs.version }}
|
||||||
@@ -54,56 +56,28 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve version and whether it is a new release
|
- name: Resolve version and whether it is a new release
|
||||||
id: v
|
id: v
|
||||||
env:
|
|
||||||
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
|
||||||
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
|
||||||
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
|
||||||
# source does not have. A tag minted here on Gitea is therefore wiped
|
|
||||||
# by the next sync (Codeberg does not have it yet) and only reappears
|
|
||||||
# once the tag push at the end of this workflow propagates back.
|
|
||||||
# Asking Gitea inside that window would report "no tag" for a release
|
|
||||||
# that already shipped, and cut it a second time.
|
|
||||||
# Public repo, so this read needs no token.
|
|
||||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
INFO=$(bash scripts/version_info.sh)
|
||||||
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
echo "$INFO"
|
||||||
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
|
||||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Resolved version $VERSION (code $VERSION_CODE)"
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
echo "Manual dispatch — re-sign path, not a release."
|
echo "Manual dispatch — re-sign path, not a release."
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# A tag for this version already existing means the release shipped on
|
# Backstop for CI's guard: betas ship from release/* via beta.yaml.
|
||||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
if [ "$CHANNEL" != "stable" ]; then
|
||||||
#
|
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
|
||||||
# Anything other than a clean 200/404 is treated as fatal rather than
|
exit 1
|
||||||
# as "no tag". A Codeberg outage or a network blip would otherwise
|
fi
|
||||||
# read as absent and re-cut a release that has already shipped —
|
# Tags are read from Codeberg, the canonical forge: a tag minted here is
|
||||||
# republishing to F-Droid and Play. Failing here is recoverable; a
|
# wiped by the next mirror sync until Codeberg has it. A lookup error is
|
||||||
# duplicate release is not.
|
# fatal, since guessing "no tag" would re-cut a shipped release.
|
||||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
GATE=$(bash scripts/release_gate.sh)
|
||||||
case "$STATUS" in
|
echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||||
200)
|
|
||||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
|
||||||
;;
|
|
||||||
404)
|
|
||||||
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
|
||||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
|
||||||
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||||
@@ -111,7 +85,7 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
needs: detect
|
needs: detect
|
||||||
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
runs-on: docker
|
runs-on: gradle
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
ANDROID_HOME: /opt/android-sdk
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
@@ -124,65 +98,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Android build environment
|
||||||
uses: actions/setup-java@v4
|
uses: ./.gitea/actions/android-env
|
||||||
with:
|
|
||||||
distribution: 'zulu'
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: Setup Android SDK
|
# The committed versionName is the source of truth; pin the derived code.
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
with:
|
|
||||||
packages: ''
|
|
||||||
|
|
||||||
- name: Setup Android SDK cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: /opt/android-sdk
|
|
||||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
|
||||||
run: |
|
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
|
||||||
sdkmanager \
|
|
||||||
"platform-tools" \
|
|
||||||
"platforms;android-37.0" \
|
|
||||||
"build-tools;36.0.0"
|
|
||||||
|
|
||||||
- name: Setup Gradle cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-gradle-
|
|
||||||
|
|
||||||
- name: Install jq
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
SUDO=""
|
|
||||||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
|
||||||
$SUDO apt-get update
|
|
||||||
$SUDO apt-get install -y jq
|
|
||||||
elif command -v apk >/dev/null 2>&1; then
|
|
||||||
$SUDO apk add --no-cache jq
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
|
||||||
run: chmod +x ./gradlew
|
|
||||||
|
|
||||||
# The committed versionName is the source of truth. Pin versionCode to the
|
|
||||||
# value derived from it so the published APK's code is always
|
|
||||||
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
|
||||||
- name: Pin versionCode to versionName
|
- name: Pin versionCode to versionName
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
run: |
|
run: bash scripts/version_info.sh --pin
|
||||||
set -e
|
|
||||||
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
|
||||||
grep -E 'versionName|versionCode' app/build.gradle.kts
|
|
||||||
|
|
||||||
# Test the exact commit being shipped (only on a real release).
|
# Test the exact commit being shipped (only on a real release).
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
@@ -195,15 +117,7 @@ jobs:
|
|||||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
run: |
|
run: bash scripts/write_keystore.sh
|
||||||
mkdir -p app
|
|
||||||
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
|
|
||||||
cat > key.properties <<EOF
|
|
||||||
storePassword=$KEY_PASSWORD
|
|
||||||
keyPassword=$KEY_PASSWORD
|
|
||||||
keyAlias=$KEY_ALIAS
|
|
||||||
storeFile=upload-keystore.jks
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
@@ -312,6 +226,7 @@ jobs:
|
|||||||
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
||||||
# this commit, so the tag only ever marks a fully-shipped release (and a
|
# this commit, so the tag only ever marks a fully-shipped release (and a
|
||||||
# failure before here leaves no tag, so re-running the workflow retries).
|
# failure before here leaves no tag, so re-running the workflow retries).
|
||||||
|
# Also attaches the R8 mapping, best-effort.
|
||||||
- name: Create tag + Gitea release
|
- name: Create tag + Gitea release
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -320,74 +235,10 @@ jobs:
|
|||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
TAG="v$VERSION"
|
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
# Notes = this version's CHANGELOG section.
|
TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
|
||||||
awk -v ver="$VERSION" '
|
MAPPING=app/build/outputs/mapping/release/mapping.txt \
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
bash scripts/publish_gitea_release.sh
|
||||||
/^## \[/ { flag = 0 }
|
|
||||||
flag' CHANGELOG.md > release-notes.md
|
|
||||||
sed -i -e '/./,$!d' release-notes.md
|
|
||||||
if [ ! -s release-notes.md ]; then
|
|
||||||
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
|
|
||||||
fi
|
|
||||||
python3 - "$TAG" "$SHA" <<'PY' > payload.json
|
|
||||||
import json, sys
|
|
||||||
print(json.dumps({
|
|
||||||
"tag_name": sys.argv[1],
|
|
||||||
"target_commitish": sys.argv[2],
|
|
||||||
"name": sys.argv[1],
|
|
||||||
"body": open("release-notes.md").read(),
|
|
||||||
"draft": False,
|
|
||||||
"prerelease": False,
|
|
||||||
}))
|
|
||||||
PY
|
|
||||||
# Upsert (re-run safe): PATCH if a release for the tag already exists,
|
|
||||||
# else POST a new one (which also creates the tag at target_commitish).
|
|
||||||
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
|
||||||
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
|
|
||||||
if [ -n "$ID" ]; then
|
|
||||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @payload.json "$API/releases/$ID")
|
|
||||||
OK=200
|
|
||||||
else
|
|
||||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @payload.json "$API/releases")
|
|
||||||
OK=201
|
|
||||||
fi
|
|
||||||
cat response.json
|
|
||||||
if [ "$CODE" != "$OK" ]; then
|
|
||||||
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Created/updated release $TAG at $SHA"
|
|
||||||
|
|
||||||
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
|
|
||||||
# Attached to the release (it's not an APK, so it fits the no-binaries
|
|
||||||
# rule). Best-effort: never fail a release over it.
|
|
||||||
- name: Attach R8 mapping to Gitea release
|
|
||||||
if: env.IS_RELEASE == 'true'
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
MAP="app/build/outputs/mapping/release/mapping.txt"
|
|
||||||
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
|
|
||||||
TAG="v$VERSION"
|
|
||||||
ASSET="mapping-${VERSION}.txt.gz"
|
|
||||||
gzip -c "$MAP" > "/tmp/$ASSET"
|
|
||||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
|
||||||
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
|
|
||||||
# Replace any prior asset of the same name (re-run safe).
|
|
||||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
|
||||||
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
|
|
||||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
|
||||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@/tmp/$ASSET" \
|
|
||||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
|
||||||
|
|
||||||
# Mirror the release to the Codeberg mirror as a direct-download channel
|
# Mirror the release to the Codeberg mirror as a direct-download channel
|
||||||
# for users who don't want F-Droid. Gitea already push-mirrors branches +
|
# for users who don't want F-Droid. Gitea already push-mirrors branches +
|
||||||
@@ -406,84 +257,10 @@ jobs:
|
|||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
if [ -z "${TOKEN:-}" ]; then
|
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
|
||||||
exit 0
|
APK=app/build/outputs/apk/release/app-release.apk \
|
||||||
fi
|
bash scripts/publish_codeberg_release.sh
|
||||||
TAG="v$VERSION"
|
|
||||||
APK="app/build/outputs/apk/release/app-release.apk"
|
|
||||||
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
|
|
||||||
ASSET_APK="calendula_v${VERSION}.apk"
|
|
||||||
ASSET_SUM="${ASSET_APK}.sha256"
|
|
||||||
cp "$APK" "/tmp/$ASSET_APK"
|
|
||||||
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
|
||||||
|
|
||||||
# Release notes: reuse the section extracted for the Gitea release,
|
|
||||||
# fall back to the CHANGELOG entry if that step's file is gone.
|
|
||||||
if [ ! -s release-notes.md ]; then
|
|
||||||
awk -v ver="$VERSION" '
|
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
|
||||||
/^## \[/ { flag = 0 }
|
|
||||||
flag' CHANGELOG.md > release-notes.md
|
|
||||||
sed -i -e '/./,$!d' release-notes.md
|
|
||||||
fi
|
|
||||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
|
||||||
# The pipeline creates the tag via the Gitea API, which the push mirror
|
|
||||||
# (sync_on_commit only fires on real git pushes) doesn't propagate
|
|
||||||
# promptly — so a release POST that carries a target_commitish can
|
|
||||||
# outrun the mirror and 500 on a commit/tag Codeberg hasn't received.
|
|
||||||
# Push the tag straight to Codeberg so it's guaranteed present, then
|
|
||||||
# attach the release to that existing tag with NO target_commitish
|
|
||||||
# (which is what triggered the 500).
|
|
||||||
git tag -f "$TAG" "$SHA"
|
|
||||||
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/calendula.git" \
|
|
||||||
"refs/tags/$TAG"
|
|
||||||
python3 - "$TAG" <<'PY' > cb-payload.json
|
|
||||||
import json, sys
|
|
||||||
print(json.dumps({
|
|
||||||
"tag_name": sys.argv[1],
|
|
||||||
"name": sys.argv[1],
|
|
||||||
"body": open("release-notes.md").read(),
|
|
||||||
"draft": False,
|
|
||||||
"prerelease": False,
|
|
||||||
}))
|
|
||||||
PY
|
|
||||||
# Create (or update) the release. Codeberg 500s on a POST/GET against a
|
|
||||||
# tag it has only just received — the release request outruns the
|
|
||||||
# indexing of the ref we pushed a moment ago — so a single attempt kept
|
|
||||||
# failing and skipping the mirror even though the very same call
|
|
||||||
# succeeds seconds later. Retry with backoff, and PATCH in place if a
|
|
||||||
# release already exists (re-run safe). A 5xx body still exits curl 0,
|
|
||||||
# so the loop, not `set -e`, controls the flow.
|
|
||||||
ID=""
|
|
||||||
for attempt in 1 2 3 4 5 6; do
|
|
||||||
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
|
|
||||||
if [ -n "$EXIST" ]; then
|
|
||||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @cb-payload.json "$API/releases/$EXIST"
|
|
||||||
ID="$EXIST"; break
|
|
||||||
fi
|
|
||||||
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @cb-payload.json "$API/releases")
|
|
||||||
echo "release POST attempt $attempt HTTP $CODE"
|
|
||||||
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
|
||||||
[ -n "$ID" ] && break
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
|
|
||||||
|
|
||||||
# Attach APK + checksum, replacing any prior asset of the same name.
|
|
||||||
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
|
||||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
|
||||||
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
|
|
||||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
|
||||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@/tmp/$A" \
|
|
||||||
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
|
||||||
done
|
|
||||||
echo "Published $TAG to Codeberg."
|
|
||||||
|
|
||||||
# Play takes an App Bundle, not the APK, so it is a second artifact from
|
# Play takes an App Bundle, not the APK, so it is a second artifact from
|
||||||
# the same source and the same signing config — not a repackage of the
|
# the same source and the same signing config — not a repackage of the
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
name: Renovate
|
name: Renovate
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
|
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
|
||||||
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
|
# same trigger release.yaml relies on), so a merged Renovate PR is followed
|
||||||
|
# within minutes by a run that rebases the sibling PRs it just conflicted —
|
||||||
|
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
|
||||||
|
# `schedule` window such a run only maintains existing branches
|
||||||
|
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
|
||||||
|
# to renovate/* branches, not main, so this cannot loop.
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
|
||||||
|
# in renovate.json5 — keep the two in step.
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 5 * * 1'
|
- cron: '0 5 * * 1'
|
||||||
# Manual run for an on-demand sweep from the Actions tab.
|
# Manual run for an on-demand sweep from the Actions tab.
|
||||||
|
|||||||
+220
@@ -5,6 +5,177 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [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]).
|
||||||
|
- **Long press an empty slot to draft an event.** In the day, 3-day and week views, a long press on free time drops a block of your default event length there. Move it while still holding, or let go and drag it or its handles into place, then tap the tick to open a new event at that time. The ✕, a tap elsewhere or going back throws the draft away ([#153]).
|
||||||
|
- **Tap a phone number in an event to call it.** Phone numbers in an event's title, location and description are now links. Tapping one opens your phone app with the number filled in, ready to call. A conference dial-in like "+1 646 558 8656,,850123#" brings its meeting ID along. Meeting IDs and PINs on their own, dates, times and amounts stay plain text. Long-pressing a field copies it, even when you press on a link ([#339]).
|
||||||
|
- **Formatted event descriptions read properly.** Descriptions from Outlook, Exchange and Google Calendar are often stored as HTML, which used to show up as a wall of tags. The event view now shows them as formatted text: bold, italics, lists and links work, table rows sit on one line each, and the fonts and colours the sender picked are left out so the text matches the app. Search matches the text you see. The edit form still shows the original, so saving an event never changes its formatting ([#271]).
|
||||||
|
- **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]).
|
||||||
|
- **Create an event from shared or selected text.** Share text to Calendula, or select text in another app and pick Create event from the selection menu, and the new-event form opens with it filled in. A shared subject becomes the title, otherwise the first line does, and the rest goes into the description. The event starts at the next full hour with your default length; Calendula doesn't try to read a date out of the text. If Calendula was opened just for this, saving or closing the form takes you back to the app you came from ([#306]).
|
||||||
|
- **Tapping an event can open the edit form.** Settings → Views → Events → Tapping an event opens can now be set to Edit form instead of Details. Taps in the views and in search then go straight to the editor for events you can edit; read-only events, reminder notifications and widgets still open the details. The editor has Duplicate, Share and Delete in its menu, and going back with unsaved changes asks whether to save, discard or keep editing ([#178]).
|
||||||
|
- **A black theme for AMOLED screens.** Settings → Appearance → Theme now has Black next to System, Light and Dark. It's the dark theme with pure black backgrounds, which saves battery on AMOLED screens and makes event colours stand out more. Day cells and the hour grid stay a shade above black so the grid lines still show, and sheets and menus still stand out from the page. It works with Material You colours too. The home-screen widgets aren't affected ([#181]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Event colours follow the theme you picked in Calendula.** With the app set to Dark on a phone in light mode, or the other way round, events took their colours from the phone's setting, so they could come out too pale or too dark. They now match the app's theme. Starting the app with a theme set in Calendula also no longer flashes the phone's light or dark background first ([#181]).
|
||||||
|
- **Events on the day the clocks change show at their real time.** In the day, 3-day and week views, events later on the day of a daylight saving change were drawn an hour off, so a 14:45 event showed at 15:45. They now sit at their own time. When the clocks go back, 02:00 to 03:00 happens twice: an event in that hour now stays in the right one when you move, resize or edit it, and the editor and event details add the zone, like CEST or CET, so you can tell the two apart ([#387]).
|
||||||
|
- **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
|
||||||
|
- **The store listing is called "Calendula: Calendar" again,** with "Calendar" in each store language. Since 2.22.0 the Play listing comes from the repository, whose title was only "Calendula". The app is unchanged ([#361]).
|
||||||
|
|
||||||
|
## [2.22.2] — 2026-10-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **2.22 reaches Google Play.** The Dutch store listing used the locale code `nl`, which Play rejects, so the Play uploads of 2.22.0 and 2.22.1 failed. It now uses `nl-NL`, and CI checks every store locale against the codes Play accepts. The app is unchanged from 2.22.1 ([#359]).
|
||||||
|
|
||||||
|
## [2.22.1] — 2026-10-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Tapping a link in an event's location opens it in the browser instead of a maps app.** Links are underlined, as in the description, and tapping the rest of the location still opens maps. A link with a capitalised `Https://`, or wrapped in `<…>` the way Outlook and Teams write them, opens correctly too. When no app can open a link, a short message says so instead of the tap doing nothing ([#357]).
|
||||||
|
|
||||||
|
## [2.22.0] — 2026-10-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Day, Week, and Month all page with a swipeable pager that follows your finger** instead of cutting between dates ([#131], [#336]).
|
||||||
|
- **Pages can open the split agenda.** Swipe up from a full month to reveal the compact grid and month agenda; tap a day to filter it, then swipe down to return. The transition gives haptic feedback ([#111]).
|
||||||
|
- **A multi-day view shows a configurable number of days side by side,** with today in the first column. Set the day count in Settings ([#193]).
|
||||||
|
- **A "Visible hours" setting fits a chosen range of hours to the screen** when Day, Week, or the new multi-day view opens; earlier and later hours stay a scroll away ([#322]).
|
||||||
|
- **Timeline views mark events that run outside the visible hours** with a coloured edge at the top or bottom, and keep them tappable underneath it ([#188]).
|
||||||
|
- **Week numbers open their week.** Tap a week number in the month grid to open Week at that row; Back returns to Month ([#197]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Day, multi-day, and Week share one timeline and morph into each other** when you switch views, instead of each view rebuilding its own ([#184]).
|
||||||
|
- **The back gesture previews the view you're returning to** and springs back to the current one if you cancel the gesture ([#185]).
|
||||||
|
- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]).
|
||||||
|
- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]).
|
||||||
|
- **All-day reminders due today or tomorrow say so** instead of spelling out the date ([#308]).
|
||||||
|
- **Store listing in every app language.** Google Play and F-Droid show the description in all 16 languages the app ships, and each release publishes the Play listing from the repository ([#285]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Titles and locations stop growing at 255 characters.** Some CalDAV servers (Open-Xchange among them) silently truncate longer values on sync; Calendula now caps them first and shows a counter once you're close ([#331]).
|
||||||
|
- **The "Calendar" launcher alias is translatable.** The "App name" setting's alternate label was hard-coded to English regardless of the app's language ([#251]).
|
||||||
|
- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]).
|
||||||
|
- **Editing one occurrence of a coloured Google series saves again.** Saving with "Only this event" kept you on the edit screen without saving anything ([#338]).
|
||||||
|
|
||||||
|
## [2.21.0] — 2026-09-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Setup makes sure you have a calendar to write to.** A fresh install could
|
||||||
|
finish onboarding and land on an empty grid with nothing saying why — either
|
||||||
|
because the device had no calendar at all, or because every calendar it had
|
||||||
|
was switched off. Setup now covers both: it offers to create a calendar on
|
||||||
|
this device or to add an account that syncs one, and it lists the calendars
|
||||||
|
that exist but are switched off so you can turn them back on ([#287]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The month grid gives its days more room.** Week numbers are set smaller and
|
||||||
|
their gutter is measured from the widest number it has to seat instead of
|
||||||
|
holding a fixed 40dp; event chips spend less of their width on padding; and
|
||||||
|
the labels in both are tracked more tightly, which is worth roughly a
|
||||||
|
character per chip ([#189], [#190], [#212], [#213]).
|
||||||
|
- **A month day shows as many events as it has height for.** The limit was a
|
||||||
|
flat three at every size, so a tall week row threw away space it could have
|
||||||
|
filled, while a cramped one drew a third chip its band could not hold and put
|
||||||
|
the "more" marker below the clip, where nothing showed the day held more at
|
||||||
|
all. It is measured per week row now, and a tablet or a landscape phone seats
|
||||||
|
more of them.
|
||||||
|
- **Split view no longer pads a short month out to six rows.** A five-row month
|
||||||
|
was handing the day pane a blank row and its gap — a sixth of the grid. The
|
||||||
|
grid now draws only the rows its month spans and the pane grows into the rest
|
||||||
|
([#162]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Short events show their title in week and day view.** A block needed 20dp
|
||||||
|
of height before it drew any title at all, which at fit-the-whole-day put the
|
||||||
|
cutoff around the half hour. The padding now tapers as the block shrinks, so
|
||||||
|
a short event keeps its name ([#289]).
|
||||||
|
- **A fully zoomed-out timeline sits still.** Rounding its height up left it as
|
||||||
|
much as a pixel per hour taller than the viewport those hours are meant to
|
||||||
|
fill — some 24px of leftover scroll, enough to bounce off Android's
|
||||||
|
overscroll stretch ([#290]).
|
||||||
|
- **Backup & restore says what is going on instead of coming up blank.** It
|
||||||
|
built its rows straight from the calendar list, so loading, a provider error,
|
||||||
|
and a device with nothing to export all rendered as an empty page with no
|
||||||
|
buttons on it ([#304]).
|
||||||
|
- **An event with no title reads in your language.** It was labelled "(Ohne
|
||||||
|
Titel)" on every screen but the event's own, whatever language the phone was
|
||||||
|
set to: the German text was chosen before the translated one could be
|
||||||
|
([#321]).
|
||||||
|
- **A calendar the system gives no name reads in your language.** The same
|
||||||
|
cause, in the calendar lists and pickers — and because the calendar editor
|
||||||
|
starts from that name, saving such a calendar wrote the German text into it
|
||||||
|
for real ([#329]).
|
||||||
|
|
||||||
|
## [2.20.4] — 2026-09-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The app no longer reopens the event you last came in on.** Opening an event
|
||||||
|
from a reminder or a home-screen widget made the app return to that same
|
||||||
|
event on every later launch — and show an error screen once the event had
|
||||||
|
been deleted. Android hands an app the launch it was started with again each
|
||||||
|
time it is rebuilt, and Calendula was acting on it every time instead of
|
||||||
|
once ([#309]).
|
||||||
|
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
|
||||||
|
today did nothing, because the agenda is browsed by scrolling rather than by
|
||||||
|
moving its date, and the button only moved the date. It now brings the list
|
||||||
|
back to today, and it stays offered while the list is scrolled away — before,
|
||||||
|
it was hidden in exactly the situation it is for ([#305]).
|
||||||
|
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
|
||||||
|
Browsing by category in an F-Droid client set to prefer that repo listed the
|
||||||
|
app under "Time" instead ([#294]).
|
||||||
|
|
||||||
|
## [2.20.3] — 2026-09-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A custom agenda range crashed the app in French.** Choosing a custom range
|
||||||
|
for the agenda — or simply typing a number into that field — closed the app on
|
||||||
|
any phone set to French. The French translation of the "%d days" label was
|
||||||
|
missing the wording French uses for counts of two and above, and Android has
|
||||||
|
nothing to fall back on when that particular form is absent, so every value
|
||||||
|
except one day was affected ([#297], [#298]).
|
||||||
|
|
||||||
|
## [2.20.2] — 2026-09-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Crash reports are filed in English.** Reporting a crash from a phone set to
|
||||||
|
another language prefilled the issue — title and body — in that language, so
|
||||||
|
a French crash arrived on Codeberg as a French issue ([#297], [#298]). The
|
||||||
|
text that is *posted* is now English for everyone; the dialog that asks you
|
||||||
|
to send it, and everything else you see in the app, is still translated.
|
||||||
|
|
||||||
|
## [2.20.1] — 2026-09-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **An empty calendar now explains itself.** "No calendars configured" offered
|
||||||
|
an "Open system calendar settings" button that opened nothing — every screen
|
||||||
|
wired it to the same reload that had just produced the empty state. It now
|
||||||
|
opens the system's account chooser, filtered to the providers that sync
|
||||||
|
calendars ([#239]).
|
||||||
|
- **Calendars that are all switched off no longer look like an empty month.**
|
||||||
|
When every calendar on the device is hidden at system level there is nothing
|
||||||
|
to draw, but the app said nothing at all: it only ever checked whether the
|
||||||
|
device had *no* calendars, so a device that had them and hid them fell
|
||||||
|
through to a blank grid with no hint. That case now says so, and points at
|
||||||
|
Settings → Calendars — the one screen that can switch a calendar back on. The
|
||||||
|
navigation drawer's calendar list drew the same conflation and is fixed with
|
||||||
|
it ([#239]).
|
||||||
|
|
||||||
## [2.20.0] — 2026-09-07
|
## [2.20.0] — 2026-09-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1654,3 +1825,52 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
|
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
|
||||||
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
|
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
|
||||||
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
|
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
|
||||||
|
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
||||||
|
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
||||||
|
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
|
||||||
|
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
||||||
|
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
|
||||||
|
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
|
||||||
|
[#287]: https://codeberg.org/jlmakiola/calendula/issues/287
|
||||||
|
[#189]: https://codeberg.org/jlmakiola/calendula/issues/189
|
||||||
|
[#190]: https://codeberg.org/jlmakiola/calendula/issues/190
|
||||||
|
[#212]: https://codeberg.org/jlmakiola/calendula/issues/212
|
||||||
|
[#213]: https://codeberg.org/jlmakiola/calendula/issues/213
|
||||||
|
[#162]: https://codeberg.org/jlmakiola/calendula/issues/162
|
||||||
|
[#289]: https://codeberg.org/jlmakiola/calendula/issues/289
|
||||||
|
[#290]: https://codeberg.org/jlmakiola/calendula/issues/290
|
||||||
|
[#304]: https://codeberg.org/jlmakiola/calendula/issues/304
|
||||||
|
[#321]: https://codeberg.org/jlmakiola/calendula/issues/321
|
||||||
|
[#329]: https://codeberg.org/jlmakiola/calendula/issues/329
|
||||||
|
[#285]: https://codeberg.org/jlmakiola/calendula/issues/285
|
||||||
|
[#338]: https://codeberg.org/jlmakiola/calendula/issues/338
|
||||||
|
[#111]: https://codeberg.org/jlmakiola/calendula/issues/111
|
||||||
|
[#131]: https://codeberg.org/jlmakiola/calendula/issues/131
|
||||||
|
[#142]: https://codeberg.org/jlmakiola/calendula/issues/142
|
||||||
|
[#184]: https://codeberg.org/jlmakiola/calendula/issues/184
|
||||||
|
[#185]: https://codeberg.org/jlmakiola/calendula/issues/185
|
||||||
|
[#188]: https://codeberg.org/jlmakiola/calendula/issues/188
|
||||||
|
[#193]: https://codeberg.org/jlmakiola/calendula/issues/193
|
||||||
|
[#197]: https://codeberg.org/jlmakiola/calendula/issues/197
|
||||||
|
[#241]: https://codeberg.org/jlmakiola/calendula/issues/241
|
||||||
|
[#251]: https://codeberg.org/jlmakiola/calendula/issues/251
|
||||||
|
[#291]: https://codeberg.org/jlmakiola/calendula/issues/291
|
||||||
|
[#308]: https://codeberg.org/jlmakiola/calendula/issues/308
|
||||||
|
[#322]: https://codeberg.org/jlmakiola/calendula/issues/322
|
||||||
|
[#331]: https://codeberg.org/jlmakiola/calendula/issues/331
|
||||||
|
[#336]: https://codeberg.org/jlmakiola/calendula/issues/336
|
||||||
|
[#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
|
||||||
|
[#153]: https://codeberg.org/jlmakiola/calendula/issues/153
|
||||||
|
[#339]: https://codeberg.org/jlmakiola/calendula/issues/339
|
||||||
|
[#387]: https://codeberg.org/jlmakiola/calendula/issues/387
|
||||||
|
[#271]: https://codeberg.org/jlmakiola/calendula/issues/271
|
||||||
|
[#181]: https://codeberg.org/jlmakiola/calendula/issues/181
|
||||||
|
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306
|
||||||
|
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
|
||||||
|
|||||||
+29
-29
@@ -1,13 +1,13 @@
|
|||||||
# Contributing to Calendula
|
# Contributing to Calendula
|
||||||
|
|
||||||
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
|
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
|
||||||
Android's `CalendarContract` — no app database, no sync stack, no network access.
|
Android's `CalendarContract`, with no app database, sync stack or network access.
|
||||||
That constraint shapes most review comments, so
|
That constraint shapes most review comments, so skim
|
||||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) before you write code. This file
|
||||||
code. This file is the practical how.
|
covers the practical side.
|
||||||
|
|
||||||
**[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** —
|
[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home for
|
||||||
issues, pull requests, releases. The self-hosted Gitea instance referenced in the
|
issues, pull requests and releases. The self-hosted Gitea instance referenced in the
|
||||||
release docs is build infrastructure only; there is nothing to contribute there.
|
release docs is build infrastructure only; there is nothing to contribute there.
|
||||||
Be decent to the people you meet in the tracker.
|
Be decent to the people you meet in the tracker.
|
||||||
|
|
||||||
@@ -18,20 +18,20 @@ Be decent to the people you meet in the tracker.
|
|||||||
| Add a feature | **Open an issue first** and wait for a go-ahead |
|
| Add a feature | **Open an issue first** and wait for a go-ahead |
|
||||||
| Fix a bug | Open an issue, then a pull request |
|
| Fix a bug | Open an issue, then a pull request |
|
||||||
| Fix a typo, a comment, or docs | Just open the pull request |
|
| Fix a typo, a comment, or docs | Just open the pull request |
|
||||||
| Add or fix a translation | **Don't** — [use Weblate](#translations) |
|
| Add or fix a translation | Use [Weblate](#translations), not a pull request |
|
||||||
|
|
||||||
Features get an opinion before they get code: whether Calendula should do a
|
Features get an opinion before they get code: whether Calendula should do a
|
||||||
thing at all is the one decision a patch can't make. A feature PR that arrives
|
thing at all is the one decision a patch can't make. A feature PR that arrives
|
||||||
without a discussed issue may be closed unmerged even when the code is good —
|
without a discussed issue may be closed unmerged even when the code is good, so
|
||||||
please don't spend a weekend on one first.
|
please don't spend a weekend on one first.
|
||||||
|
|
||||||
Bugs are more straightforward, but still start with an issue: it's what carries
|
Bugs are more straightforward, but still start with an issue: it's what carries
|
||||||
the milestone and gives the changelog something to link.
|
the milestone and gives the changelog something to link.
|
||||||
|
|
||||||
Issue templates cover bug, crash, feature and question. For a crash, let the app
|
Issue templates cover bug, crash, feature and question. For a crash, let the app
|
||||||
do the work — **Settings → Report a problem**, or the prompt shown after a crash,
|
do the work: *Settings → Report a problem*, or the prompt shown after a crash,
|
||||||
captures the stack trace and prefills the form. The report contains app, Android
|
captures the stack trace and prefills the form. The report contains app, Android
|
||||||
and device versions plus the trace; no calendar content, no personal data.
|
and device versions plus the trace, and no calendar content or personal data.
|
||||||
|
|
||||||
## Which branch to target
|
## Which branch to target
|
||||||
|
|
||||||
@@ -45,20 +45,20 @@ Once your issue has a milestone, that milestone names your branch:
|
|||||||
| `2.18.0` | `release/v2.18.0` |
|
| `2.18.0` | `release/v2.18.0` |
|
||||||
|
|
||||||
Every milestone has a matching branch. If it's somehow missing, target `main` and
|
Every milestone has a matching branch. If it's somehow missing, target `main` and
|
||||||
mention it in the PR — it will be retargeted. Don't pick an older release branch:
|
mention it in the PR, and it will be retargeted. Don't pick an older release branch:
|
||||||
they're kept after shipping, so the newest one isn't necessarily yours.
|
they're kept after shipping, so the newest one isn't necessarily yours.
|
||||||
|
|
||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
**Never edit a `values-*/strings.xml` file in a pull request** — German included.
|
**Never edit a `values-*/strings.xml` file in a pull request**, German included.
|
||||||
Translations are owned by a self-hosted Weblate that writes to this repository
|
Translations are owned by a self-hosted Weblate that writes to this repository
|
||||||
directly, and a hand-edit is overwritten on the next sync.
|
directly, and a hand-edit is overwritten on the next sync.
|
||||||
|
|
||||||
→ **[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)
|
||||||
|
|
||||||
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
|
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
|
||||||
picks it up and offers it to translators. Partial translations are expected and
|
picks it up and offers it to translators. Partial translations are expected and
|
||||||
fine — missing keys are informational. Stale and orphaned keys are not, so run
|
fine; missing keys are informational. Stale and orphaned keys are not, so run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 scripts/check_translations.py
|
python3 scripts/check_translations.py
|
||||||
@@ -67,7 +67,7 @@ python3 scripts/check_translations.py
|
|||||||
before pushing. It reports those more clearly than lint's `MissingTranslation`
|
before pushing. It reports those more clearly than lint's `MissingTranslation`
|
||||||
does.
|
does.
|
||||||
|
|
||||||
## Build & test
|
## Build and test
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
|
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
|
||||||
@@ -77,11 +77,11 @@ The `floret-kit` submodule is a composite build compiled from source. An
|
|||||||
existing clone needs `git submodule update --init --recursive`, or nothing
|
existing clone needs `git submodule update --init --recursive`, or nothing
|
||||||
resolves.
|
resolves.
|
||||||
|
|
||||||
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. Set
|
- **JDK 17**, not newer: the Android Gradle Plugin requires exactly 17. Set
|
||||||
`JAVA_HOME` if your default differs.
|
`JAVA_HOME` if your default differs.
|
||||||
- **Android SDK** — platform 37 (`compileSdk`) and build-tools 36.0.0, located
|
- **Android SDK**: platform 37 (`compileSdk`) and build-tools 36.0.0, located
|
||||||
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
|
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
|
||||||
go the `local.properties` route the included build needs its own copy at
|
go the `local.properties` route, the included build needs its own copy at
|
||||||
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
|
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
|
||||||
easier path.
|
easier path.
|
||||||
|
|
||||||
@@ -109,21 +109,21 @@ These are the ones that turn into review comments.
|
|||||||
|
|
||||||
1. **No network.** Calendula holds no `INTERNET` permission, and that's a
|
1. **No network.** Calendula holds no `INTERNET` permission, and that's a
|
||||||
feature rather than an oversight. Anything that would need one is a product
|
feature rather than an oversight. Anything that would need one is a product
|
||||||
decision before it's a patch — the crash reporter deliberately opens a
|
decision before it's a patch. The crash reporter opens a prefilled web issue
|
||||||
prefilled web issue instead of posting anything itself.
|
on purpose instead of posting anything itself.
|
||||||
2. **The provider is the only database.** No Room, no cache, no local mirror of
|
2. **The provider is the only database.** No Room, no cache, no local mirror of
|
||||||
events. `CalendarContract` is the single source of truth, which is also why
|
events. `CalendarContract` is the single source of truth, which is also why
|
||||||
externally synced changes work for free.
|
externally synced changes work for free.
|
||||||
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
|
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
|
||||||
views recompose from fresh provider state. Hand-patching a list after saving
|
views recompose from fresh provider state. Hand-patching a list after saving
|
||||||
appears to work, then quietly diverges from what the provider actually stored.
|
appears to work, then drifts away from what the provider stored.
|
||||||
4. **`domain/` has no Android imports.** Models, validation, recurrence
|
4. **`domain/` has no Android imports.** Models, validation, recurrence
|
||||||
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
|
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
|
||||||
remain JVM-testable.
|
remain JVM-testable.
|
||||||
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
|
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
|
||||||
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
|
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
|
||||||
through `ColumnReader` instead of cursors. Instrumented tests are a last
|
through `ColumnReader` instead of cursors. Instrumented tests are a last
|
||||||
resort, not a default.
|
resort.
|
||||||
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
|
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
|
||||||
DURATION, exception URIs, series splits), save-conflict detection and reminder
|
DURATION, exception URIs, series splits), save-conflict detection and reminder
|
||||||
delivery (post-before-mark) follow provider-driven rules that are documented
|
delivery (post-before-mark) follow provider-driven rules that are documented
|
||||||
@@ -138,14 +138,14 @@ These are the ones that turn into review comments.
|
|||||||
## UI conventions
|
## UI conventions
|
||||||
|
|
||||||
Material 3 Expressive throughout, built from the system's own tokens and
|
Material 3 Expressive throughout, built from the system's own tokens and
|
||||||
components — colour-scheme tokens rather than hardcoded colours, `ListItem` for
|
components: colour-scheme tokens rather than hardcoded colours, `ListItem` for
|
||||||
settings rows.
|
settings rows.
|
||||||
|
|
||||||
**Selection pickers are full-screen.** Every browse-style "choose one" surface
|
**Selection pickers are full-screen.** Every browse-style "choose one" surface
|
||||||
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
|
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
|
||||||
extra action passes it through the picker's `actions` slot. The exception is the
|
extra action passes it through the picker's `actions` slot. The exception is the
|
||||||
recurring-scope chooser (*this / this and following / all*), which stays a
|
recurring-scope chooser (*this / this and following / all*), which stays a
|
||||||
compact dialog — a two- or three-option decision reads better as a popup than as
|
compact dialog, because a two- or three-option decision reads better as a popup than as
|
||||||
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
|
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
|
||||||
or text-list dialogs aren't used at all.
|
or text-list dialogs aren't used at all.
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ Shared UI machinery lives in the `floret-kit` submodule and has
|
|||||||
changing it means a pull request against that repository plus a submodule bump
|
changing it means a pull request against that repository plus a submodule bump
|
||||||
here.
|
here.
|
||||||
|
|
||||||
## Commits & pull requests
|
## Commits and pull requests
|
||||||
|
|
||||||
Conventional commits, scoped to the area you touched:
|
Conventional commits, scoped to the area you touched:
|
||||||
|
|
||||||
@@ -165,13 +165,13 @@ docs(architecture): record what the second review pass changed
|
|||||||
```
|
```
|
||||||
|
|
||||||
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
|
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
|
||||||
`revert`. Reference the issue in the subject or the body. Keep commits small —
|
`revert`. Reference the issue in the subject or the body. Keep commits small.
|
||||||
small commits revert cleanly, which matters more here than a tidy history.
|
Small commits revert cleanly, which matters more here than a tidy history.
|
||||||
|
|
||||||
If your change is user-visible, add an entry under `## [Unreleased]` in
|
If your change is user-visible, add an entry under `## [Unreleased]` in
|
||||||
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
|
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
|
||||||
what changed *for the person using the app*, and why, not what changed in the
|
what changed *for the person using the app*, and why. Code changes don't go
|
||||||
code. Link the issue and add its reference at the bottom of the file. It may get
|
there. Link the issue and add its reference at the bottom of the file. It may get
|
||||||
reworded when the release is cut, so don't agonise over it.
|
reworded when the release is cut, so don't agonise over it.
|
||||||
|
|
||||||
Please don't commit planning or design documents. Code, tests, architecture notes
|
Please don't commit planning or design documents. Code, tests, architecture notes
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
<h1>Calendula</h1>
|
<h1>Calendula</h1>
|
||||||
|
|
||||||
<p><strong>A modern Material 3 Expressive calendar for Android.</strong><br>
|
<p><strong>A Material 3 Expressive calendar for Android.</strong><br>
|
||||||
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
It works on top of the system calendar and has no network access.</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
<a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||||
@@ -26,84 +26,89 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="16%" alt="Week view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="13%" alt="Week view">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="16%" alt="Month view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="13%" alt="Month view">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png" width="16%" alt="Day view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-agenda.png" width="13%" alt="Agenda view">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="16%" alt="Event detail">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="13%" alt="Event detail">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png" width="16%" alt="Agenda view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-recurring.png" width="13%" alt="Editing a recurring event">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="16%" alt="Reminder onboarding">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-calendars.png" width="13%" alt="Calendars from your accounts">
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07-dark.png" width="13%" alt="Dark theme">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Calendula is named after the flower whose name — like the word *calendar* —
|
Calendula is named after the flower. Both its name and the word *calendar*
|
||||||
comes from the Latin *kalendae*, the first day of the month. It lives
|
come from the Latin *kalendae*, the first day of the month.
|
||||||
entirely on top of Android's `CalendarContract`: any calendar synced to your
|
|
||||||
device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply
|
|
||||||
appears, and everything you create or edit syncs back the same way. No own
|
|
||||||
database, no sync stack reinvented.
|
|
||||||
|
|
||||||
## ✨ Features
|
The app works directly on Android's `CalendarContract`. Any calendar synced to
|
||||||
|
your device (CalDAV via DAVx5, Google, local, WebCal subscriptions) shows up in
|
||||||
|
Calendula, and whatever you create or edit syncs back the same way. Calendula
|
||||||
|
has no database or sync code of its own.
|
||||||
|
|
||||||
**Calendar**
|
## Features
|
||||||
|
|
||||||
- Month, week, and day views with a one-tap view switcher
|
### Calendar
|
||||||
- Full event details — attendees and their responses, reminders, recurrence
|
|
||||||
(humanized), availability, visibility, foreign time zones
|
|
||||||
- Per-calendar visibility toggle, grouped by account
|
|
||||||
|
|
||||||
**Editing**
|
- Month, week and day views, with a switcher in the top bar
|
||||||
|
- Event details with attendees and their responses, reminders, recurrence in
|
||||||
|
plain words, availability, visibility and foreign time zones
|
||||||
|
- Calendars can be shown or hidden one by one, grouped by account
|
||||||
|
- Your contacts' birthdays, anniversaries and other dates, if you turn it on.
|
||||||
|
Contacts are read on the device and never changed.
|
||||||
|
|
||||||
- Create, edit, and delete events — including recurring events with scoped
|
### Editing
|
||||||
writes: *only this event*, *this and all following*, or *the whole series*
|
|
||||||
- Recurrence picker with one-tap presets and custom rules (interval, weekday
|
|
||||||
toggles, end conditions); rules it can't express are preserved verbatim
|
|
||||||
- Conflict-safe saves: if an event changed elsewhere while you were editing,
|
|
||||||
Calendula asks instead of silently overwriting
|
|
||||||
- Read-only calendars (WebCal, birthdays) are detected and respected
|
|
||||||
|
|
||||||
**Reminders**
|
- Create, edit and delete events. For a recurring event you choose whether a
|
||||||
|
change applies to *only this event*, *this and all following*, or *the whole
|
||||||
|
series*.
|
||||||
|
- The repeat picker has presets and custom rules (interval, weekdays, end
|
||||||
|
condition). Rules it can't show are kept as they are.
|
||||||
|
- If an event changed elsewhere while you were editing it, Calendula asks
|
||||||
|
before saving over it.
|
||||||
|
- Read-only calendars such as WebCal subscriptions and birthdays are detected
|
||||||
|
and can't be edited by accident.
|
||||||
|
|
||||||
- Event reminders delivered by Calendula itself as notifications —
|
### Reminders
|
||||||
essential when it's your only calendar app, since Android delegates
|
|
||||||
reminder delivery to calendar apps
|
|
||||||
- Tap a reminder to land on the event
|
|
||||||
|
|
||||||
**Design & privacy**
|
- Calendula shows event reminders as notifications itself. Android leaves
|
||||||
|
reminder delivery to calendar apps, so if Calendula is your only one, this is
|
||||||
|
where your reminders come from.
|
||||||
|
- Tapping a reminder opens the event.
|
||||||
|
|
||||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
### Design and privacy
|
||||||
expressive motion and shapes, light/dark theme
|
|
||||||
- English and German UI plus community translations (Spanish, French, Italian,
|
|
||||||
Polish, and more in progress), per-app language setting — and [open to more
|
|
||||||
languages](#-translations)
|
|
||||||
- **Zero telemetry, zero analytics, no internet permission** — your data
|
|
||||||
never leaves the device
|
|
||||||
|
|
||||||
## 📦 Install
|
- Material 3 Expressive throughout, with dynamic color on Android 12+, light
|
||||||
|
and dark themes, and expressive motion and shapes
|
||||||
|
- Available in 16 languages, with a per-app language setting.
|
||||||
|
[Help translate](#translations).
|
||||||
|
- No telemetry, no analytics and no internet permission. Your data stays on
|
||||||
|
the device.
|
||||||
|
|
||||||
Pick whichever channel you already use — they all install the same app:
|
## Install
|
||||||
|
|
||||||
|
All channels install the same app:
|
||||||
|
|
||||||
| Channel | Updates | Notes |
|
| Channel | Updates | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
|
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
|
||||||
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
|
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; add the repo once |
|
||||||
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium |
|
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automatic with Obtainium |
|
||||||
| [Google Play](#google-play) | Per release | Signed with Google's key — see below |
|
| [Google Play](#google-play) | Per release | Signed with Google's key, see below |
|
||||||
| [Build from source](#build-from-source) | Whenever you build | Full control |
|
| [Build from source](#build-from-source) | Whenever you build | Full control |
|
||||||
|
|
||||||
### F-Droid (recommended)
|
### F-Droid (recommended)
|
||||||
|
|
||||||
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
Calendula is in the official [F-Droid](https://f-droid.org) repository. Search
|
||||||
just search for **Calendula** in any F-Droid client, or
|
for Calendula in any F-Droid client, or
|
||||||
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
||||||
|
|
||||||
F-Droid rebuilds from source on its own schedule, so a new version usually
|
F-Droid builds from source on its own schedule, so a new version usually shows
|
||||||
shows up there a few days after release.
|
up there a few days after release.
|
||||||
|
|
||||||
### Self-hosted F-Droid repo (fastest updates)
|
### Self-hosted F-Droid repo (fastest updates)
|
||||||
|
|
||||||
Every release is built, signed, and published to a self-hosted F-Droid
|
The release pipeline builds, signs and publishes every release to a
|
||||||
repository as part of the release pipeline, so it lands there first. Add it once
|
self-hosted F-Droid repository, so new versions land there first. Add it once
|
||||||
and your F-Droid client handles updates from then on:
|
and your F-Droid client handles updates from then on:
|
||||||
|
|
||||||
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||||
@@ -117,76 +122,87 @@ and your F-Droid client handles updates from then on:
|
|||||||
fingerprint (SHA-256):
|
fingerprint (SHA-256):
|
||||||
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
||||||
|
|
||||||
2. Refresh, search for **Calendula**, install.
|
2. Refresh, search for Calendula and install it.
|
||||||
|
|
||||||
### Codeberg release / Obtainium
|
### Codeberg release / Obtainium
|
||||||
|
|
||||||
If you'd rather not use F-Droid at all, every release is also published on
|
Every release is also on
|
||||||
**[Codeberg](https://codeberg.org/jlmakiola/calendula/releases)** with the
|
[Codeberg](https://codeberg.org/jlmakiola/calendula/releases) with the signed
|
||||||
signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — download
|
APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum. Download it and install
|
||||||
and install it directly.
|
it directly.
|
||||||
|
|
||||||
For automatic updates from that channel, use
|
For automatic updates from Codeberg, use
|
||||||
**[Obtainium](https://github.com/ImranR98/Obtainium)** — on the phone,
|
[Obtainium](https://github.com/ImranR98/Obtainium). On your phone,
|
||||||
**[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula)**,
|
[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula),
|
||||||
or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
|
or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`,
|
||||||
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
|
then *Add*. Obtainium then watches the releases and tells you when a new one
|
||||||
one appears.
|
is out. Betas of upcoming versions are published there too, as pre-releases; to
|
||||||
|
test them, switch on *Include prereleases* for Calendula in Obtainium.
|
||||||
|
|
||||||
### Google Play
|
### Google Play
|
||||||
|
|
||||||
Calendula is live on Google Play:
|
Calendula is on
|
||||||
**[play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula)**.
|
[Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula).
|
||||||
|
|
||||||
Play builds are signed with Google's key rather than mine, so switching between
|
Play builds are signed with Google's key rather than mine, so switching between
|
||||||
Play and any other channel requires an uninstall (and with it, a fresh start for
|
Play and any other channel means uninstalling first. You lose the app settings.
|
||||||
app settings — your events live in the system calendar and are unaffected).
|
Your events live in the system calendar and aren't affected.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
The build is a plain Gradle build with no proprietary dependencies — see
|
It's a plain Gradle build with no proprietary dependencies. See
|
||||||
**[docs/BUILDING.md](docs/BUILDING.md)** (note the `floret-kit` submodule).
|
[docs/BUILDING.md](docs/BUILDING.md), and note the `floret-kit` submodule.
|
||||||
|
|
||||||
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
|
<sub>Official F-Droid, the self-hosted repo and the Codeberg releases share the
|
||||||
the same signing key, so you can switch freely between them without
|
same signing key, so you can switch between them without reinstalling. Google
|
||||||
reinstalling. Google Play is the exception — see above.</sub>
|
Play is the exception (see above).</sub>
|
||||||
|
|
||||||
## 📚 Documentation
|
## Documentation
|
||||||
|
|
||||||
- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch
|
- [Contributing](CONTRIBUTING.md): how to report bugs, propose features and send patches
|
||||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
- [Building from source](docs/BUILDING.md): requirements and Gradle tasks
|
||||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
- [Architecture](docs/ARCHITECTURE.md): the layers and the main pipelines
|
||||||
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
|
- [Milestones](https://codeberg.org/jlmakiola/calendula/milestones): what has shipped and what's next
|
||||||
|
- [Privacy policy](docs/PRIVACY.md): the source of the page at
|
||||||
|
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
|
||||||
|
|
||||||
## 🤝 Contributing
|
## Contributing
|
||||||
|
|
||||||
Bug reports, ideas, and patches are all welcome on
|
Bug reports, ideas and patches are welcome on
|
||||||
**[Codeberg](https://codeberg.org/jlmakiola/calendula/issues)**.
|
[Codeberg](https://codeberg.org/jlmakiola/calendula/issues).
|
||||||
|
|
||||||
The short version: **start with an issue.** Features get a yes-or-no before they
|
Start with an issue. A feature gets a yes or no before anyone writes code, and
|
||||||
get code, and both features and bugs are assigned a milestone whose
|
features and bugs are assigned a milestone whose `release/vX.Y.Z` branch your
|
||||||
`release/vX.Y.Z` branch your pull request then targets. Typo and docs fixes can
|
pull request targets. Typo and docs fixes can go straight to a pull request.
|
||||||
skip straight to a pull request. Translations don't go through pull requests at
|
Translations go through [Weblate](#translations), not pull requests.
|
||||||
all — [Weblate owns them](#-translations).
|
|
||||||
|
|
||||||
Read **[CONTRIBUTING.md](CONTRIBUTING.md)** before writing code: it covers the
|
Read [CONTRIBUTING.md](CONTRIBUTING.md) before writing code. It covers the
|
||||||
workflow, the build (note the `floret-kit` submodule), and the architectural
|
workflow, the build (including the `floret-kit` submodule) and the
|
||||||
rules a change is reviewed against.
|
architectural rules changes are reviewed against.
|
||||||
|
|
||||||
## 🌍 Translations
|
## Translations
|
||||||
|
|
||||||
Calendula ships in English and German, with community translations in Arabic,
|
Calendula is written in English. Translators on Weblate have added Arabic,
|
||||||
Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying
|
Belarusian, Chinese (Simplified), Czech, French, German, Hungarian, Italian,
|
||||||
degrees of completeness — partial is fine, untranslated strings simply fall back
|
Japanese, Polish, Portuguese (Portugal and Brazil), Russian, Slovak and Spanish,
|
||||||
to English. You're warmly invited to add or finish your language. Translations
|
some of them partly. Untranslated strings fall back to English, so a partial
|
||||||
are managed on a self-hosted **Weblate**:
|
translation is fine. Translations are managed on a self-hosted Weblate:
|
||||||
|
|
||||||
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
[Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)
|
||||||
|
|
||||||
No coding needed — register on the Weblate server, pick (or request) a language,
|
You don't need to code. Register on the Weblate server, pick a language (or
|
||||||
and translate the strings in your browser. You can also reach this link in the
|
request one) and translate in your browser. The app links here from the top of
|
||||||
app from the top of **Settings → App language**.
|
*Settings → App language*.
|
||||||
|
|
||||||
## 📜 License
|
## Privacy
|
||||||
|
|
||||||
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
Calendula has no analytics, tracking, ads, crash-reporting SDK or third-party
|
||||||
|
services. It doesn't have the internet permission, so it can't send anything
|
||||||
|
anywhere. Your events stay in Android's own calendar store.
|
||||||
|
|
||||||
|
[Privacy policy](https://jeanlucmakiola.de/calendula/privacy), written in
|
||||||
|
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE), Jean-Luc Makiola, 2026
|
||||||
|
|||||||
+20
-10
@@ -26,10 +26,14 @@ android {
|
|||||||
// These committed values ARE the source of truth for a release: merging
|
// These committed values ARE the source of truth for a release: merging
|
||||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
|
||||||
versionCode = 22000
|
// versionCode is derived from versionName by scripts/version_info.sh
|
||||||
versionName = "2.20.0"
|
// (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 = 2230004
|
||||||
|
versionName = "2.23.0-beta.4"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -113,12 +117,16 @@ android {
|
|||||||
lint {
|
lint {
|
||||||
// Community translations are expected to be partial — a missing string
|
// Community translations are expected to be partial — a missing string
|
||||||
// falls back to the English base at runtime — so don't fail the build on
|
// falls back to the English base at runtime — so don't fail the build on
|
||||||
// it. Likewise a translated <plurals> may not fill every CLDR quantity
|
// it. A translated <plurals> may likewise skip a CLDR quantity form its
|
||||||
// form its locale defines (e.g. Arabic needs "zero"); the missing form
|
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for
|
||||||
// falls back to "other" at runtime, so MissingQuantity is informational
|
// any form it cannot find, so MissingQuantity is informational too.
|
||||||
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
// What a translation must NOT skip is "other" itself — nothing falls back
|
||||||
// check_translations.py guards the same invariants with clearer,
|
// for that one, not even the base locale, so it throws
|
||||||
// translator-facing messages.
|
// Resources$NotFoundException at runtime (Codeberg #297/#298).
|
||||||
|
// MissingQuantity doesn't tell the two cases apart, so that invariant is
|
||||||
|
// enforced by scripts/check_translations.py instead, with a clearer
|
||||||
|
// translator-facing message. Stale/extra keys (ExtraTranslation) stay
|
||||||
|
// fatal.
|
||||||
informational += listOf("MissingTranslation", "MissingQuantity")
|
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +175,9 @@ dependencies {
|
|||||||
implementation("de.jeanlucmakiola.floret:core-time")
|
implementation("de.jeanlucmakiola.floret:core-time")
|
||||||
implementation("de.jeanlucmakiola.floret:core-locale")
|
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||||
implementation("de.jeanlucmakiola.floret:core-crash")
|
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-di")
|
||||||
implementation("de.jeanlucmakiola.floret:core-reminders")
|
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-android")
|
||||||
implementation("de.jeanlucmakiola.floret:identity")
|
implementation("de.jeanlucmakiola.floret:identity")
|
||||||
implementation("de.jeanlucmakiola.floret:components")
|
implementation("de.jeanlucmakiola.floret:components")
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.rule.GrantPermissionRule
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moving a synced series keeps its deleted and edited occurrences, although the
|
||||||
|
* moved row has no `_sync_id` for exception rows to attach to.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class MoveEventExceptionsTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
|
||||||
|
Manifest.permission.READ_CALENDAR,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
private val resolver = context.contentResolver
|
||||||
|
private val ds = AndroidCalendarDataSource(context)
|
||||||
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
private val day = 86_400_000L
|
||||||
|
private val start = 1_893_492_000_000L // 2030-01-01T10:00Z
|
||||||
|
private val hour = 3_600_000L
|
||||||
|
|
||||||
|
/** "day offset:title" of every instance on [calendarId]. */
|
||||||
|
private fun occurrences(calendarId: Long): List<String> =
|
||||||
|
ds.instances(start - day, start + 10 * day)
|
||||||
|
.filter { it.calendarId == calendarId }
|
||||||
|
.map { "${(it.start.toEpochMilliseconds() - start) / day}:${it.title}" }
|
||||||
|
.sorted()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movedSeriesKeepsCancelledAndModifiedOccurrences() {
|
||||||
|
val a = ds.createLocalCalendar("move-test-a", 0xFF336699.toInt(), null)
|
||||||
|
val b = ds.createLocalCalendar("move-test-b", 0xFF993366.toInt(), null)
|
||||||
|
try {
|
||||||
|
val src = insertSyncedSeries(a)
|
||||||
|
ds.deleteOccurrence(src, start + 2 * day)
|
||||||
|
val occurrence = ds.eventDetail(src, 540)!!.toEditForm(start + 3 * day, start + 3 * day + hour, zone)
|
||||||
|
ds.updateOccurrence(src, start + 3 * day, occurrence, occurrence.copy(title = "edited"), 540)
|
||||||
|
val expected = listOf("0:series", "1:series", "3:edited", "4:series")
|
||||||
|
assertThat(occurrences(a)).isEqualTo(expected)
|
||||||
|
|
||||||
|
val form = ds.eventDetail(src, 540)!!.toEditForm(start, start + hour, zone)
|
||||||
|
ds.moveEvent(src, b, form, form, 540)
|
||||||
|
|
||||||
|
assertThat(occurrences(a)).isEmpty()
|
||||||
|
assertThat(occurrences(b)).isEqualTo(expected)
|
||||||
|
} finally {
|
||||||
|
ds.deleteCalendar(a)
|
||||||
|
ds.deleteCalendar(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A daily series carrying a `_sync_id`, so its exceptions are real exception rows. */
|
||||||
|
private fun insertSyncedSeries(calendarId: Long): Long {
|
||||||
|
val accountName = resolver.query(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||||
|
arrayOf(CalendarContract.Calendars.ACCOUNT_NAME), null, null, null,
|
||||||
|
)!!.use { it.moveToFirst(); it.getString(0) }
|
||||||
|
val uri = CalendarContract.Events.CONTENT_URI.buildUpon()
|
||||||
|
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||||
|
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, accountName)
|
||||||
|
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
|
.build()
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||||
|
put(CalendarContract.Events.TITLE, "series")
|
||||||
|
put(CalendarContract.Events.DTSTART, start)
|
||||||
|
put(CalendarContract.Events.DURATION, "P3600S")
|
||||||
|
put(CalendarContract.Events.RRULE, "FREQ=DAILY;COUNT=5")
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, "UTC")
|
||||||
|
put(CalendarContract.Events._SYNC_ID, "move-test-series")
|
||||||
|
}
|
||||||
|
return ContentUris.parseId(resolver.insert(uri, values)!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform phone matcher on real text. It reads the region off the SIM,
|
||||||
|
* so these stick to international numbers and to text that must never match
|
||||||
|
* anywhere — domestic formats depend on the device they run on.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PhoneLinksPlatformTest {
|
||||||
|
|
||||||
|
private fun numbers(text: String) = findPhoneLinks(text).map { it.number }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun internationalNumbers_areFound() {
|
||||||
|
assertThat(numbers("Call +49 30 1234567 or +1 646-558-8656"))
|
||||||
|
.containsExactly("+49301234567", "+16465588656")
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trunkZero_isDroppedWithOrWithoutSpace() {
|
||||||
|
assertThat(numbers("+49 (0)151 2345678")).containsExactly("+491512345678")
|
||||||
|
assertThat(numbers("+49 (0) 151 2345678")).containsExactly("+491512345678")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun neighbouringNumbers_doNotJoinTheNumber() {
|
||||||
|
assertThat(numbers("Start 10:00 +49 151 2345678")).containsExactly("+491512345678")
|
||||||
|
assertThat(numbers("Call +49 151 2345678 2 times")).containsExactly("+491512345678")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun conferenceDialIn_keepsMeetingId() {
|
||||||
|
assertThat(numbers("One tap mobile: +16465588656,,85012345678# US"))
|
||||||
|
.containsExactly("+16465588656,,85012345678#")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun meetingIds_areNotNumbers() {
|
||||||
|
assertThat(numbers("Meeting ID: 850 1234 5678")).isEmpty()
|
||||||
|
assertThat(numbers("Conference ID: 123 456 789#")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun datesAndTimes_areNotNumbers() {
|
||||||
|
assertThat(numbers("2026-10-14 10:00")).isEmpty()
|
||||||
|
assertThat(numbers("Am 14.10.2026 10:00 Uhr")).isEmpty()
|
||||||
|
assertThat(numbers("14.10.26 - 16.10.26")).isEmpty()
|
||||||
|
assertThat(numbers("10:00 - 11:30")).isEmpty()
|
||||||
|
assertThat(numbers("Schuljahr 2025/2026, Season 2025-2026")).isEmpty()
|
||||||
|
assertThat(numbers("1.10.2026 - 3.10.2026")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun amounts_areNotNumbers() {
|
||||||
|
assertThat(numbers("Budget 1.250.000 EUR, 1 250 000 EUR")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isbn_isNotANumber() {
|
||||||
|
assertThat(numbers("ISBN 978-3-16-148410-0")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extension_becomesAPause() {
|
||||||
|
assertThat(numbers("+1 212 555 1234 ext. 567")).containsExactly("+12125551234,567")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun webexAccessCode_isNotANumber() {
|
||||||
|
assertThat(numbers("Meeting number (access code): 2732 123 4567")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun commaSeparatedNumbers_stayApart() {
|
||||||
|
assertThat(numbers("+49 30 1234567,+49 151 2345678"))
|
||||||
|
.containsExactly("+49301234567", "+491512345678")
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dottedTimes_areNotNumbers() {
|
||||||
|
assertThat(numbers("Beginn 9.30 – 12.00 Uhr")).isEmpty()
|
||||||
|
assertThat(numbers("10.00-11.30")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun schemelessUrlDigits_areNotNumbers() {
|
||||||
|
assertThat(numbers("zoom.us/j/85012345678")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun webLinkDigits_areNotNumbers() {
|
||||||
|
assertThat(numbers("https://zoom.us/j/85012345678?pwd=1")).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,6 +151,19 @@
|
|||||||
<data android:mimeType="application/ics" />
|
<data android:mimeType="application/ics" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Text shared or selected in another app opens a prefilled new
|
||||||
|
event (MainActivity.sharedTextExtrasOrNull, issue #306). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter android:label="@string/process_text_create_event">
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
||||||
to create a new event, the way the AOSP calendar accepts it:
|
to create a new event, the way the AOSP calendar accepts it:
|
||||||
ACTION_INSERT on the events *dir* mime type, carrying the new
|
ACTION_INSERT on the events *dir* mime type, carrying the new
|
||||||
|
|||||||
@@ -32,13 +32,15 @@ class CalendulaApp : Application() {
|
|||||||
// respecting, on-device; the user submits the report by hand). The
|
// respecting, on-device; the user submits the report by hand). The
|
||||||
// capture/loop-detection/report machinery lives in floret-kit's
|
// capture/loop-detection/report machinery lives in floret-kit's
|
||||||
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
||||||
|
// The prefilled issue's own title and body are the kit's, in English:
|
||||||
|
// they are posted to the tracker, so they don't follow the device
|
||||||
|
// language the way the dialog around them does.
|
||||||
CrashReporter.install(
|
CrashReporter.install(
|
||||||
this,
|
this,
|
||||||
CrashConfig(
|
CrashConfig(
|
||||||
appLabel = getString(R.string.app_name),
|
appLabel = getString(R.string.app_name),
|
||||||
newIssueUrl = getString(R.string.report_issue_url),
|
newIssueUrl = getString(R.string.report_issue_url),
|
||||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||||
issueTitle = getString(R.string.crash_report_issue_title),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
|
|||||||
@@ -25,13 +25,18 @@ import androidx.core.content.IntentCompat
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.sharedTextOrNull
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
@@ -48,13 +53,17 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
|||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.windowBackgroundRes
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** A prefilled create form from an external launch, with the source it came from. */
|
/** A prefilled create form from an external launch, with the source it came from. */
|
||||||
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
||||||
@@ -62,6 +71,9 @@ private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
|||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var calendarRepository: CalendarRepository
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
// Which of light/dark the system bars are drawn for. The styles installed
|
// Which of light/dark the system bars are drawn for. The styles installed
|
||||||
// in onCreate read this field live, so androidx's config-change replay
|
// in onCreate read this field live, so androidx's config-change replay
|
||||||
// picks up the in-app override instead of the night resource qualifier.
|
// picks up the in-app override instead of the night resource qualifier.
|
||||||
@@ -81,10 +93,18 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||||
|
|
||||||
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||||
// app/widget asking us to create an event, issue #30). Consumed once by
|
// app/widget asking us to create an event, issue #30) or shared text (#306).
|
||||||
// CalendarHost, which opens it in the create form for review.
|
// Consumed once by CalendarHost, which opens it in the create form for review.
|
||||||
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
|
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
|
||||||
|
|
||||||
|
// Whether such a form is still open. Only then does a recreation re-read the
|
||||||
|
// intent to bring it back; a saved or dismissed one stays closed.
|
||||||
|
private var insertFormOpen = false
|
||||||
|
|
||||||
|
// Started just for an external create, so closing its form returns to the
|
||||||
|
// app that asked instead of leaving the calendar on top of it.
|
||||||
|
private var launchedForInsert = false
|
||||||
|
|
||||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
||||||
// detail channel; consumed once by CalendarHost.
|
// detail channel; consumed once by CalendarHost.
|
||||||
@@ -110,14 +130,50 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
val systemDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||||
Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_YES
|
||||||
|
// The stored theme only arrives after the first frame, so a Light, Dark
|
||||||
|
// or Black override starts from its last mirrored copy: the matching
|
||||||
|
// backdrop and bar icons instead of the night qualifier's (#181).
|
||||||
|
val cachedTheme = WindowThemeCache.read(this)
|
||||||
|
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
|
||||||
|
systemBarsDark = when (cachedTheme) {
|
||||||
|
null, ThemeMode.SYSTEM -> systemDark
|
||||||
|
ThemeMode.LIGHT -> false
|
||||||
|
ThemeMode.DARK, ThemeMode.BLACK -> true
|
||||||
|
}
|
||||||
applyEdgeToEdge()
|
applyEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
lifecycleScope.launch {
|
||||||
|
settingsPrefs.themeMode.collect { mode ->
|
||||||
|
WindowThemeCache.write(this@MainActivity, mode)
|
||||||
|
window.setBackgroundDrawableResource(mode.windowBackgroundRes() ?: R.color.window_background)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Android hands the intent that started the task back to onCreate every
|
||||||
|
// time the activity is recreated — on a rotation, or on a restart after
|
||||||
|
// the system reclaimed the process. Re-running a launch that opens one
|
||||||
|
// specific event is what made a single reminder or widget tap re-open
|
||||||
|
// that event on every later launch, an error screen once the event had
|
||||||
|
// been deleted (#309); the user left it long ago, and the activity
|
||||||
|
// restores where they actually were along with its saved state.
|
||||||
|
//
|
||||||
|
// Only those channels are held back. An .ics import or a prefilled
|
||||||
|
// create form keeps no saved state of its own, so re-reading the intent
|
||||||
|
// is what carries one through a recreation — the create form only while
|
||||||
|
// it's still open.
|
||||||
|
val replaying = savedInstanceState != null || intent.isRelaunch()
|
||||||
|
if (!replaying) {
|
||||||
|
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
|
requestedEditKey = intent.editEventKeyOrNull()
|
||||||
|
}
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
|
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsert = intent.insertRequestOrNull()
|
val insertOpenBefore = savedInstanceState?.getBoolean(KEY_INSERT_FORM_OPEN) == true
|
||||||
requestedEditKey = intent.editEventKeyOrNull()
|
requestedInsert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
|
||||||
|
insertFormOpen = requestedInsert != null
|
||||||
|
launchedForInsert = savedInstanceState?.getBoolean(KEY_LAUNCHED_FOR_INSERT)
|
||||||
|
?: (requestedInsert != null)
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
@@ -127,7 +183,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val darkTheme = when (settings.themeMode) {
|
val darkTheme = when (settings.themeMode) {
|
||||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||||
ThemeMode.LIGHT -> false
|
ThemeMode.LIGHT -> false
|
||||||
ThemeMode.DARK -> true
|
ThemeMode.DARK, ThemeMode.BLACK -> true
|
||||||
}
|
}
|
||||||
// onCreate can only see the night resource qualifier, not the in-app
|
// onCreate can only see the night resource qualifier, not the in-app
|
||||||
// override — re-apply from the resolved theme so the bar icons follow
|
// override — re-apply from the resolved theme so the bar icons follow
|
||||||
@@ -154,6 +210,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// weeks and switching between the week and day view.
|
// weeks and switching between the week and day view.
|
||||||
val timelineZoom = rememberTimelineZoom(
|
val timelineZoom = rememberTimelineZoom(
|
||||||
stored = settings.timelineScale,
|
stored = settings.timelineScale,
|
||||||
|
hours = settings.timelineHours,
|
||||||
onPersist = settingsViewModel::setTimelineScale,
|
onPersist = settingsViewModel::setTimelineScale,
|
||||||
)
|
)
|
||||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||||
@@ -166,6 +223,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
CalendulaTheme(
|
CalendulaTheme(
|
||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
|
pureBlack = settings.themeMode == ThemeMode.BLACK,
|
||||||
typography = typography,
|
typography = typography,
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -173,6 +231,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalShowHourGrid provides settings.showHourGrid,
|
LocalShowHourGrid provides settings.showHourGrid,
|
||||||
LocalTimelineZoom provides timelineZoom,
|
LocalTimelineZoom provides timelineZoom,
|
||||||
|
LocalMultiDayCount provides settings.multiDayCount,
|
||||||
LocalSoftenColors provides settings.softenColors,
|
LocalSoftenColors provides settings.softenColors,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
@@ -186,6 +245,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
requestedInsertForm = requestedInsert?.form,
|
requestedInsertForm = requestedInsert?.form,
|
||||||
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
|
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
|
||||||
onInsertConsumed = { requestedInsert = null },
|
onInsertConsumed = { requestedInsert = null },
|
||||||
|
onExternalFormClosed = {
|
||||||
|
insertFormOpen = false
|
||||||
|
if (launchedForInsert) finish()
|
||||||
|
},
|
||||||
requestedEditKey = requestedEditKey,
|
requestedEditKey = requestedEditKey,
|
||||||
onEditKeyConsumed = { requestedEditKey = null },
|
onEditKeyConsumed = { requestedEditKey = null },
|
||||||
)
|
)
|
||||||
@@ -227,21 +290,46 @@ class MainActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
// Changes made while we were away may never have notified us (#364).
|
||||||
|
calendarRepository.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||||
CrashReporter.markHealthy(this)
|
CrashReporter.markHealthy(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
outState.putBoolean(KEY_INSERT_FORM_OPEN, insertFormOpen)
|
||||||
|
outState.putBoolean(KEY_LAUNCHED_FOR_INSERT, launchedForInsert)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
intent.insertRequestOrNull()?.let { requestedInsert = it }
|
intent.createRequestOrNull()?.let {
|
||||||
|
requestedInsert = it
|
||||||
|
insertFormOpen = true
|
||||||
|
}
|
||||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this launch is the task being resumed rather than a fresh
|
||||||
|
* delivery — Android sets the flag when an activity is started from the
|
||||||
|
* recents list, where the intent it carries is the one the task was started
|
||||||
|
* with however long ago. `onNewIntent` is the channel a real new request
|
||||||
|
* arrives on while the task lives, so nothing is lost by ignoring these.
|
||||||
|
*/
|
||||||
|
private fun Intent.isRelaunch(): Boolean =
|
||||||
|
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
||||||
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
||||||
@@ -250,7 +338,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private fun Intent.importUriOrNull(): Uri? {
|
private fun Intent.importUriOrNull(): Uri? {
|
||||||
val uri = when (action) {
|
val uri = when (action) {
|
||||||
Intent.ACTION_VIEW -> data
|
Intent.ACTION_VIEW -> data
|
||||||
|
// Plain text is a create (#306), never a file to import.
|
||||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
.takeUnless { type == SHARED_TEXT_TYPE }
|
||||||
else -> null
|
else -> null
|
||||||
} ?: return null
|
} ?: return null
|
||||||
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||||
@@ -295,6 +385,36 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
|
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Any external request to create an event: shared text or an insert intent. */
|
||||||
|
private fun Intent.createRequestOrNull(): InsertRequest? = sharedTextRequestOrNull() ?: insertRequestOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain text shared into us (`ACTION_SEND`) or picked from a text-selection
|
||||||
|
* menu (`ACTION_PROCESS_TEXT`), as a new event at the default time (#306).
|
||||||
|
*/
|
||||||
|
private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
|
||||||
|
val shared = when (action) {
|
||||||
|
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) {
|
||||||
|
sharedTextOrNull(
|
||||||
|
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
|
||||||
|
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
Intent.ACTION_PROCESS_TEXT ->
|
||||||
|
sharedTextOrNull(subject = null, text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString())
|
||||||
|
else -> null
|
||||||
|
} ?: return null
|
||||||
|
val form = buildInsertEventForm(
|
||||||
|
title = shared.title,
|
||||||
|
description = shared.description,
|
||||||
|
zone = TimeZone.currentSystemDefault(),
|
||||||
|
now = Clock.System.now(),
|
||||||
|
)
|
||||||
|
return InsertRequest(form, ImportSource.InsertOpenEnded)
|
||||||
|
}
|
||||||
|
|
||||||
/** A Long extra's value, or null when the extra is absent. */
|
/** A Long extra's value, or null when the extra is absent. */
|
||||||
private fun Intent.longExtraOrNull(key: String): Long? =
|
private fun Intent.longExtraOrNull(key: String): Long? =
|
||||||
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
||||||
@@ -420,6 +540,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// The calendar provider's authority/host. A date tap arrives as
|
// The calendar provider's authority/host. A date tap arrives as
|
||||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||||
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||||
|
private const val SHARED_TEXT_TYPE = "text/plain"
|
||||||
|
private const val KEY_INSERT_FORM_OPEN = "insert_form_open"
|
||||||
|
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
|
||||||
|
|
||||||
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
@@ -28,6 +26,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
|
import de.jeanlucmakiola.floret.android.hasPermission
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -133,8 +132,7 @@ class BackupWorker(
|
|||||||
|
|
||||||
private fun notifyFailure(context: Context) {
|
private fun notifyFailure(context: Context) {
|
||||||
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!canPost) return
|
if (!canPost) return
|
||||||
val manager = NotificationManagerCompat.from(context)
|
val manager = NotificationManagerCompat.from(context)
|
||||||
manager.createNotificationChannel(
|
manager.createNotificationChannel(
|
||||||
|
|||||||
+28
-15
@@ -1,22 +1,35 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
* The colours offered when creating or editing a calendar; ARGB ints for a raw
|
||||||
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
* `CALENDAR_COLOR` / `EVENT_COLOR`.
|
||||||
* hue (the managed special-dates calendars), so they can't drift from the
|
*
|
||||||
* swatches offered in the colour picker.
|
* Hues rather than final fills: every surface that draws a calendar colour runs
|
||||||
|
* it through the app's own tone pass first, so these are chosen to stay
|
||||||
|
* distinguishable after it. Shared with Agendula, whose list palette these are —
|
||||||
|
* the two apps are the same family and a calendar and a task list picked from
|
||||||
|
* different-looking sets read as different products.
|
||||||
|
*
|
||||||
|
* The named entries exist for callers that need one specific hue (the managed
|
||||||
|
* special-dates calendars), so they can't drift from the swatches on offer.
|
||||||
*/
|
*/
|
||||||
object CalendarColorPalette {
|
object CalendarColorPalette {
|
||||||
val Red = 0xFFD50000.toInt()
|
val Mauve = 0xFF7A5C6B.toInt()
|
||||||
val Orange = 0xFFE67C00.toInt()
|
val Red = 0xFFD7484A.toInt()
|
||||||
val Amber = 0xFFF6BF26.toInt()
|
val Orange = 0xFFE8743B.toInt()
|
||||||
val Green = 0xFF33B679.toInt()
|
val Amber = 0xFFE0A32E.toInt()
|
||||||
val DarkGreen = 0xFF0B8043.toInt()
|
val Olive = 0xFF7CA83E.toInt()
|
||||||
val Blue = 0xFF039BE5.toInt()
|
val Green = 0xFF35A06A.toInt()
|
||||||
val Indigo = 0xFF3F51B5.toInt()
|
val Teal = 0xFF19938C.toInt()
|
||||||
val Purple = 0xFF8E24AA.toInt()
|
val Cyan = 0xFF2A9BC4.toInt()
|
||||||
val Graphite = 0xFF616161.toInt()
|
val Blue = 0xFF3C74C8.toInt()
|
||||||
|
val Indigo = 0xFF6A5CC0.toInt()
|
||||||
|
val Purple = 0xFF9455B8.toInt()
|
||||||
|
val Pink = 0xFFC94F8E.toInt()
|
||||||
|
|
||||||
/** The full palette, in swatch-row order. */
|
/** The full palette, in swatch-grid order. */
|
||||||
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
val all: List<Int> = listOf(
|
||||||
|
Mauve, Red, Orange, Amber, Olive, Green,
|
||||||
|
Teal, Cyan, Blue, Indigo, Purple, Pink,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
import de.jeanlucmakiola.floret.android.hasPermission
|
||||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -15,7 +14,6 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
@@ -28,7 +26,6 @@ import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||||
@@ -36,7 +33,10 @@ import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
|
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@@ -238,8 +238,10 @@ interface CalendarDataSource {
|
|||||||
* and cancelled occurrences) to [targetCalendarId], returning the new
|
* and cancelled occurrences) to [targetCalendarId], returning the new
|
||||||
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
|
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
|
||||||
* place, so this is copy+delete: the row is re-inserted on the target (its
|
* place, so this is copy+delete: the row is re-inserted on the target (its
|
||||||
* `UID_2445` preserved), its exceptions, reminders and editable guests
|
* `UID_2445` preserved), its reminders and editable guests copied, its
|
||||||
* replayed, the [original]→[updated] field edits applied, then the source
|
* exceptions turned into EXDATEs (modified ones also into standalone events,
|
||||||
|
* as the new row has no `_sync_id` to attach them to), the
|
||||||
|
* [original]→[updated] field edits applied, then the source
|
||||||
* deleted. The source is removed only once the copy fully succeeds; a failure
|
* deleted. The source is removed only once the copy fully succeeds; a failure
|
||||||
* before that rolls the new event back, leaving the original untouched.
|
* before that rolls the new event back, leaving the original untouched.
|
||||||
* [allDayReminderTimeMinutes]: see [insertEvent].
|
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
@@ -323,8 +325,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
private val registeredObservers = mutableSetOf<ContentObserver>()
|
private val registeredObservers = mutableSetOf<ContentObserver>()
|
||||||
|
|
||||||
private fun hasCalendarPermission(): Boolean =
|
private fun hasCalendarPermission(): Boolean =
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach any not-yet-registered observers to the provider, but only once the
|
* Attach any not-yet-registered observers to the provider, but only once the
|
||||||
@@ -376,7 +377,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
val name = displayName.trim()
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
@@ -400,7 +401,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
||||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
val name = displayName.trim()
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||||
put(CalendarContract.Calendars.NAME, name)
|
put(CalendarContract.Calendars.NAME, name)
|
||||||
@@ -460,11 +461,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun canWriteCalendars(): Boolean =
|
override fun canWriteCalendars(): Boolean =
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
context.hasPermission(Manifest.permission.WRITE_CALENDAR)
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
val name = displayName.trim()
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
@@ -610,8 +610,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// cancelled exception for the one instance (#47). A NULL status is a
|
// cancelled exception for the one instance (#47). A NULL status is a
|
||||||
// normal, un-cancelled event, so it must survive the filter — a bare
|
// normal, un-cancelled event, so it must survive the filter — a bare
|
||||||
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
||||||
"${CalendarContract.Instances.STATUS} IS NULL OR " +
|
// DELETED = 0: a provider that soft-deletes may keep the row's
|
||||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
|
// instances until a purge (#364).
|
||||||
|
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
|
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0",
|
||||||
null,
|
null,
|
||||||
CalendarContract.Instances.BEGIN + " ASC",
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
@@ -1070,10 +1073,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
|
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
|
||||||
)?.let(ContentUris::parseId)
|
)?.let(ContentUris::parseId)
|
||||||
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
|
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
|
||||||
|
val detached = mutableListOf<Long>()
|
||||||
try {
|
try {
|
||||||
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
|
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
|
||||||
insertAttendees(newEventId, editableAttendees(eventId))
|
insertAttendees(newEventId, editableAttendees(eventId))
|
||||||
copyExceptions(fromEventId = eventId, toEventId = newEventId)
|
copyExceptions(eventId, newEventId, targetCalendarId, allDayReminderTimeMinutes, detached)
|
||||||
// Apply the user's field edits exactly as a same-calendar "all
|
// Apply the user's field edits exactly as a same-calendar "all
|
||||||
// events" save would: the moved master carries the source's values,
|
// events" save would: the moved master carries the source's values,
|
||||||
// so the dirty diff against [original] writes only what changed
|
// so the dirty diff against [original] writes only what changed
|
||||||
@@ -1083,6 +1087,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// Undo the partial copy so a failed move leaves nothing behind; the
|
// Undo the partial copy so a failed move leaves nothing behind; the
|
||||||
// source is still intact (it's deleted only past this point).
|
// source is still intact (it's deleted only past this point).
|
||||||
runCatching { deleteEvent(newEventId) }
|
runCatching { deleteEvent(newEventId) }
|
||||||
|
detached.forEach(::rollBackDetached)
|
||||||
throw t
|
throw t
|
||||||
}
|
}
|
||||||
deleteEvent(eventId)
|
deleteEvent(eventId)
|
||||||
@@ -1159,44 +1164,47 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replay every exception of the series [fromEventId] against the moved series
|
* Carry every exception of the series [fromEventId] over to the moved series
|
||||||
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence
|
* [toEventId]. The moved row has no `_sync_id` yet, so exception rows would
|
||||||
* time still resolves: a cancelled occurrence is re-hidden with a cancelled
|
* never attach to it ([deleteOccurrence], [detachOccurrence]): a cancelled
|
||||||
* exception, a modified one is re-inserted with its fields (then its reminders
|
* occurrence becomes an EXDATE, a modified one an EXDATE plus a standalone
|
||||||
* reconciled and editable guests copied). No-op for a non-recurring event.
|
* event on [targetCalendarId], its id added to [detached] for rollback.
|
||||||
*/
|
*/
|
||||||
private fun copyExceptions(fromEventId: Long, toEventId: Long) {
|
private fun copyExceptions(
|
||||||
queryExceptionRows(fromEventId).forEach { ex ->
|
fromEventId: Long,
|
||||||
if (ex.isCancelled) {
|
toEventId: Long,
|
||||||
val values = ContentValues().apply {
|
targetCalendarId: Long,
|
||||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis)
|
allDayReminderTimeMinutes: Int,
|
||||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
detached: MutableList<Long>,
|
||||||
}
|
) {
|
||||||
resolver.insert(
|
val exceptions = queryExceptionRows(fromEventId)
|
||||||
ContentUris.withAppendedId(
|
if (exceptions.isEmpty()) return
|
||||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
val zone = TimeZone.currentSystemDefault()
|
||||||
),
|
exceptions.filterNot { it.isCancelled }.forEach { ex ->
|
||||||
values,
|
val detail = eventDetail(ex.exceptionEventId, allDayReminderTimeMinutes)
|
||||||
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId")
|
?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}")
|
||||||
} else {
|
val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration))
|
||||||
val uri = resolver.insert(
|
val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone)
|
||||||
ContentUris.withAppendedId(
|
.copy(calendarId = targetCalendarId)
|
||||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
.toDetachedOccurrence()
|
||||||
),
|
detached += insertEvent(form, allDayReminderTimeMinutes)
|
||||||
buildCopiedExceptionValues(ex).toContentValues(),
|
|
||||||
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
|
|
||||||
val newExceptionId = ContentUris.parseId(uri)
|
|
||||||
// The provider may clone the parent's reminders onto the new
|
|
||||||
// exception; reconcile to the source exception's exact set so
|
|
||||||
// they neither double nor drop. Same DTSTART → same all-day
|
|
||||||
// encoding, so the stored offsets match directly.
|
|
||||||
reconcileReminders(
|
|
||||||
newExceptionId,
|
|
||||||
queryReminders(ex.exceptionEventId).map { it.minutes },
|
|
||||||
)
|
|
||||||
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val row = querySeriesRow(toEventId)
|
||||||
|
val values = buildOccurrencesExdateValues(
|
||||||
|
existingExdate = row.exdate,
|
||||||
|
occurrences = exceptions.map { it.originalInstanceMillis },
|
||||||
|
dtStartMillis = row.dtStartMillis,
|
||||||
|
rrule = row.rrule,
|
||||||
|
rdate = row.rdate,
|
||||||
|
duration = row.duration,
|
||||||
|
timezone = row.timezone,
|
||||||
|
allDay = row.allDay,
|
||||||
|
)
|
||||||
|
val updated = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, toEventId),
|
||||||
|
values.toContentValues(), null, null,
|
||||||
|
)
|
||||||
|
if (updated == 0) throw WriteFailedException("exdate moved exceptions event id=$toEventId")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
|
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
|
||||||
@@ -1215,18 +1223,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
|
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
|
||||||
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
|
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
|
||||||
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
|
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
|
||||||
status = status,
|
|
||||||
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(),
|
|
||||||
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0,
|
|
||||||
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
|
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
|
||||||
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
|
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
|
||||||
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
|
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
|
||||||
duration = r.getString(ExceptionProjection.IDX_DURATION),
|
duration = r.getString(ExceptionProjection.IDX_DURATION),
|
||||||
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
|
|
||||||
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
|
|
||||||
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
|
|
||||||
location = r.getString(ExceptionProjection.IDX_LOCATION),
|
|
||||||
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
@@ -1261,6 +1261,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
values.toContentValues(),
|
values.toContentValues(),
|
||||||
) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis")
|
) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis")
|
||||||
val exceptionId = ContentUris.parseId(uri)
|
val exceptionId = ContentUris.parseId(uri)
|
||||||
|
form.colorKey?.let { key ->
|
||||||
|
resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, exceptionId),
|
||||||
|
ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) },
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
}
|
||||||
// Whether the provider copied the parent's reminder rows is its
|
// Whether the provider copied the parent's reminder rows is its
|
||||||
// business — reconciling against the actual rows handles both ways.
|
// business — reconciling against the actual rows handles both ways.
|
||||||
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
|||||||
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||||
return CalendarSource(
|
return CalendarSource(
|
||||||
id = getLong(CalendarProjection.IDX_ID),
|
id = getLong(CalendarProjection.IDX_ID),
|
||||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
// Blank when the provider row has no name: the placeholder is a display
|
||||||
?: Fallbacks.UNNAMED_CALENDAR,
|
// string and belongs to the UI, where it can be localised (#329).
|
||||||
|
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME).orEmpty(),
|
||||||
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
||||||
accountType = accountType,
|
accountType = accountType,
|
||||||
color = getInt(CalendarProjection.IDX_COLOR),
|
color = getInt(CalendarProjection.IDX_COLOR),
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import kotlin.time.Instant
|
|||||||
interface CalendarRepository {
|
interface CalendarRepository {
|
||||||
fun calendars(): Flow<List<CalendarSource>>
|
fun calendars(): Flow<List<CalendarSource>>
|
||||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
|
|
||||||
|
/** Re-query every open [calendars] / [instances] flow. */
|
||||||
|
fun refresh()
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+35
-18
@@ -1,8 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -51,21 +52,37 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
private suspend fun allDayReminderTimeMinutes(): Int =
|
private suspend fun allDayReminderTimeMinutes(): Int =
|
||||||
settingsPrefs.allDayReminderTimeMinutes.first()
|
settingsPrefs.allDayReminderTimeMinutes.first()
|
||||||
|
|
||||||
|
// DROP_OLDEST: tryEmit never fails, so a tick that lands while a subscriber
|
||||||
|
// still holds the previous one is never lost (#364).
|
||||||
private val ticks = MutableSharedFlow<Unit>(
|
private val ticks = MutableSharedFlow<Unit>(
|
||||||
replay = 0,
|
replay = 0,
|
||||||
extraBufferCapacity = 1,
|
extraBufferCapacity = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bumped on every provider notification, so one tick's calendar read can be
|
* Bumped on every [refresh], so one tick's calendar read can be
|
||||||
* shared by everything that needs it (see [calendarsSnapshot]).
|
* shared by everything that needs it (see [calendarsSnapshot]).
|
||||||
*/
|
*/
|
||||||
private val generation = AtomicLong(0L)
|
private val generation = AtomicLong(0L)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
dataSource.registerChangeListener {
|
dataSource.registerChangeListener(::refresh)
|
||||||
generation.incrementAndGet()
|
}
|
||||||
ticks.tryEmit(Unit)
|
|
||||||
|
// Writes re-query on their own too: some providers never notify the
|
||||||
|
// observer, which left every view stale until a restart (#364).
|
||||||
|
override fun refresh() {
|
||||||
|
generation.incrementAndGet()
|
||||||
|
ticks.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a provider write on [io], then re-query whether or not it threw. */
|
||||||
|
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +198,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
color: Int,
|
color: Int,
|
||||||
description: String?,
|
description: String?,
|
||||||
): Long = withContext(io) {
|
): Long = write {
|
||||||
dataSource.createLocalCalendar(displayName, color, description)
|
dataSource.createLocalCalendar(displayName, color, description)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,13 +207,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
color: Int,
|
color: Int,
|
||||||
description: String?,
|
description: String?,
|
||||||
) = withContext(io) { dataSource.updateCalendar(id, displayName, color, description) }
|
) = write { dataSource.updateCalendar(id, displayName, color, description) }
|
||||||
|
|
||||||
override suspend fun deleteCalendar(id: Long) =
|
override suspend fun deleteCalendar(id: Long) =
|
||||||
withContext(io) { dataSource.deleteCalendar(id) }
|
write { dataSource.deleteCalendar(id) }
|
||||||
|
|
||||||
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
||||||
withContext(io) {
|
write {
|
||||||
if (dataSource.canWriteCalendars()) {
|
if (dataSource.canWriteCalendars()) {
|
||||||
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
||||||
// Nothing of ours is left waiting for the provider once the
|
// Nothing of ours is left waiting for the provider once the
|
||||||
@@ -217,7 +234,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun importEvents(
|
override suspend fun importEvents(
|
||||||
targetCalendarId: Long,
|
targetCalendarId: Long,
|
||||||
events: List<ParsedIcsEvent>,
|
events: List<ParsedIcsEvent>,
|
||||||
): IcsImportSummary = withContext(io) {
|
): IcsImportSummary = write {
|
||||||
val existing = dataSource.existingUids(targetCalendarId)
|
val existing = dataSource.existingUids(targetCalendarId)
|
||||||
// Both are per-calendar, not per-event: looking them up once keeps a
|
// Both are per-calendar, not per-event: looking them up once keeps a
|
||||||
// thousand-event restore to two extra queries. The palette is the
|
// thousand-event restore to two extra queries. The palette is the
|
||||||
@@ -268,7 +285,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
override suspend fun createEvent(form: EventForm): Long = write {
|
||||||
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,11 +293,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
eventId: Long,
|
eventId: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
) = withContext(io) {
|
) = write {
|
||||||
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
override suspend fun deleteEvent(eventId: Long) = write {
|
||||||
dataSource.deleteEvent(eventId)
|
dataSource.deleteEvent(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +306,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
targetCalendarId: Long,
|
targetCalendarId: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = write {
|
||||||
dataSource.moveEvent(
|
dataSource.moveEvent(
|
||||||
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
||||||
)
|
)
|
||||||
@@ -300,7 +317,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = write {
|
||||||
dataSource.updateOccurrence(
|
dataSource.updateOccurrence(
|
||||||
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
||||||
)
|
)
|
||||||
@@ -311,20 +328,20 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = write {
|
||||||
dataSource.updateEventFromOccurrence(
|
dataSource.updateEventFromOccurrence(
|
||||||
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
|
||||||
dataSource.deleteOccurrence(eventId, beginMillis)
|
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteEventFromOccurrence(
|
override suspend fun deleteEventFromOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
) = withContext(io) {
|
) = write {
|
||||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-7
@@ -4,15 +4,15 @@ import android.Manifest
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.EntryPoint
|
import dagger.hilt.EntryPoint
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
|
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
|
||||||
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
|
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
|
||||||
|
import de.jeanlucmakiola.floret.android.hasPermission
|
||||||
|
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -47,7 +47,7 @@ class CalendarVisibilityReconciler @Inject constructor(
|
|||||||
// Settled ahead of the permission gate, so an update installed
|
// Settled ahead of the permission gate, so an update installed
|
||||||
// before the first grant can't later look like an upgrade.
|
// before the first grant can't later look like an upgrade.
|
||||||
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
|
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
|
||||||
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
|
if (!context.hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
|
||||||
val pending = prefs.pendingDisabledCalendarIds.first()
|
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||||
val noticeSettled = prefs.visibilityNoticePending.first() != null
|
val noticeSettled = prefs.visibilityNoticePending.first() != null
|
||||||
if (pending.isEmpty() && noticeSettled) return@withContext
|
if (pending.isEmpty() && noticeSettled) return@withContext
|
||||||
@@ -56,7 +56,7 @@ class CalendarVisibilityReconciler @Inject constructor(
|
|||||||
// Both decisions below are one-way, so leave them to the next run.
|
// Both decisions below are one-way, so leave them to the next run.
|
||||||
if (calendars.isEmpty()) return@withContext
|
if (calendars.isEmpty()) return@withContext
|
||||||
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
||||||
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
if (pending.isEmpty() || !context.hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
val plan = calendarVisibilityPlan(calendars, pending)
|
val plan = calendarVisibilityPlan(calendars, pending)
|
||||||
@@ -97,9 +97,6 @@ class CalendarVisibilityReconciler @Inject constructor(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasPermission(permission: String): Boolean =
|
|
||||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
/** Lets non-injectable entry points (the Application) reach the reconciler. */
|
/** Lets non-injectable entry points (the Application) reach the reconciler. */
|
||||||
@EntryPoint
|
@EntryPoint
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.instantIn
|
||||||
|
import kotlinx.datetime.toKotlinTimeZone
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.nearestTo
|
import de.jeanlucmakiola.calendula.domain.nearestTo
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toJavaLocalDateTime
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
|
import kotlinx.datetime.toJavaZoneOffset
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -48,8 +52,8 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
|||||||
} else {
|
} else {
|
||||||
val writeZone = writeZone(zone)
|
val writeZone = writeZone(zone)
|
||||||
EventWriteTimes(
|
EventWriteTimes(
|
||||||
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
dtStartMillis = start.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
|
||||||
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
dtEndMillis = end.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
|
||||||
timezone = writeZone.id,
|
timezone = writeZone.id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -75,7 +79,7 @@ private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
|
|||||||
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
|
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
|
||||||
start.date.toJavaLocalDate().atStartOfDay()
|
start.date.toJavaLocalDate().atStartOfDay()
|
||||||
} else {
|
} else {
|
||||||
start.toJavaLocalDateTime()
|
start.dateTime.toJavaLocalDateTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,7 +185,8 @@ internal fun buildEventUpdateValues(
|
|||||||
|
|
||||||
// A zone change counts as a time change even when the wall-clock is
|
// A zone change counts as a time change even when the wall-clock is
|
||||||
// untouched: the same 09:00 in another zone is a different instant, so
|
// untouched: the same 09:00 in another zone is a different instant, so
|
||||||
// DTSTART has to move with it.
|
// DTSTART has to move with it. So does a switch between the two passes of
|
||||||
|
// the fall-back hour, which each [FormTime] carries (#387).
|
||||||
val timesChanged = updated.start != original.start ||
|
val timesChanged = updated.start != original.start ||
|
||||||
updated.end != original.end ||
|
updated.end != original.end ||
|
||||||
updated.isAllDay != original.isAllDay ||
|
updated.isAllDay != original.isAllDay ||
|
||||||
@@ -209,15 +214,23 @@ internal fun buildEventUpdateValues(
|
|||||||
// is a different offset from the series anchor's whenever a DST boundary
|
// is a different offset from the series anchor's whenever a DST boundary
|
||||||
// sits between them, or whenever the zone itself changed. Working in wall
|
// sits between them, or whenever the zone itself changed. Working in wall
|
||||||
// clock keeps "09:00" meaning 09:00 at both ends.
|
// clock keeps "09:00" meaning 09:00 at both ends.
|
||||||
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
|
val seriesZoned = Instant.ofEpochMilli(seriesDtStartMillis).atZone(original.writeZone(zone))
|
||||||
.atZone(original.writeZone(zone)).toLocalDateTime()
|
val seriesLocal = seriesZoned.toLocalDateTime()
|
||||||
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
||||||
val shifted = seriesLocal.plus(wallClockShift)
|
val shifted = seriesLocal.plus(wallClockShift)
|
||||||
// An all-day series anchor must sit on a UTC midnight. A pure day move
|
// An all-day series anchor must sit on a UTC midnight. A pure day move
|
||||||
// already lands there (both ends are midnights), but *switching* a
|
// already lands there (both ends are midnights), but *switching* a
|
||||||
// recurring event to all-day shifts by a time-of-day too, so snap.
|
// recurring event to all-day shifts by a time-of-day too, so snap.
|
||||||
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
|
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
|
||||||
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
|
// A wall clock the fall-back hour repeats keeps the anchor's own pass, or,
|
||||||
|
// when the anchor is the edited occurrence itself, the pass the form now
|
||||||
|
// holds: a bare wall clock would always take the earlier one (#387).
|
||||||
|
val pass = if (seriesLocal == original.anchorLocal()) {
|
||||||
|
updated.start.offset?.toJavaZoneOffset()
|
||||||
|
} else {
|
||||||
|
seriesZoned.offset
|
||||||
|
}
|
||||||
|
val newSeriesStart = ZonedDateTime.ofLocal(newSeriesLocal, updated.writeZone(zone), pass)
|
||||||
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
|
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
|
||||||
put(CalendarContract.Events.DTEND, null)
|
put(CalendarContract.Events.DTEND, null)
|
||||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||||
@@ -247,6 +260,10 @@ internal fun buildEventUpdateValues(
|
|||||||
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
||||||
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
||||||
* "only this event" fail on-device (Codeberg #16).
|
* "only this event" fail on-device (Codeberg #16).
|
||||||
|
*
|
||||||
|
* A palette key is left out: the provider crashes on an exception insert that
|
||||||
|
* carries `EVENT_COLOR_KEY` (Codeberg #338), so the caller sets it with a plain
|
||||||
|
* update once the row exists.
|
||||||
*/
|
*/
|
||||||
internal fun buildOccurrenceExceptionValues(
|
internal fun buildOccurrenceExceptionValues(
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
@@ -264,7 +281,7 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
||||||
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
||||||
putAll(eventColorColumns(form.colorKey, form.color))
|
if (form.colorKey == null) putAll(eventColorColumns(null, form.color))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -355,48 +372,11 @@ internal data class ExceptionRowSnapshot(
|
|||||||
val exceptionEventId: Long,
|
val exceptionEventId: Long,
|
||||||
val originalInstanceMillis: Long,
|
val originalInstanceMillis: Long,
|
||||||
val isCancelled: Boolean,
|
val isCancelled: Boolean,
|
||||||
val status: Int?,
|
|
||||||
val title: String,
|
|
||||||
val isAllDay: Boolean,
|
|
||||||
val dtStartMillis: Long,
|
val dtStartMillis: Long,
|
||||||
val dtEndMillis: Long?,
|
val dtEndMillis: Long?,
|
||||||
val duration: String?,
|
val duration: String?,
|
||||||
val timezone: String?,
|
|
||||||
val availability: Int,
|
|
||||||
val accessLevel: Int,
|
|
||||||
val location: String?,
|
|
||||||
val description: String?,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Column values replaying a *modified* occurrence [snapshot] against the moved
|
|
||||||
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
|
|
||||||
* length travels as DURATION (the provider rejects DTEND on an exception). A
|
|
||||||
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
|
|
||||||
* STATUS_CANCELED) — this builder is only for the modified case.
|
|
||||||
*/
|
|
||||||
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
|
|
||||||
buildMap {
|
|
||||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
|
|
||||||
put(CalendarContract.Events.TITLE, snapshot.title)
|
|
||||||
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
|
||||||
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
|
||||||
put(
|
|
||||||
CalendarContract.Events.DURATION,
|
|
||||||
snapshot.duration?.takeIf { it.isNotBlank() }
|
|
||||||
?: rfc2445Duration(
|
|
||||||
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
|
|
||||||
snapshot.isAllDay,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
|
||||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
|
||||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
|
||||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
|
||||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Column values for a *cancelled*-occurrence exception row ("delete only this
|
* Column values for a *cancelled*-occurrence exception row ("delete only this
|
||||||
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
||||||
@@ -462,9 +442,24 @@ internal fun buildOccurrenceExdateValues(
|
|||||||
duration: String?,
|
duration: String?,
|
||||||
timezone: String?,
|
timezone: String?,
|
||||||
allDay: Int,
|
allDay: Int,
|
||||||
|
): Map<String, Any?> = buildOccurrencesExdateValues(
|
||||||
|
existingExdate, listOf(occurrenceMillis), dtStartMillis, rrule, rdate, duration, timezone, allDay,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** [buildOccurrenceExdateValues] excluding every one of [occurrences] in one write. */
|
||||||
|
internal fun buildOccurrencesExdateValues(
|
||||||
|
existingExdate: String?,
|
||||||
|
occurrences: List<Long>,
|
||||||
|
dtStartMillis: Long,
|
||||||
|
rrule: String?,
|
||||||
|
rdate: String?,
|
||||||
|
duration: String?,
|
||||||
|
timezone: String?,
|
||||||
|
allDay: Int,
|
||||||
): Map<String, Any?> = mapOf(
|
): Map<String, Any?> = mapOf(
|
||||||
CalendarContract.Events.EXDATE to
|
CalendarContract.Events.EXDATE to occurrences.fold(existingExdate) { exdate, millis ->
|
||||||
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone),
|
mergedExdate(exdate, millis, allDay != 0, timezone)
|
||||||
|
},
|
||||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||||
CalendarContract.Events.RRULE to rrule,
|
CalendarContract.Events.RRULE to rrule,
|
||||||
CalendarContract.Events.RDATE to rdate,
|
CalendarContract.Events.RDATE to rdate,
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val rawTitle = getString(InstanceProjection.IDX_TITLE)
|
// Left blank when the event has no title: the placeholder is a display
|
||||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
// string, so it belongs to the UI layer where it can be localised (#321).
|
||||||
|
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
|
||||||
|
|
||||||
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
||||||
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||||
|
|||||||
@@ -251,40 +251,26 @@ internal object MoveMasterProjection {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
|
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
|
||||||
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
|
* carry them over to a moved copy (see [ExceptionRowSnapshot]). Both modified
|
||||||
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
|
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
|
||||||
* `DELETED = 0` so provider tombstones aren't replayed.
|
* `DELETED = 0` so provider tombstones aren't carried over.
|
||||||
*/
|
*/
|
||||||
internal object ExceptionProjection {
|
internal object ExceptionProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
CalendarContract.Events._ID,
|
CalendarContract.Events._ID,
|
||||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
|
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
|
||||||
CalendarContract.Events.STATUS,
|
CalendarContract.Events.STATUS,
|
||||||
CalendarContract.Events.TITLE,
|
|
||||||
CalendarContract.Events.ALL_DAY,
|
|
||||||
CalendarContract.Events.DTSTART,
|
CalendarContract.Events.DTSTART,
|
||||||
CalendarContract.Events.DTEND,
|
CalendarContract.Events.DTEND,
|
||||||
CalendarContract.Events.DURATION,
|
CalendarContract.Events.DURATION,
|
||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
|
||||||
CalendarContract.Events.AVAILABILITY,
|
|
||||||
CalendarContract.Events.ACCESS_LEVEL,
|
|
||||||
CalendarContract.Events.EVENT_LOCATION,
|
|
||||||
CalendarContract.Events.DESCRIPTION,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
const val IDX_ORIGINAL_INSTANCE_TIME = 1
|
const val IDX_ORIGINAL_INSTANCE_TIME = 1
|
||||||
const val IDX_STATUS = 2
|
const val IDX_STATUS = 2
|
||||||
const val IDX_TITLE = 3
|
const val IDX_DTSTART = 3
|
||||||
const val IDX_ALL_DAY = 4
|
const val IDX_DTEND = 4
|
||||||
const val IDX_DTSTART = 5
|
const val IDX_DURATION = 5
|
||||||
const val IDX_DTEND = 6
|
|
||||||
const val IDX_DURATION = 7
|
|
||||||
const val IDX_EVENT_TIMEZONE = 8
|
|
||||||
const val IDX_AVAILABILITY = 9
|
|
||||||
const val IDX_ACCESS_LEVEL = 10
|
|
||||||
const val IDX_LOCATION = 11
|
|
||||||
const val IDX_DESCRIPTION = 12
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
@@ -312,8 +298,3 @@ internal object ReminderProjection {
|
|||||||
const val IDX_MINUTES = 0
|
const val IDX_MINUTES = 0
|
||||||
const val IDX_METHOD = 1
|
const val IDX_METHOD = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object Fallbacks {
|
|
||||||
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
|
|
||||||
const val UNTITLED_EVENT = "(Ohne Titel)"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||||
}.coerceAtLeast(dtStart)
|
}.coerceAtLeast(dtStart)
|
||||||
|
|
||||||
val rawTitle = getString(SearchProjection.IDX_TITLE)
|
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
|
||||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
|
||||||
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+2
-4
@@ -2,16 +2,15 @@ package de.jeanlucmakiola.calendula.data.contacts
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.provider.ContactsContract
|
import android.provider.ContactsContract
|
||||||
import android.provider.ContactsContract.CommonDataKinds.Event
|
import android.provider.ContactsContract.CommonDataKinds.Event
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||||
|
import de.jeanlucmakiola.floret.android.hasPermission
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -31,8 +30,7 @@ interface ContactSpecialDatesDataSource {
|
|||||||
|
|
||||||
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
|
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
|
||||||
fun Context.hasContactsPermission(): Boolean =
|
fun Context.hasContactsPermission(): Boolean =
|
||||||
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
|
hasPermission(Manifest.permission.READ_CONTACTS)
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||||
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||||
|
|||||||
+3
-2
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
@@ -212,8 +213,8 @@ class SpecialDatesSyncEngine @Inject constructor(
|
|||||||
calendarId = calendarId,
|
calendarId = calendarId,
|
||||||
title = title,
|
title = title,
|
||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
start = start,
|
start = FormTime(start),
|
||||||
end = start,
|
end = FormTime(start),
|
||||||
reminders = reminderCtx.resolveAllDay(calendarId),
|
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||||
availability = Availability.Free,
|
availability = Availability.Free,
|
||||||
rrule = YEARLY_RRULE,
|
rrule = YEARLY_RRULE,
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
|||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
|
||||||
import kotlinx.coroutines.Dispatchers
|
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
@@ -61,6 +63,18 @@ abstract class DataBindModule {
|
|||||||
abstract fun bindSpecialDatesCalendarSpec(
|
abstract fun bindSpecialDatesCalendarSpec(
|
||||||
impl: AndroidSpecialDatesCalendarSpec,
|
impl: AndroidSpecialDatesCalendarSpec,
|
||||||
): SpecialDatesCalendarSpec
|
): SpecialDatesCalendarSpec
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindTodaySource(
|
||||||
|
impl: AndroidTodaySource,
|
||||||
|
): TodaySource
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindZoneSource(
|
||||||
|
impl: AndroidZoneSource,
|
||||||
|
): ZoneSource
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -71,8 +85,4 @@ object DataProvideModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
context.calendulaDataStore
|
context.calendulaDataStore
|
||||||
|
|
||||||
@Provides
|
|
||||||
@IoDispatcher
|
|
||||||
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.di
|
|
||||||
|
|
||||||
import javax.inject.Qualifier
|
|
||||||
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class IoDispatcher
|
|
||||||
@@ -7,11 +7,11 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
||||||
@@ -30,18 +31,19 @@ import de.jeanlucmakiola.calendula.widget.WidgetSize
|
|||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import java.time.temporal.WeekFields
|
import java.time.temporal.WeekFields
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/** Light/dark override. SYSTEM follows the device setting. */
|
/**
|
||||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
* Light/dark override. SYSTEM follows the device setting; BLACK is dark with
|
||||||
|
* pure-black surfaces, for AMOLED screens (#181).
|
||||||
|
*/
|
||||||
|
enum class ThemeMode { SYSTEM, LIGHT, DARK, BLACK }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Week-start override. [Auto] derives the first day from the active locale;
|
* Week-start override. [Auto] derives the first day from the active locale;
|
||||||
@@ -101,9 +103,8 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
|||||||
fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow<DayOfWeek> =
|
fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow<DayOfWeek> =
|
||||||
weekStart
|
weekStart
|
||||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||||
.stateIn(
|
.stateWhileSubscribed(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
// Seed with the locale convention rather than a hardcoded weekday, so
|
// Seed with the locale convention rather than a hardcoded weekday, so
|
||||||
// the first frame is already right for everyone still on Auto.
|
// the first frame is already right for everyone still on Auto.
|
||||||
initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()),
|
initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()),
|
||||||
@@ -287,6 +288,36 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many days the multi-day view shows side by side (#193). */
|
||||||
|
val multiDayCount: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setMultiDayCount(count: Int) {
|
||||||
|
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hours the day, week and multi-day timelines fit to the screen when they
|
||||||
|
* open (#322). Off by default; the hours survive switching it off.
|
||||||
|
*/
|
||||||
|
val timelineHours: Flow<TimelineHours> = store.data.map { prefs ->
|
||||||
|
TimelineHours.of(
|
||||||
|
enabled = prefs[TIMELINE_HOURS_ENABLED_KEY] ?: false,
|
||||||
|
startHour = prefs[TIMELINE_HOURS_START_KEY] ?: TimelineHours.DEFAULT_START_HOUR,
|
||||||
|
endHour = prefs[TIMELINE_HOURS_END_KEY] ?: TimelineHours.DEFAULT_END_HOUR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTimelineHours(hours: TimelineHours) {
|
||||||
|
val valid = TimelineHours.of(hours.enabled, hours.startHour, hours.endHour)
|
||||||
|
store.edit {
|
||||||
|
it[TIMELINE_HOURS_ENABLED_KEY] = valid.enabled
|
||||||
|
it[TIMELINE_HOURS_START_KEY] = valid.startHour
|
||||||
|
it[TIMELINE_HOURS_END_KEY] = valid.endHour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||||
* historical layout, where it's an extended FAB that fades in above the "+"
|
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||||
@@ -315,6 +346,19 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
|
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether tapping an event in the calendar views opens the edit form instead
|
||||||
|
* of the detail screen (#178). Defaults to OFF; read-only events and
|
||||||
|
* reminder notifications always open the detail screen.
|
||||||
|
*/
|
||||||
|
val tapOpensEditor: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[TAP_OPENS_EDITOR_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTapOpensEditor(enabled: Boolean) {
|
||||||
|
store.edit { it[TAP_OPENS_EDITOR_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||||
@@ -405,10 +449,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) {
|
|
||||||
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomic read-modify-write of the quick-switch config: [transform] sees the
|
* Atomic read-modify-write of the quick-switch config: [transform] sees the
|
||||||
* value currently stored, parsed inside the edit block, not the async-echoed
|
* value currently stored, parsed inside the edit block, not the async-echoed
|
||||||
@@ -586,6 +626,24 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's calendar step has been answered (or skipped). */
|
||||||
|
val onboardingCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_CALENDARS_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingCalendarsDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_CALENDARS_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's hidden-calendars step has been answered (or skipped). */
|
||||||
|
val onboardingHiddenCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_HIDDEN_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingHiddenCalendarsDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_HIDDEN_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether the wizard's backup step has been answered (or skipped). */
|
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||||
@@ -930,10 +988,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
if (view in config.enabled) view.name else "!${view.name}"
|
if (view in config.enabled) view.name else "!${view.name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep the given order (de-duplicated), then append any views it omits. */
|
/** Keep stored order; insert the new multi-day view after Week on upgrades. */
|
||||||
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
||||||
val ordered = seen.distinct()
|
val ordered = seen.distinct().toMutableList()
|
||||||
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
|
IMPLEMENTED_VIEWS.forEach { view ->
|
||||||
|
if (view !in ordered) {
|
||||||
|
val index = if (view == CalendarView.MultiDay && CalendarView.Week in ordered) {
|
||||||
|
ordered.indexOf(CalendarView.Week) + 1
|
||||||
|
} else {
|
||||||
|
ordered.size
|
||||||
|
}
|
||||||
|
ordered.add(index, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ordered
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -960,6 +1028,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
|
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
|
||||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||||
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
|
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
|
||||||
|
internal val TAP_OPENS_EDITOR_KEY = booleanPreferencesKey("tap_opens_editor")
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||||
@@ -977,6 +1046,10 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||||
|
internal val ONBOARDING_CALENDARS_KEY =
|
||||||
|
booleanPreferencesKey("onboarding_calendars_done")
|
||||||
|
internal val ONBOARDING_HIDDEN_KEY =
|
||||||
|
booleanPreferencesKey("onboarding_hidden_calendars_done")
|
||||||
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||||
@@ -995,8 +1068,14 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||||
private const val MINUTES_PER_DAY = 1_440
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
||||||
|
internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count")
|
||||||
|
internal val TIMELINE_HOURS_ENABLED_KEY = booleanPreferencesKey("timeline_hours_enabled")
|
||||||
|
internal val TIMELINE_HOURS_START_KEY = intPreferencesKey("timeline_hours_start")
|
||||||
|
internal val TIMELINE_HOURS_END_KEY = intPreferencesKey("timeline_hours_end")
|
||||||
/** Default snooze delay for the notification "Snooze" action. */
|
/** Default snooze delay for the notification "Snooze" action. */
|
||||||
const val DEFAULT_SNOOZE_MINUTES = 10
|
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||||
|
const val DEFAULT_MULTI_DAY_COUNT = 3
|
||||||
|
val MULTI_DAY_COUNTS = 3..6
|
||||||
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||||
stringPreferencesKey("per_calendar_reminder_override")
|
stringPreferencesKey("per_calendar_reminder_override")
|
||||||
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||||
|
|||||||
+8
-21
@@ -6,11 +6,8 @@ import android.content.Intent
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import de.jeanlucmakiola.floret.android.goAsync
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,27 +41,17 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
// Cancel now so the notification doesn't linger until the alarm;
|
// Cancel now so the notification doesn't linger until the alarm;
|
||||||
// the snooze delay read is the only async work.
|
// the snooze delay read is the only async work.
|
||||||
notifier.cancel(alert)
|
notifier.cancel(alert)
|
||||||
val pendingResult = goAsync()
|
goAsync {
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
val minutes = settingsPrefs.snoozeMinutes.first()
|
||||||
try {
|
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
|
||||||
val minutes = settingsPrefs.snoozeMinutes.first()
|
scheduler.schedule(alert, triggerAt)
|
||||||
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
|
|
||||||
scheduler.schedule(alert, triggerAt)
|
|
||||||
} finally {
|
|
||||||
pendingResult.finish()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ACTION_SHOW -> {
|
ACTION_SHOW -> {
|
||||||
val pendingResult = goAsync()
|
goAsync {
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||||
try {
|
notifier.post(alert)
|
||||||
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
|
||||||
notifier.post(alert)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
pendingResult.finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,12 @@ import android.app.AlarmManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.floret.android.setWakeup
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
|
||||||
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
|
|
||||||
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
|
|
||||||
* calendar app), user-revocable on 31–32.
|
|
||||||
*/
|
|
||||||
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
|
|
||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
|
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
|
||||||
* exists at a time, for the earliest reminder ahead; every firing re-scans and
|
* exists at a time, for the earliest reminder ahead; every firing re-scans and
|
||||||
@@ -31,16 +23,7 @@ class ReminderAlarmScheduler @Inject constructor(
|
|||||||
|
|
||||||
fun scheduleScan(triggerAtMillis: Long) {
|
fun scheduleScan(triggerAtMillis: Long) {
|
||||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||||
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
|
alarmManager.setWakeup(triggerAtMillis, scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
||||||
if (alarmManager.canScheduleExactCompat()) {
|
|
||||||
alarmManager.setExactAndAllowWhileIdle(
|
|
||||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
alarmManager.setAndAllowWhileIdle(
|
|
||||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
|
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
@@ -19,6 +17,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
import de.jeanlucmakiola.floret.android.hasPermission
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.datetime.isoDayNumber
|
import kotlinx.datetime.isoDayNumber
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
@@ -48,8 +47,7 @@ class ReminderNotifier @Inject constructor(
|
|||||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
fun canPost(): Boolean {
|
fun canPost(): Boolean {
|
||||||
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,13 +61,9 @@ class ReminderNotifier @Inject constructor(
|
|||||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||||
calendarDataSource.isCalendarVisible(calendarId) == false
|
calendarDataSource.isCalendarVisible(calendarId) == false
|
||||||
|
|
||||||
/**
|
/** Post [alert], unless its calendar is switched off. */
|
||||||
* Post [alert], unless its calendar is switched off. Returns whether the
|
suspend fun post(alert: ReminderAlert) {
|
||||||
* notification was put up, which the snooze re-show path uses to tell a
|
if (isSilenced(alert.calendarId)) return
|
||||||
* silenced reminder from a delivered one.
|
|
||||||
*/
|
|
||||||
suspend fun post(alert: ReminderAlert): Boolean {
|
|
||||||
if (isSilenced(alert.calendarId)) return false
|
|
||||||
ensureChannel()
|
ensureChannel()
|
||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
val is24Hour = settingsPrefs.timeFormat.first()
|
val is24Hour = settingsPrefs.timeFormat.first()
|
||||||
@@ -88,6 +82,7 @@ class ReminderNotifier @Inject constructor(
|
|||||||
is24Hour = is24Hour,
|
is24Hour = is24Hour,
|
||||||
today = Instant.now().atZone(zone).toLocalDate(),
|
today = Instant.now().atZone(zone).toLocalDate(),
|
||||||
firstDayOfWeek = firstDayOfWeek,
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
|
todayLabel = context.getString(R.string.reminder_day_today),
|
||||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||||
)
|
)
|
||||||
@@ -121,8 +116,6 @@ class ReminderNotifier @Inject constructor(
|
|||||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||||
}
|
}
|
||||||
// Handled either way — a retry hits the same revoked permission.
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||||
|
|||||||
@@ -2,19 +2,18 @@ package de.jeanlucmakiola.calendula.data.reminders
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
|
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
|
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
|
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
||||||
|
import de.jeanlucmakiola.floret.android.hasPermission
|
||||||
|
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineStart
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -113,9 +112,7 @@ class ReminderScanner @Inject constructor(
|
|||||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
|
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||||
context, Manifest.permission.READ_CALENDAR,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-scan when the provider changes, so a saved or deleted event re-arms the
|
* Re-scan when the provider changes, so a saved or deleted event re-arms the
|
||||||
|
|||||||
+2
-12
@@ -4,10 +4,7 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import de.jeanlucmakiola.floret.android.goAsync
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,14 +26,7 @@ class ReminderScheduleReceiver : BroadcastReceiver() {
|
|||||||
// exported and the broadcasts it takes are protected, so any other
|
// exported and the broadcasts it takes are protected, so any other
|
||||||
// action did not come from where it claims to.
|
// action did not come from where it claims to.
|
||||||
if (intent.action !in HANDLED_ACTIONS) return
|
if (intent.action !in HANDLED_ACTIONS) return
|
||||||
val pendingResult = goAsync()
|
goAsync { scanner.scan() }
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
|
||||||
try {
|
|
||||||
scanner.scan()
|
|
||||||
} finally {
|
|
||||||
pendingResult.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+2
-20
@@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula.data.reminders
|
|||||||
import android.app.AlarmManager
|
import android.app.AlarmManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.floret.android.setWakeup
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -31,24 +31,6 @@ class ReminderSnoozeScheduler @Inject constructor(
|
|||||||
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
|
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
if (canScheduleExact(alarmManager)) {
|
alarmManager.setWakeup(triggerAtMillis, pendingIntent)
|
||||||
alarmManager.setExactAndAllowWhileIdle(
|
|
||||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Exact alarms revoked (API 31–32); an inexact wake is the best
|
|
||||||
// available without nagging for SCHEDULE_EXACT_ALARM.
|
|
||||||
alarmManager.setAndAllowWhileIdle(
|
|
||||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
|
|
||||||
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
|
|
||||||
* (Calendula is a calendar app), user-revocable on 31–32.
|
|
||||||
*/
|
|
||||||
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
|
|
||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ import java.util.Locale
|
|||||||
* alone would be ambiguous)
|
* alone would be ambiguous)
|
||||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
||||||
* already unambiguous)
|
* already unambiguous)
|
||||||
* - all-day, one day: "11 Jun 2026"
|
* - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules,
|
||||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
* plus [todayLabel] — no time range to lean on)
|
||||||
|
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
|
||||||
*
|
*
|
||||||
* All-day instances already carry an explicit date, so they never gain a
|
* All-day instances store UTC midnights with an exclusive end, so they are read
|
||||||
* relative prefix. They store UTC midnights with an exclusive end, so they are
|
* in UTC and the end day is the last *covered* day (issue #308).
|
||||||
* read in UTC and the end day is the last *covered* day.
|
|
||||||
*/
|
*/
|
||||||
fun reminderTimeText(
|
fun reminderTimeText(
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
@@ -43,6 +43,7 @@ fun reminderTimeText(
|
|||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
firstDayOfWeek: DayOfWeek,
|
firstDayOfWeek: DayOfWeek,
|
||||||
|
todayLabel: String,
|
||||||
tomorrowLabel: String,
|
tomorrowLabel: String,
|
||||||
yesterdayLabel: String,
|
yesterdayLabel: String,
|
||||||
): String {
|
): String {
|
||||||
@@ -53,11 +54,16 @@ fun reminderTimeText(
|
|||||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
.minusDays(1)
|
.minusDays(1)
|
||||||
.coerceAtLeast(firstDay)
|
.coerceAtLeast(firstDay)
|
||||||
return if (lastDay == firstDay) {
|
val start = relativeDayPrefix(
|
||||||
dateFormat.format(firstDay)
|
day = firstDay,
|
||||||
} else {
|
today = today,
|
||||||
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
}
|
locale = locale,
|
||||||
|
dateFormat = dateFormat,
|
||||||
|
tomorrowLabel = tomorrowLabel,
|
||||||
|
yesterdayLabel = yesterdayLabel,
|
||||||
|
) ?: todayLabel
|
||||||
|
return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.time
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
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> = context
|
||||||
|
.broadcastTicks(Intent.ACTION_DATE_CHANGED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED)
|
||||||
|
.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
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.time
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone the device is in. Every screen that turns wall clock into instants
|
||||||
|
* reads it from here, so the timeline, the event form and a saved move use the
|
||||||
|
* same zone (#387).
|
||||||
|
*/
|
||||||
|
interface ZoneSource {
|
||||||
|
/** The device's zone, re-emitted whenever it changes. */
|
||||||
|
val zone: Flow<TimeZone>
|
||||||
|
|
||||||
|
/** The device's zone right now. */
|
||||||
|
fun current(): TimeZone
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follows the system's zone broadcast. */
|
||||||
|
class AndroidZoneSource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ZoneSource {
|
||||||
|
|
||||||
|
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
|
||||||
|
.map { current() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
|
override fun current(): TimeZone = TimeZone.currentSystemDefault()
|
||||||
|
}
|
||||||
@@ -46,3 +46,19 @@ fun hasSystemHiddenCalendars(
|
|||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
pendingDisabledIds: Set<Long>,
|
pendingDisabledIds: Set<Long>,
|
||||||
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The failure a calendar list implies, or null when there is something to show.
|
||||||
|
* Distinguishes "the device has no calendars" from "every one of them is
|
||||||
|
* switched off" (#239) — both leave the grid empty, but only the second is
|
||||||
|
* fixed from Settings → Calendars.
|
||||||
|
*
|
||||||
|
* The filter sheet's own hidden set is deliberately not folded in: emptying it
|
||||||
|
* is a deliberate act taken moments earlier, in a sheet that is still one tap
|
||||||
|
* away.
|
||||||
|
*/
|
||||||
|
fun calendarListFailure(calendars: List<CalendarSource>): FailureReason? = when {
|
||||||
|
calendars.isEmpty() -> FailureReason.NoCalendarsConfigured
|
||||||
|
calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
|
||||||
|
/** Lower values appear first in lists and get the upper visible lanes in grids. */
|
||||||
|
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
|
||||||
|
isDeclined -> 3
|
||||||
|
spansMultipleDays(zone) -> 0
|
||||||
|
isAllDay -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
|
||||||
|
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
|
||||||
|
compareBy<EventInstance> { it.displayPriority(zone) }
|
||||||
|
.thenBy { it.start }
|
||||||
|
.thenBy { it.title }
|
||||||
|
.thenBy { it.instanceId }
|
||||||
@@ -4,7 +4,7 @@ import kotlinx.datetime.LocalDate
|
|||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.UtcOffset
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@ data class EventForm(
|
|||||||
val calendarId: Long?,
|
val calendarId: Long?,
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val isAllDay: Boolean = false,
|
val isAllDay: Boolean = false,
|
||||||
val start: LocalDateTime,
|
val start: FormTime,
|
||||||
val end: LocalDateTime,
|
val end: FormTime,
|
||||||
/**
|
/**
|
||||||
* The zone [start]/[end] are wall-clock values in, or null to follow the
|
* The zone [start]/[end] are wall-clock values in, or null to follow the
|
||||||
* device — null is not "no zone", it is "whichever zone the device is in
|
* device — null is not "no zone", it is "whichever zone the device is in
|
||||||
@@ -70,6 +70,19 @@ data class EventForm(
|
|||||||
val attendees: List<EventAttendee> = emptyList(),
|
val attendees: List<EventAttendee> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One end of a form's span: a wall-clock [dateTime] in the form's zone, and the
|
||||||
|
* [offset] it was read at when the fall-back hour repeats that wall clock — both
|
||||||
|
* of the hour's instants share one [LocalDateTime] (#387). Null everywhere else,
|
||||||
|
* so a time changed and changed back compares equal again. A time picked by hand
|
||||||
|
* gets its pass from [repicked]; one without an offset reads as the earlier
|
||||||
|
* pass. One value, so a time never changes without its pass. See [toFormTime].
|
||||||
|
*/
|
||||||
|
data class FormTime(val dateTime: LocalDateTime, val offset: UtcOffset? = null) {
|
||||||
|
val date: LocalDate get() = dateTime.date
|
||||||
|
val time: LocalTime get() = dateTime.time
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One editable guest: the user controls the [email] (the identity we dedup and
|
* One editable guest: the user controls the [email] (the identity we dedup and
|
||||||
* write on), an optional display [name], and whether they're [optional] rather
|
* write on), an optional display [name], and whether they're [optional] rather
|
||||||
@@ -145,16 +158,15 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
|
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
|
||||||
}
|
}
|
||||||
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
|
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
|
||||||
|
val begin = Instant.fromEpochMilliseconds(beginMillis)
|
||||||
|
val finish = Instant.fromEpochMilliseconds(endMillis)
|
||||||
val (start, end) = if (instance.isAllDay) {
|
val (start, end) = if (instance.isAllDay) {
|
||||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
val startDate = begin.toLocalDateTime(TimeZone.UTC).date
|
||||||
.toLocalDateTime(TimeZone.UTC).date
|
val endExclusive = finish.toLocalDateTime(TimeZone.UTC).date
|
||||||
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
|
|
||||||
.toLocalDateTime(TimeZone.UTC).date
|
|
||||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
|
||||||
} else {
|
} else {
|
||||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
begin.toFormTime(formZone) to finish.toFormTime(formZone)
|
||||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
|
||||||
}
|
}
|
||||||
return EventForm(
|
return EventForm(
|
||||||
calendarId = instance.calendarId,
|
calendarId = instance.calendarId,
|
||||||
@@ -221,20 +233,18 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The form's times as they land in [target] — what a pinned event's wall-clock
|
* Whether this form's times mean something else in [target] — a timed event
|
||||||
* actually means where the user is standing. Null when there's nothing to
|
* pinned to another zone. False when there's nothing to disambiguate: an
|
||||||
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
|
* unpinned event (already in [target]), one pinned to [target] itself, an
|
||||||
* itself, an all-day event (no zone), or an unparseable pinned zone.
|
* all-day event (no zone), or an unparseable pinned zone.
|
||||||
*
|
*
|
||||||
* The form edits a pinned event in its own zone, so this is what lets the UI
|
* The form edits a pinned event in its own zone, so this is what tells the UI
|
||||||
* show the other side of the pair rather than making the user do the arithmetic.
|
* to show the other side of the pair rather than making the user do the arithmetic.
|
||||||
*/
|
*/
|
||||||
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
|
fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
|
||||||
if (isAllDay) return null
|
if (isAllDay) return false
|
||||||
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
|
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return false
|
||||||
if (pinned.id == target.id) return null
|
return pinned.id != target.id
|
||||||
return start.toInstant(pinned).toLocalDateTime(target) to
|
|
||||||
end.toInstant(pinned).toLocalDateTime(target)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,9 +264,19 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
|||||||
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
/**
|
||||||
|
* What stops this form from saving. A timed span is checked as instants in the
|
||||||
|
* form's zone (else [deviceZone]): one ending in the fall-back hour's repeat can
|
||||||
|
* end on an earlier wall clock than it starts (#387).
|
||||||
|
*/
|
||||||
|
fun EventForm.problems(deviceZone: TimeZone): Set<EventFormProblem> = buildSet {
|
||||||
if (calendarId == null) add(EventFormProblem.NoCalendar)
|
if (calendarId == null) add(EventFormProblem.NoCalendar)
|
||||||
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
|
val endsTooEarly = if (isAllDay) {
|
||||||
|
end.date < start.date
|
||||||
|
} else {
|
||||||
|
val zone = resolvedZone(deviceZone)
|
||||||
|
endInstant(zone) < startInstant(zone)
|
||||||
|
}
|
||||||
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
|
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
|
||||||
// An UNTIL before the first day would make the provider generate zero
|
// An UNTIL before the first day would make the provider generate zero
|
||||||
// occurrences — the event would silently vanish from every view.
|
// occurrences — the event would silently vanish from every view.
|
||||||
@@ -265,3 +285,20 @@ fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
|||||||
add(EventFormProblem.RecurrenceEndsBeforeStart)
|
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 },
|
||||||
|
)
|
||||||
|
|||||||
@@ -162,16 +162,20 @@ object EventSearch {
|
|||||||
val title = candidate.title?.takeIf { it.isNotBlank() }
|
val title = candidate.title?.takeIf { it.isNotBlank() }
|
||||||
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
||||||
// Collapsed so a multi-line description matches the way it is drawn.
|
// Collapsed so a multi-line description matches the way it is drawn.
|
||||||
val description = candidate.description
|
val rich = candidate.description?.let(::descriptionRichText)
|
||||||
|
val description = rich?.text
|
||||||
?.replace(WHITESPACE, " ")
|
?.replace(WHITESPACE, " ")
|
||||||
?.trim()
|
?.trim()
|
||||||
?.takeIf { it.isNotEmpty() }
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
// A link's address isn't in the drawn text, but "zoom.us" should still find the invite.
|
||||||
|
val linkUrls = rich?.links.orEmpty().map { it.url }
|
||||||
|
|
||||||
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
|
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
|
||||||
val present = { token: String ->
|
val present = { token: String ->
|
||||||
title?.contains(token, ignoreCase = true) == true ||
|
title?.contains(token, ignoreCase = true) == true ||
|
||||||
location?.contains(token, ignoreCase = true) == true ||
|
location?.contains(token, ignoreCase = true) == true ||
|
||||||
description?.contains(token, ignoreCase = true) == true
|
description?.contains(token, ignoreCase = true) == true ||
|
||||||
|
linkUrls.any { it.contains(token, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
if (!tokens.all(present)) return null
|
if (!tokens.all(present)) return null
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,19 @@ package de.jeanlucmakiola.calendula.domain
|
|||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.UtcOffset
|
||||||
|
import kotlinx.datetime.offsetAt
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
|
import kotlinx.datetime.toJavaZoneId
|
||||||
|
import kotlinx.datetime.toJavaZoneOffset
|
||||||
|
import kotlinx.datetime.toKotlinUtcOffset
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import java.time.ZonedDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
import kotlin.time.toKotlinInstant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The zone this form's wall-clock times mean, matching what the data layer
|
* The zone this form's wall-clock times mean, matching what the data layer
|
||||||
@@ -15,6 +24,114 @@ import kotlin.time.Instant
|
|||||||
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
||||||
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
|
||||||
|
* it and that is one of its two offsets — otherwise the earlier one (#387).
|
||||||
|
*/
|
||||||
|
fun LocalDateTime.resolveIn(zone: TimeZone, preferred: UtcOffset?): Instant =
|
||||||
|
ZonedDateTime.ofLocal(toJavaLocalDateTime(), zone.toJavaZoneId(), preferred?.toJavaZoneOffset())
|
||||||
|
.toInstant().toKotlinInstant()
|
||||||
|
|
||||||
|
/** Whether this instant's wall clock in [zone] is one the fall-back hour repeats. */
|
||||||
|
fun Instant.inRepeatedHour(zone: TimeZone): Boolean {
|
||||||
|
val zid = zone.toJavaZoneId()
|
||||||
|
return zid.rules.getTransition(java.time.LocalDateTime.ofInstant(toJavaInstant(), zid))?.isOverlap == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [at]'s offset in this zone when its wall clock is one the fall-back hour
|
||||||
|
* repeats, else null: elsewhere the wall clock alone already names the instant.
|
||||||
|
* What a [FormTime.offset] holds (#387).
|
||||||
|
*/
|
||||||
|
fun TimeZone.repeatedHourOffsetAt(at: Instant): UtcOffset? = if (at.inRepeatedHour(this)) offsetAt(at) else null
|
||||||
|
|
||||||
|
/** This instant as a [FormTime] in [zone], keeping its pass of a repeated hour. */
|
||||||
|
fun Instant.toFormTime(zone: TimeZone): FormTime = FormTime(toLocalDateTime(zone), zone.repeatedHourOffsetAt(this))
|
||||||
|
|
||||||
|
/** This time as an instant in [zone], on its kept pass of a repeated hour. */
|
||||||
|
fun FormTime.instantIn(zone: TimeZone): Instant = dateTime.resolveIn(zone, offset)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This edge of an event moved to [picked] by hand — a picker or a resize handle —
|
||||||
|
* as a [FormTime] in [zone]. A time in the spring-forward gap moves on to the
|
||||||
|
* real one, as the provider would read it. A wall clock the fall-back hour
|
||||||
|
* repeats has two instants; which one it means (#387):
|
||||||
|
* - [original]'s, when [picked] is the wall clock the event was opened with, so
|
||||||
|
* a time changed and changed back is the time it was;
|
||||||
|
* - else the one that moves the edge in the direction the user moved it:
|
||||||
|
* earlier than this edge's instant when [picked] is before [shownAt] (where
|
||||||
|
* the edge was on screen), later when after;
|
||||||
|
* - kept within [notBefore]..[notAfter] (the other edge), and to the ones
|
||||||
|
* [drawable] accepts — where the screen can show it — when any fit.
|
||||||
|
*/
|
||||||
|
fun FormTime.repicked(
|
||||||
|
picked: LocalDateTime,
|
||||||
|
zone: TimeZone,
|
||||||
|
shownAt: LocalDateTime = dateTime,
|
||||||
|
original: FormTime? = null,
|
||||||
|
notBefore: Instant? = null,
|
||||||
|
notAfter: Instant? = null,
|
||||||
|
drawable: (Instant) -> Boolean = { true },
|
||||||
|
): FormTime {
|
||||||
|
if (picked == dateTime && picked == shownAt) return this
|
||||||
|
val offsets = zone.toJavaZoneId().rules.getValidOffsets(picked.toJavaLocalDateTime())
|
||||||
|
if (offsets.isEmpty()) return FormTime(picked).instantIn(zone).toFormTime(zone)
|
||||||
|
if (offsets.size == 1) return FormTime(picked)
|
||||||
|
val passes = offsets.map { picked.toInstant(it.toKotlinUtcOffset()) }.sorted()
|
||||||
|
val bounded = passes.filter { (notBefore == null || it >= notBefore) && (notAfter == null || it <= notAfter) }
|
||||||
|
.ifEmpty { passes }
|
||||||
|
val fitting = bounded.filter(drawable).ifEmpty { bounded }
|
||||||
|
if (original?.dateTime == picked && original.offset != null && original.instantIn(zone) in fitting) return original
|
||||||
|
val from = instantIn(zone)
|
||||||
|
val chosen = when {
|
||||||
|
picked < shownAt -> fitting.lastOrNull { it < from }
|
||||||
|
picked > shownAt -> fitting.firstOrNull { it > from }
|
||||||
|
else -> null
|
||||||
|
} ?: fitting.minBy { (it - from).absoluteValue }
|
||||||
|
return chosen.toFormTime(zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The form's start as an instant in [zone]. */
|
||||||
|
fun EventForm.startInstant(zone: TimeZone): Instant = start.instantIn(zone)
|
||||||
|
|
||||||
|
/** The form's end as an instant in [zone]. */
|
||||||
|
fun EventForm.endInstant(zone: TimeZone): Instant = end.instantIn(zone)
|
||||||
|
|
||||||
|
/** The form's timed span set to these instants, read in [zone]. */
|
||||||
|
fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm =
|
||||||
|
copy(start = start.toFormTime(zone), end = end.toFormTime(zone))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form pinned to [zoneId] (null: the device). A kept pass of a repeated hour
|
||||||
|
* was one of the old zone's, so it goes when the zone the times mean changes.
|
||||||
|
*/
|
||||||
|
fun EventForm.withTimezone(zoneId: String?, deviceZone: TimeZone): EventForm {
|
||||||
|
val pinned = copy(timezone = zoneId)
|
||||||
|
if (pinned.resolvedZone(deviceZone) == resolvedZone(deviceZone)) return pinned
|
||||||
|
return pinned.withoutKeptPasses()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form with its start picked by hand, keeping its length. A repeated hour's
|
||||||
|
* pass is [repicked]: [original] is the start the event was opened with.
|
||||||
|
*/
|
||||||
|
fun EventForm.withStart(start: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm {
|
||||||
|
val span = endInstant(zone) - startInstant(zone)
|
||||||
|
val startAt = this.start.repicked(start, zone, original = original).instantIn(zone)
|
||||||
|
return withTimes(startAt, startAt + span, zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form with its end picked by hand. A repeated hour's pass is [repicked],
|
||||||
|
* kept after the start where it can be: [original] is the end the event was
|
||||||
|
* opened with.
|
||||||
|
*/
|
||||||
|
fun EventForm.withEnd(end: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm =
|
||||||
|
copy(end = this.end.repicked(end, zone, original = original, notBefore = startInstant(zone)))
|
||||||
|
|
||||||
|
/** The form with no kept passes: its times as the wall clock alone says them. */
|
||||||
|
fun EventForm.withoutKeptPasses(): EventForm = copy(start = FormTime(start.dateTime), end = FormTime(end.dateTime))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The form moved so it starts at [newStart], keeping its **instant** duration —
|
* The form moved so it starts at [newStart], keeping its **instant** duration —
|
||||||
* a recurring event's length travels to the provider as `DURATION`, so keeping
|
* a recurring event's length travels to the provider as `DURATION`, so keeping
|
||||||
@@ -26,11 +143,8 @@ fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
|||||||
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
|
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
|
||||||
if (isAllDay) return this
|
if (isAllDay) return this
|
||||||
val zone = resolvedZone(deviceZone)
|
val zone = resolvedZone(deviceZone)
|
||||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
val span = endInstant(zone) - startInstant(zone)
|
||||||
return copy(
|
return withTimes(newStart, newStart + span, zone)
|
||||||
start = newStart.toLocalDateTime(zone),
|
|
||||||
end = (newStart + span).toLocalDateTime(zone),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,11 +157,21 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
|||||||
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
return copy(
|
return copy(
|
||||||
start = newStart,
|
start = FormTime(newStart),
|
||||||
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
|
end = FormTime(LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val zone = resolvedZone(deviceZone)
|
val zone = resolvedZone(deviceZone)
|
||||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
val span = endInstant(zone) - startInstant(zone)
|
||||||
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
val startAt = FormTime(newStart, start.offset).instantIn(zone)
|
||||||
|
// The start keeps the wall clock it was moved to, even one the spring-forward
|
||||||
|
// gap skips, so a recurring series still sees a whole-day move.
|
||||||
|
val movedStart = startAt.toFormTime(zone).takeIf { it.dateTime == newStart } ?: FormTime(newStart)
|
||||||
|
return copy(start = movedStart, end = (startAt + span).toFormTime(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
|
||||||
|
return withTimes(newStart, newEnd, resolvedZone(deviceZone))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
/** Inline formatting an HTML description keeps; fonts, sizes and colours are dropped. */
|
||||||
|
enum class InlineStyle { Bold, Italic, Underline, Strikethrough, Superscript, Subscript, Monospace }
|
||||||
|
|
||||||
|
data class StyleRange(val start: Int, val end: Int, val style: InlineStyle)
|
||||||
|
|
||||||
|
/** A link from an `<a href>`, spanning [start] until [end] (exclusive). */
|
||||||
|
data class LinkRange(val start: Int, val end: Int, val url: String)
|
||||||
|
|
||||||
|
/** Text as drawn, with the formatting and links that sit on it. Plain text has neither. */
|
||||||
|
data class RichText(
|
||||||
|
val text: String,
|
||||||
|
val styles: List<StyleRange> = emptyList(),
|
||||||
|
val links: List<LinkRange> = emptyList(),
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* [text] with each link's address after it, where the text doesn't already
|
||||||
|
* show it, so a copied "Join the meeting" still carries its URL.
|
||||||
|
*/
|
||||||
|
fun copyText(): String {
|
||||||
|
if (links.isEmpty()) return text
|
||||||
|
val out = StringBuilder(text)
|
||||||
|
for (link in links.sortedByDescending { it.end }) {
|
||||||
|
val address = link.url.removePrefix("mailto:").removePrefix("tel:")
|
||||||
|
if (address !in text.substring(link.start, link.end)) out.insert(link.end, " ($address)")
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun looksLikeHtml(text: String): Boolean = htmlTagRegex.containsMatchIn(text)
|
||||||
|
|
||||||
|
/** [description] as it reads: HTML rendered down to [RichText], plain text as it is. */
|
||||||
|
fun descriptionRichText(description: String): RichText =
|
||||||
|
if (looksLikeHtml(description)) parseHtml(description) else RichText(description)
|
||||||
|
|
||||||
|
private val BLOCK_TAGS = setOf(
|
||||||
|
"p", "div", "ul", "ol", "li", "table", "tr", "blockquote", "pre", "hr", "section", "article",
|
||||||
|
"header", "footer", "dl", "dt", "dd", "address", "center", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Their content is never text to show.
|
||||||
|
private val SKIPPED_TAGS = setOf("head", "style", "script", "title", "xml", "template")
|
||||||
|
|
||||||
|
private val STYLE_TAGS = mapOf(
|
||||||
|
"b" to InlineStyle.Bold, "strong" to InlineStyle.Bold, "th" to InlineStyle.Bold,
|
||||||
|
"h1" to InlineStyle.Bold, "h2" to InlineStyle.Bold, "h3" to InlineStyle.Bold,
|
||||||
|
"h4" to InlineStyle.Bold, "h5" to InlineStyle.Bold, "h6" to InlineStyle.Bold,
|
||||||
|
"i" to InlineStyle.Italic, "em" to InlineStyle.Italic, "cite" to InlineStyle.Italic,
|
||||||
|
"u" to InlineStyle.Underline, "ins" to InlineStyle.Underline,
|
||||||
|
"s" to InlineStyle.Strikethrough, "strike" to InlineStyle.Strikethrough, "del" to InlineStyle.Strikethrough,
|
||||||
|
"sup" to InlineStyle.Superscript, "sub" to InlineStyle.Subscript,
|
||||||
|
"code" to InlineStyle.Monospace, "tt" to InlineStyle.Monospace, "pre" to InlineStyle.Monospace,
|
||||||
|
)
|
||||||
|
|
||||||
|
// A real tag the renderer knows, not prose like "<3" or "a < b". Google
|
||||||
|
// Calendar and Exchange both store their descriptions as HTML.
|
||||||
|
private val htmlTagRegex = Regex(
|
||||||
|
(BLOCK_TAGS + SKIPPED_TAGS + STYLE_TAGS.keys + setOf("html", "body", "br", "span", "a", "font", "img", "meta", "td"))
|
||||||
|
.joinToString("|", prefix = """</?(?:""", postfix = """)(?:\s[^<>]*)?/?>"""),
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LINK_SCHEMES = listOf("http://", "https://", "mailto:", "tel:")
|
||||||
|
|
||||||
|
private const val NBSP = '\u00A0'
|
||||||
|
private const val CELL_SEPARATOR = " · "
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render [html] down to text with its inline formatting and links. Blocks and
|
||||||
|
* `<br>` become line breaks, list items get a bullet or number, table rows a
|
||||||
|
* line each with their cells side by side. Lenient about broken markup:
|
||||||
|
* whatever doesn't parse as a tag stays text.
|
||||||
|
*/
|
||||||
|
fun parseHtml(html: String): RichText = HtmlRenderer(html).render()
|
||||||
|
|
||||||
|
// Markup that sets its own lines. Without any, the text's newlines are the
|
||||||
|
// lines: "Agenda:\n1. Intro" with one <a href> in it stays on two lines.
|
||||||
|
private val lineTagRegex = Regex("""<(?:br|p|div|li|tr|h[1-6]|pre|blockquote|table)\b""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
private class HtmlRenderer(private val html: String) {
|
||||||
|
private val out = StringBuilder()
|
||||||
|
private val styles = mutableListOf<StyleRange>()
|
||||||
|
private val links = mutableListOf<LinkRange>()
|
||||||
|
private val openStyles = mutableListOf<Pair<String, Int>>()
|
||||||
|
private var openLink: Pair<String, Int>? = null
|
||||||
|
private val lists = mutableListOf<ListState>()
|
||||||
|
private val keepNewlines = !lineTagRegex.containsMatchIn(html)
|
||||||
|
private var preDepth = 0
|
||||||
|
private var rowStart = 0
|
||||||
|
private var cellNeedsSeparator = false
|
||||||
|
// Where the current list item's bullet ends, so a <p> inside the item
|
||||||
|
// doesn't push its text off the bullet's line.
|
||||||
|
private var markerEnd = -1
|
||||||
|
|
||||||
|
private class ListState(val ordered: Boolean, var count: Int = 0)
|
||||||
|
|
||||||
|
fun render(): RichText {
|
||||||
|
var i = 0
|
||||||
|
while (i < html.length) {
|
||||||
|
val lt = html.indexOf('<', i)
|
||||||
|
if (lt < 0) {
|
||||||
|
text(html.substring(i))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (lt > i) text(html.substring(i, lt))
|
||||||
|
i = tag(lt)
|
||||||
|
}
|
||||||
|
return normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle the markup at [lt]; returns where reading continues. */
|
||||||
|
private fun tag(lt: Int): Int {
|
||||||
|
if (html.startsWith("<!--", lt)) {
|
||||||
|
val end = html.indexOf("-->", lt + 4)
|
||||||
|
return if (end < 0) html.length else end + 3
|
||||||
|
}
|
||||||
|
if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
|
||||||
|
val end = html.indexOf('>', lt)
|
||||||
|
return if (end < 0) html.length else end + 1
|
||||||
|
}
|
||||||
|
val closing = html.getOrNull(lt + 1) == '/'
|
||||||
|
val nameStart = if (closing) lt + 2 else lt + 1
|
||||||
|
if (html.getOrNull(nameStart)?.isLetter() != true) {
|
||||||
|
text("<")
|
||||||
|
return lt + 1
|
||||||
|
}
|
||||||
|
val end = tagEnd(lt)
|
||||||
|
if (end < 0) {
|
||||||
|
text("<")
|
||||||
|
return lt + 1
|
||||||
|
}
|
||||||
|
var nameEnd = nameStart
|
||||||
|
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
|
||||||
|
val name = html.substring(nameStart, nameEnd).lowercase()
|
||||||
|
val attrs = html.substring(nameEnd, end)
|
||||||
|
if (closing) {
|
||||||
|
close(name)
|
||||||
|
} else if (name in SKIPPED_TAGS && !attrs.trimEnd().endsWith("/")) {
|
||||||
|
// Never closed (an omitted </head> is valid): just ignore the tag.
|
||||||
|
val closeAt = html.indexOf("</$name", end, ignoreCase = true)
|
||||||
|
if (closeAt < 0) return end + 1
|
||||||
|
val closeEnd = html.indexOf('>', closeAt)
|
||||||
|
return if (closeEnd < 0) html.length else closeEnd + 1
|
||||||
|
} else {
|
||||||
|
open(name, attrs)
|
||||||
|
}
|
||||||
|
return end + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
|
||||||
|
private fun tagEnd(lt: Int): Int {
|
||||||
|
var quote: Char? = null
|
||||||
|
for (j in lt + 1 until html.length) {
|
||||||
|
val c = html[j]
|
||||||
|
when {
|
||||||
|
quote != null -> if (c == quote) quote = null
|
||||||
|
c == '"' || c == '\'' -> quote = c
|
||||||
|
c == '>' -> return j
|
||||||
|
c == '<' -> return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(name: String, attrs: String) {
|
||||||
|
when (name) {
|
||||||
|
"br" -> newline()
|
||||||
|
"li" -> {
|
||||||
|
markerEnd = -1
|
||||||
|
cellNeedsSeparator = false
|
||||||
|
lineBreak()
|
||||||
|
val list = lists.lastOrNull()
|
||||||
|
out.append("$NBSP$NBSP".repeat((lists.size - 1).coerceAtLeast(0)))
|
||||||
|
if (list?.ordered == true) {
|
||||||
|
list.count++
|
||||||
|
out.append("${list.count}. ")
|
||||||
|
} else {
|
||||||
|
out.append("• ")
|
||||||
|
}
|
||||||
|
markerEnd = out.length
|
||||||
|
}
|
||||||
|
"ul", "ol" -> {
|
||||||
|
cellNeedsSeparator = false
|
||||||
|
lineBreak()
|
||||||
|
val start = startRegex.find(attrs)?.groupValues?.get(1)?.toIntOrNull() ?: 1
|
||||||
|
lists += ListState(ordered = name == "ol", count = start - 1)
|
||||||
|
}
|
||||||
|
"tr" -> {
|
||||||
|
lineBreak()
|
||||||
|
rowStart = out.length
|
||||||
|
cellNeedsSeparator = false
|
||||||
|
}
|
||||||
|
"td", "th" -> {
|
||||||
|
// An empty spacer cell leaves nothing behind, not even its .
|
||||||
|
if (rowStart <= out.length && (rowStart until out.length).none { out[it].isContent() }) {
|
||||||
|
cut(rowStart)
|
||||||
|
} else {
|
||||||
|
cellNeedsSeparator = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"a" -> {
|
||||||
|
closeLink()
|
||||||
|
hrefRegex.find(attrs)
|
||||||
|
?.groupValues?.drop(1)?.firstOrNull { it.isNotEmpty() }
|
||||||
|
?.let(::decodeEntities)
|
||||||
|
?.trim()
|
||||||
|
?.let { href ->
|
||||||
|
val scheme = LINK_SCHEMES.firstOrNull { href.startsWith(it, ignoreCase = true) }
|
||||||
|
?: return@let
|
||||||
|
// Intent filters match the scheme case-sensitively.
|
||||||
|
openLink = scheme + href.substring(scheme.length) to out.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> if (name in BLOCK_TAGS) lineBreak()
|
||||||
|
}
|
||||||
|
if (name == "pre") preDepth++
|
||||||
|
STYLE_TAGS[name]?.let { openStyles += name to out.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun close(name: String) {
|
||||||
|
// A row's last cell, if empty, leaves a separator pending; it ends with the row.
|
||||||
|
if (name == "tr" || name == "table") cellNeedsSeparator = false
|
||||||
|
val index = openStyles.indexOfLast { it.first == name }
|
||||||
|
if (index >= 0) {
|
||||||
|
val (_, start) = openStyles.removeAt(index)
|
||||||
|
if (out.length > start) styles += StyleRange(start, out.length, STYLE_TAGS.getValue(name))
|
||||||
|
}
|
||||||
|
if (name == "pre") preDepth = (preDepth - 1).coerceAtLeast(0)
|
||||||
|
if (name == "li" || name == "ul" || name == "ol") {
|
||||||
|
if (out.length == markerEnd) cut(out.lastIndexOf("\n") + 1)
|
||||||
|
markerEnd = -1
|
||||||
|
}
|
||||||
|
when (name) {
|
||||||
|
"ul", "ol" -> {
|
||||||
|
lists.removeLastOrNull()
|
||||||
|
lineBreak()
|
||||||
|
}
|
||||||
|
"a" -> closeLink()
|
||||||
|
else -> if (name in BLOCK_TAGS) lineBreak()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeLink() {
|
||||||
|
val (url, start) = openLink ?: return
|
||||||
|
if (out.length > start) links += LinkRange(start, out.length, url)
|
||||||
|
openLink = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun text(raw: String) {
|
||||||
|
val decoded = decodeEntities(raw)
|
||||||
|
if (preDepth > 0) {
|
||||||
|
out.append(decoded.replace("\r\n", "\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (c in decoded) {
|
||||||
|
if (c == '\n' && keepNewlines) {
|
||||||
|
newline()
|
||||||
|
} else if (c == ' ' || c == '\n' || c == '\t' || c == '\r' || c == '\u000C') {
|
||||||
|
if (out.isNotEmpty() && out.last() != ' ' && out.last() != '\n') out.append(' ')
|
||||||
|
} else {
|
||||||
|
if (cellNeedsSeparator && c.isContent()) separateCell()
|
||||||
|
out.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put this cell's text beside the row's last, even after a block in that cell ended its line. */
|
||||||
|
private fun separateCell() {
|
||||||
|
cellNeedsSeparator = false
|
||||||
|
var end = out.length
|
||||||
|
while (end > rowStart && !out[end - 1].isContent()) end--
|
||||||
|
cut(end)
|
||||||
|
out.append(CELL_SEPARATOR)
|
||||||
|
openStyles.replaceAll { (name, start) -> name to if (start >= end) out.length else start }
|
||||||
|
openLink = openLink?.let { (url, start) -> url to if (start >= end) out.length else start }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Char.isContent() = !isWhitespace() && this != NBSP
|
||||||
|
|
||||||
|
private fun newline() {
|
||||||
|
trimTrailingSpaces()
|
||||||
|
out.append('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End the current line, unless it's already ended or holds just a bullet. */
|
||||||
|
private fun lineBreak() {
|
||||||
|
if (out.length == markerEnd) return
|
||||||
|
trimTrailingSpaces()
|
||||||
|
if (out.isNotEmpty() && out.last() != '\n') out.append('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trimTrailingSpaces() {
|
||||||
|
while (out.isNotEmpty() && out.last() == ' ') out.setLength(out.length - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the output from [length] on, with whatever formatting lay only there. */
|
||||||
|
private fun cut(length: Int) {
|
||||||
|
if (length >= out.length) return
|
||||||
|
out.setLength(length)
|
||||||
|
styles.replaceAll { it.copy(end = minOf(it.end, length)) }
|
||||||
|
styles.removeAll { it.end <= it.start }
|
||||||
|
links.replaceAll { it.copy(end = minOf(it.end, length)) }
|
||||||
|
links.removeAll { it.end <= it.start }
|
||||||
|
openStyles.replaceAll { (name, start) -> name to minOf(start, length) }
|
||||||
|
openLink = openLink?.let { (url, start) -> url to minOf(start, length) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tidy the rendered text: no-break spaces become plain, lines lose trailing
|
||||||
|
* blanks, runs of empty lines shrink to one and the ends are trimmed. Spans
|
||||||
|
* move with the text they cover.
|
||||||
|
*/
|
||||||
|
private fun normalize(): RichText {
|
||||||
|
closeLink()
|
||||||
|
for ((name, start) in openStyles) {
|
||||||
|
if (out.length > start) styles += StyleRange(start, out.length, STYLE_TAGS.getValue(name))
|
||||||
|
}
|
||||||
|
val src = out.toString().replace(NBSP, ' ')
|
||||||
|
val map = IntArray(src.length + 1)
|
||||||
|
val result = StringBuilder()
|
||||||
|
var i = 0
|
||||||
|
while (i < src.length) {
|
||||||
|
var lineEnd = src.indexOf('\n', i)
|
||||||
|
if (lineEnd < 0) lineEnd = src.length
|
||||||
|
var contentEnd = lineEnd
|
||||||
|
while (contentEnd > i && src[contentEnd - 1].isWhitespace()) contentEnd--
|
||||||
|
for (j in i until lineEnd) {
|
||||||
|
map[j] = result.length
|
||||||
|
if (j < contentEnd) result.append(src[j])
|
||||||
|
}
|
||||||
|
if (lineEnd < src.length) {
|
||||||
|
map[lineEnd] = result.length
|
||||||
|
if (!result.endsWith("\n\n")) result.append('\n')
|
||||||
|
}
|
||||||
|
i = lineEnd + 1
|
||||||
|
}
|
||||||
|
map[src.length] = result.length
|
||||||
|
val leading = result.length - result.trimStart().length
|
||||||
|
val text = result.trim().toString()
|
||||||
|
fun at(index: Int) = (map[index] - leading).coerceIn(0, text.length)
|
||||||
|
return RichText(
|
||||||
|
text = text,
|
||||||
|
styles = styles.mapNotNull { r -> StyleRange(at(r.start), at(r.end), r.style).takeIf { it.end > it.start } },
|
||||||
|
links = links.mapNotNull { r -> LinkRange(at(r.start), at(r.end), r.url).takeIf { it.end > it.start } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val startRegex = Regex("""(?:^|\s)start\s*=\s*["']?(-?\d{1,6})""", RegexOption.IGNORE_CASE)
|
||||||
|
private val hrefRegex = Regex("""(?:^|\s)href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
// Latin-1 ( … ÿ) in code point order, then the common extras.
|
||||||
|
private val LATIN1_ENTITIES = ("nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr " +
|
||||||
|
"deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest " +
|
||||||
|
"Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml " +
|
||||||
|
"ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig " +
|
||||||
|
"agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml " +
|
||||||
|
"eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml").split(' ')
|
||||||
|
|
||||||
|
private val ENTITIES: Map<String, String> = LATIN1_ENTITIES.withIndex()
|
||||||
|
.associate { (i, name) -> name to (0xA0 + i).toChar().toString() } + mapOf(
|
||||||
|
"amp" to "&", "lt" to "<", "gt" to ">", "quot" to "\"", "apos" to "'",
|
||||||
|
"ndash" to "–", "mdash" to "—", "hellip" to "…", "bull" to "•",
|
||||||
|
"lsquo" to "‘", "rsquo" to "’", "ldquo" to "“", "rdquo" to "”", "sbquo" to "‚", "bdquo" to "„",
|
||||||
|
"euro" to "€", "trade" to "™", "OElig" to "Œ", "oelig" to "œ", "Scaron" to "Š", "scaron" to "š",
|
||||||
|
"zwnj" to "\u200C", "zwj" to "\u200D", "ensp" to "\u2002", "emsp" to "\u2003", "thinsp" to "\u2009",
|
||||||
|
)
|
||||||
|
|
||||||
|
// A named reference needs its ";": "?id=5®=eu" in a link is a query, not "®".
|
||||||
|
private val entityRegex = Regex("""&(?:(#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});?|([a-zA-Z][a-zA-Z0-9]{1,31});)""")
|
||||||
|
|
||||||
|
/** Decode character references; an unknown name is left as written. */
|
||||||
|
internal fun decodeEntities(text: String): String {
|
||||||
|
if ('&' !in text) return text
|
||||||
|
return entityRegex.replace(text) { match ->
|
||||||
|
val name = match.groupValues[2]
|
||||||
|
if (name.isNotEmpty()) return@replace ENTITIES[name] ?: match.value
|
||||||
|
val body = match.groupValues[1]
|
||||||
|
val code = when {
|
||||||
|
body.startsWith("#x", ignoreCase = true) -> body.substring(2).toIntOrNull(16)
|
||||||
|
else -> body.substring(1).toIntOrNull()
|
||||||
|
}
|
||||||
|
if (code == null || code == 0 || !Character.isValidCodePoint(code)) "�" else String(Character.toChars(code))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import kotlinx.datetime.LocalDate
|
|||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -25,13 +24,13 @@ import kotlin.time.Instant
|
|||||||
* the extras lives in `MainActivity.insertFormOrNull`.
|
* the extras lives in `MainActivity.insertFormOrNull`.
|
||||||
*/
|
*/
|
||||||
fun buildInsertEventForm(
|
fun buildInsertEventForm(
|
||||||
beginMillis: Long?,
|
beginMillis: Long? = null,
|
||||||
endMillis: Long?,
|
endMillis: Long? = null,
|
||||||
isAllDay: Boolean,
|
isAllDay: Boolean = false,
|
||||||
title: String?,
|
title: String?,
|
||||||
description: String?,
|
description: String?,
|
||||||
location: String?,
|
location: String? = null,
|
||||||
rrule: String?,
|
rrule: String? = null,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
): EventForm {
|
): EventForm {
|
||||||
@@ -46,16 +45,12 @@ fun buildInsertEventForm(
|
|||||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||||
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
|
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
|
||||||
?: startDate
|
?: startDate
|
||||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
|
||||||
} else {
|
} else {
|
||||||
val startTime = beginMillis
|
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
|
||||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
|
||||||
?: nextFullHour(now, zone)
|
?: (startAt + 1.hours)
|
||||||
val endTime = endMillis
|
startAt.toFormTime(zone) to endAt.toFormTime(zone)
|
||||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
|
||||||
?.takeIf { it >= startTime }
|
|
||||||
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
|
||||||
startTime to endTime
|
|
||||||
}
|
}
|
||||||
return EventForm(
|
return EventForm(
|
||||||
calendarId = null,
|
calendarId = null,
|
||||||
@@ -71,8 +66,34 @@ fun buildInsertEventForm(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
|
/** The first full hour after [now]: may roll into the next day before midnight. */
|
||||||
|
internal fun nextFullHour(now: Instant): Instant {
|
||||||
val hourMillis = 3_600_000L
|
val hourMillis = 3_600_000L
|
||||||
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
return Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis)
|
||||||
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
}
|
||||||
|
|
||||||
|
/** Title and description for text shared or selected into the app (#306). */
|
||||||
|
data class SharedText(val title: String, val description: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split shared text into an event title and description: the share's subject
|
||||||
|
* becomes the title and the text the description. Without a subject the text's
|
||||||
|
* first non-blank line is the title and the rest the description. A title longer
|
||||||
|
* than [maxTitle] is cut, and the full text then stays in the description.
|
||||||
|
* Null when there is nothing to use.
|
||||||
|
*/
|
||||||
|
fun sharedTextOrNull(subject: String?, text: String?, maxTitle: Int = 255): SharedText? {
|
||||||
|
val body = text?.trim().orEmpty()
|
||||||
|
val given = subject?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
val (title, rest) = if (given != null) {
|
||||||
|
given to body
|
||||||
|
} else {
|
||||||
|
val lines = body.lines()
|
||||||
|
val first = lines.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||||
|
first to lines.drop(1).joinToString("\n").trim()
|
||||||
|
}
|
||||||
|
if (title.length <= maxTitle) return SharedText(title, rest)
|
||||||
|
val cut = title.take(maxTitle).let { if (it.last().isHighSurrogate()) it.dropLast(1) else it }
|
||||||
|
val full = if (given != null) listOf(given, body).filter { it.isNotEmpty() }.joinToString("\n\n") else body
|
||||||
|
return SharedText(cut.trimEnd(), full)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,17 @@ data class EventDetail(
|
|||||||
val isException: Boolean = false,
|
val isException: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This event on one occurrence's times. The Events row holds the series start,
|
||||||
|
* so a recurring event needs the tapped occurrence pinned to render and share right.
|
||||||
|
*/
|
||||||
|
fun EventDetail.atOccurrence(beginMillis: Long, endMillis: Long): EventDetail = copy(
|
||||||
|
instance = instance.copy(
|
||||||
|
start = Instant.fromEpochMilliseconds(beginMillis),
|
||||||
|
end = Instant.fromEpochMilliseconds(endMillis),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One selectable event colour published by a calendar's account
|
* One selectable event colour published by a calendar's account
|
||||||
* (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped
|
* (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped
|
||||||
@@ -252,6 +263,14 @@ enum class RecurringWriteScope {
|
|||||||
enum class FailureReason {
|
enum class FailureReason {
|
||||||
PermissionRevoked,
|
PermissionRevoked,
|
||||||
NoCalendarsConfigured,
|
NoCalendarsConfigured,
|
||||||
|
AllCalendarsHidden,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendars exist and are switched on, but none can receive an event: every
|
||||||
|
* one is read-only, app-managed, or not synced to this device. Distinct from
|
||||||
|
* [AllCalendarsHidden], which a visibility switch fixes.
|
||||||
|
*/
|
||||||
|
NoImportTarget,
|
||||||
ProviderUnavailable,
|
ProviderUnavailable,
|
||||||
EventNotFound,
|
EventNotFound,
|
||||||
Unknown,
|
Unknown,
|
||||||
|
|||||||
@@ -157,14 +157,36 @@ fun timeZoneOptionOf(
|
|||||||
* the offset isn't stated twice in ICU's spelling and ours.
|
* the offset isn't stated twice in ICU's spelling and ours.
|
||||||
*/
|
*/
|
||||||
fun zoneDescriptor(option: TimeZoneOption): String {
|
fun zoneDescriptor(option: TimeZoneOption): String {
|
||||||
val abbrev = option.shortName
|
val name = zoneAbbreviation(option)
|
||||||
return when {
|
val statesOffset = option.shortName == "UTC" || option.shortName.startsWith("GMT")
|
||||||
abbrev == "UTC" -> "UTC"
|
return if (statesOffset) name else "$name · ${formatGmtOffset(option.offsetMinutes)}"
|
||||||
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
|
|
||||||
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [option]'s short name on its own — "CEST", "UTC" — with an offset-shaped one
|
||||||
|
* normalised to our GMT form, as [zoneDescriptor] spells it.
|
||||||
|
*/
|
||||||
|
fun zoneAbbreviation(option: TimeZoneOption): String = abbreviation(option.shortName, option.offsetMinutes)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zone [id]'s abbreviation [at] an instant, as [zoneAbbreviation] spells it,
|
||||||
|
* without the localized long name a whole [TimeZoneOption] would look up. Null
|
||||||
|
* for an id the tz database can't parse.
|
||||||
|
*/
|
||||||
|
fun zoneAbbreviationAt(
|
||||||
|
id: String,
|
||||||
|
locale: Locale,
|
||||||
|
at: Instant,
|
||||||
|
regionOf: (String) -> String? = { null },
|
||||||
|
): String? {
|
||||||
|
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
||||||
|
val shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf)
|
||||||
|
return abbreviation(shortName, zone.rules.getOffset(at).totalSeconds / 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abbreviation(shortName: String, offsetMinutes: Int): String =
|
||||||
|
if (shortName.startsWith("GMT")) formatGmtOffset(offsetMinutes) else shortName
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [options] matching [query], best matches first; a blank query returns the list
|
* [options] matching [query], best matches first; a blank query returns the list
|
||||||
* unchanged. Matching is accent- and case-insensitive and treats underscores as
|
* unchanged. Matching is accent- and case-insensitive and treats underscores as
|
||||||
|
|||||||
@@ -166,14 +166,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
component != null -> {
|
component != null -> {
|
||||||
|
// A component whose END line never arrives ends at the next
|
||||||
|
// one's BEGIN, not at that one's END: reading to the far END
|
||||||
|
// would fold two events into one body, where the later
|
||||||
|
// properties overwrite the earlier and one event is lost.
|
||||||
val end = indexOfEnd(lines, i + 1, component)
|
val end = indexOfEnd(lines, i + 1, component)
|
||||||
|
val body = indexOfNextComponent(lines, i + 1, end)
|
||||||
parseComponent(
|
parseComponent(
|
||||||
body = lines.subList(i + 1, end),
|
body = lines.subList(i + 1, body),
|
||||||
fileCalendarName = calendarName,
|
fileCalendarName = calendarName,
|
||||||
warnings = warnings,
|
warnings = warnings,
|
||||||
isTask = component == "VTODO",
|
isTask = component == "VTODO",
|
||||||
)?.let(events::add)
|
)?.let(events::add)
|
||||||
i = end + 1
|
i = if (body < end) body else end + 1
|
||||||
}
|
}
|
||||||
line.isBegin("VTIMEZONE") -> {
|
line.isBegin("VTIMEZONE") -> {
|
||||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||||
@@ -445,6 +450,23 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||||
|
/**
|
||||||
|
* Where an unterminated component's body has to stop: the next top-level
|
||||||
|
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
|
||||||
|
* nested `VALARM` is part of the body and is not a boundary.
|
||||||
|
*/
|
||||||
|
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
|
||||||
|
var i = from
|
||||||
|
while (i < end) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
var i = from
|
var i = from
|
||||||
while (i < lines.size) {
|
while (i < lines.size) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain.ics
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toFormTime
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -24,9 +26,9 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
|||||||
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
|
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
|
||||||
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
|
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
|
||||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
|
||||||
} else {
|
} else {
|
||||||
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
|
this.start.toFormTime(zone) to this.end.toFormTime(zone)
|
||||||
}
|
}
|
||||||
return EventForm(
|
return EventForm(
|
||||||
calendarId = null,
|
calendarId = null,
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.PredictiveBackHandler
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
|
import androidx.compose.animation.core.SeekableTransitionState
|
||||||
|
import androidx.compose.animation.core.rememberTransition
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.listSaver
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.screen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInWindow
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
@@ -26,34 +44,69 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.StayingPut
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFab
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ChromeHost
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ChromeSlot
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.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.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventActionsViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
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.canWriteCalendar
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGateOr
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewFocus
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
||||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the top-level view back stack (spec M1) and swaps between the calendar
|
* Holds the top-level view back stack (spec M1) and swaps between the calendar
|
||||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
* screens. Each screen owns its own ViewModel; they all open on the one focused
|
||||||
* pill in their top bars writes back here via [onSelectView].
|
* date held here ([ViewFocus]), and the view-switcher pill in their top bars
|
||||||
|
* writes back here via [onSelectView].
|
||||||
*
|
*
|
||||||
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
||||||
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
||||||
@@ -70,6 +123,7 @@ import kotlin.time.Clock
|
|||||||
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
||||||
* widget's view.)
|
* widget's view.)
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarHost(
|
fun CalendarHost(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -82,6 +136,7 @@ fun CalendarHost(
|
|||||||
requestedInsertForm: EventForm? = null,
|
requestedInsertForm: EventForm? = null,
|
||||||
requestedInsertSource: ImportSource = ImportSource.Insert,
|
requestedInsertSource: ImportSource = ImportSource.Insert,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
|
onExternalFormClosed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
requestedEditKey: LongArray? = null,
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||||
@@ -101,14 +156,38 @@ fun CalendarHost(
|
|||||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||||
mutableStateOf(listOf(defaultView))
|
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()
|
val view = viewStack.last()
|
||||||
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
|
// The date every view opens on and carries along (#184); tapping a day
|
||||||
|
// anywhere focuses it and opens the day view there.
|
||||||
|
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
val switchScope = rememberCoroutineScope()
|
||||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
|
||||||
|
navigationVersion++
|
||||||
|
returnedFromDay = false
|
||||||
|
viewStack = move(viewStack)
|
||||||
|
}
|
||||||
|
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
|
||||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||||
pendingDayIso = date.toString()
|
focus.navigateTo(date)
|
||||||
viewStack = viewStack.drillToDay()
|
switchTo { it.drillToDay() }
|
||||||
|
}
|
||||||
|
val onOpenWeek: (LocalDate) -> Unit = { date ->
|
||||||
|
focus.navigateTo(date)
|
||||||
|
switchTo { it.drillToWeek() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||||
@@ -120,7 +199,7 @@ fun CalendarHost(
|
|||||||
// placeholder — so the destination never loads a bogus id=0 on first frame.
|
// placeholder — so the destination never loads a bogus id=0 on first frame.
|
||||||
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||||
var heldKey by remember { mutableStateOf<LongArray?>(null) }
|
var heldKey by remember { mutableStateOf<LongArray?>(null) }
|
||||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
val onOpenDetail: (EventInstance) -> Unit = { event ->
|
||||||
val key = longArrayOf(
|
val key = longArrayOf(
|
||||||
event.eventId,
|
event.eventId,
|
||||||
event.start.toEpochMilliseconds(),
|
event.start.toEpochMilliseconds(),
|
||||||
@@ -167,11 +246,23 @@ fun CalendarHost(
|
|||||||
var heldCreateIso by remember { mutableStateOf<String?>(null) }
|
var heldCreateIso by remember { mutableStateOf<String?>(null) }
|
||||||
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
|
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||||
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
|
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
|
||||||
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
|
// A drafted block's start and end (#153) as epoch millis. Instants rather than
|
||||||
|
// minutes into a day, so the form opens at the times drawn even across a DST
|
||||||
|
// change (#387).
|
||||||
|
var createDraftMillis by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||||
|
var heldCreateDraftMillis by remember { mutableStateOf<LongArray?>(null) }
|
||||||
|
fun openCreate(date: LocalDate, startMinutes: Int?, draftMillis: LongArray?) {
|
||||||
heldCreateIso = date.toString()
|
heldCreateIso = date.toString()
|
||||||
createDateIso = date.toString()
|
createDateIso = date.toString()
|
||||||
heldCreateMinutes = startMinutes
|
heldCreateMinutes = startMinutes
|
||||||
createStartMinutes = startMinutes
|
createStartMinutes = startMinutes
|
||||||
|
heldCreateDraftMillis = draftMillis
|
||||||
|
createDraftMillis = draftMillis
|
||||||
|
}
|
||||||
|
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date, startMinutes, null) }
|
||||||
|
val onCreateDraft: (Instant, Instant) -> Unit = { start, end ->
|
||||||
|
val date = start.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
openCreate(date, null, longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
|
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
|
||||||
@@ -191,15 +282,54 @@ fun CalendarHost(
|
|||||||
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
||||||
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
||||||
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
||||||
|
// Bumped per open, so even an equal prefill starts a fresh form.
|
||||||
|
var importFormId by remember { mutableIntStateOf(0) }
|
||||||
|
// Whether [importForm] came from an external create request (#30, #306);
|
||||||
|
// closing it then hands control back to whoever asked.
|
||||||
|
var importFromRequest by rememberSaveable { mutableStateOf(false) }
|
||||||
|
// A second external request that arrived while a prefilled form was open:
|
||||||
|
// it waits behind that form's unsaved-changes prompt, then opens.
|
||||||
|
var pendingInsert by remember { mutableStateOf<Pair<EventForm, ImportSource>?>(null) }
|
||||||
|
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
|
||||||
|
fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
|
||||||
|
importFormSource = source
|
||||||
|
importForm = form
|
||||||
|
importFormId++
|
||||||
|
importFromRequest = fromRequest
|
||||||
|
}
|
||||||
|
fun closeImportForm() {
|
||||||
|
val next = pendingInsert
|
||||||
|
pendingInsert = null
|
||||||
|
if (next != null) {
|
||||||
|
openImportForm(next.first, next.second, fromRequest = true)
|
||||||
|
} else {
|
||||||
|
importForm = null
|
||||||
|
if (importFromRequest) {
|
||||||
|
importFromRequest = false
|
||||||
|
onExternalFormClosed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// A restore (in-app "Restore from .ics" button) always runs the full import
|
// A restore (in-app "Restore from .ics" button) always runs the full import
|
||||||
// flow — picker + summary — even for a single-event file, because the intent
|
// flow — picker + summary — even for a single-event file, because the intent
|
||||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||||
// keeps routing a single event straight into the prefilled create form.
|
// keeps routing a single event straight into the prefilled create form.
|
||||||
var importForceMany by remember { mutableStateOf(false) }
|
var importForceMany by remember { mutableStateOf(false) }
|
||||||
|
// Where a restore came from, so closing the import puts it back: the import
|
||||||
|
// overlays are declared under Backup & restore and the manager, so starting
|
||||||
|
// one has to close them — without this, finishing a restore drops you on the
|
||||||
|
// calendar and the next file means walking in through Settings again.
|
||||||
|
var backupAfterImport by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var calendarsAfterImport by rememberSaveable { mutableStateOf(false) }
|
||||||
|
// One import run. The import VM lives in the Activity's store, so this is
|
||||||
|
// what tells it a *re-import of the same file* is new work and not the run
|
||||||
|
// it already finished; a rotation keeps the number and keeps the result.
|
||||||
|
var importSession by rememberSaveable { mutableStateOf(0) }
|
||||||
LaunchedEffect(requestedImportUri) {
|
LaunchedEffect(requestedImportUri) {
|
||||||
if (requestedImportUri != null) {
|
if (requestedImportUri != null) {
|
||||||
importUri = requestedImportUri
|
importUri = requestedImportUri
|
||||||
importForceMany = false
|
importForceMany = false
|
||||||
|
importSession++
|
||||||
onImportConsumed()
|
onImportConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,8 +339,11 @@ fun CalendarHost(
|
|||||||
// reveals on top of whatever was open without extra dismissal.
|
// reveals on top of whatever was open without extra dismissal.
|
||||||
LaunchedEffect(requestedInsertForm) {
|
LaunchedEffect(requestedInsertForm) {
|
||||||
if (requestedInsertForm != null) {
|
if (requestedInsertForm != null) {
|
||||||
importFormSource = requestedInsertSource
|
if (importForm == null) {
|
||||||
importForm = requestedInsertForm
|
openImportForm(requestedInsertForm, requestedInsertSource, fromRequest = true)
|
||||||
|
} else {
|
||||||
|
pendingInsert = requestedInsertForm to requestedInsertSource
|
||||||
|
}
|
||||||
onInsertConsumed()
|
onInsertConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,6 +359,9 @@ fun CalendarHost(
|
|||||||
editKey = null
|
editKey = null
|
||||||
importUri = null
|
importUri = null
|
||||||
importForm = null
|
importForm = null
|
||||||
|
importFromRequest = false
|
||||||
|
pendingInsert = null
|
||||||
|
importViewModel.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
||||||
@@ -247,13 +383,17 @@ fun CalendarHost(
|
|||||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||||
// view so backing out returns there (then home), not to the default.
|
// view so backing out returns there (then home), not to the default.
|
||||||
LaunchedEffect(widgetNavRequest) {
|
LaunchedEffect(widgetNavRequest) {
|
||||||
|
if (widgetNavRequest != null) {
|
||||||
|
navigationVersion++
|
||||||
|
returnedFromDay = false
|
||||||
|
}
|
||||||
when (val req = widgetNavRequest) {
|
when (val req = widgetNavRequest) {
|
||||||
is WidgetNavRequest.OpenDate -> {
|
is WidgetNavRequest.OpenDate -> {
|
||||||
// Drill the day view in over the widget's view: drop any overlay
|
// Drill the day view in over the widget's view: drop any overlay
|
||||||
// that would cover it, so the open doesn't land under Settings/form.
|
// that would cover it, so the open doesn't land under Settings/form.
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = req.dateIso
|
focus.navigateTo(LocalDate.parse(req.dateIso))
|
||||||
// No widget source (an external date tap) roots over the default
|
// No widget source (an external date tap) roots over the default
|
||||||
// home view, so backing out of the day returns home then exits.
|
// home view, so backing out of the day returns home then exits.
|
||||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||||
@@ -277,7 +417,7 @@ fun CalendarHost(
|
|||||||
// default home — so backing out returns to the default, then exits.
|
// default home — so backing out returns to the default, then exits.
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = null
|
focus.navigateTo(localToday())
|
||||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
@@ -293,6 +433,8 @@ fun CalendarHost(
|
|||||||
createDateIso = iso
|
createDateIso = iso
|
||||||
heldCreateMinutes = null
|
heldCreateMinutes = null
|
||||||
createStartMinutes = null
|
createStartMinutes = null
|
||||||
|
heldCreateDraftMillis = null
|
||||||
|
createDraftMillis = null
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
null -> {}
|
null -> {}
|
||||||
@@ -303,6 +445,9 @@ fun CalendarHost(
|
|||||||
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
||||||
val reschedule: RescheduleViewModel = hiltViewModel()
|
val reschedule: RescheduleViewModel = hiltViewModel()
|
||||||
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
|
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 onEditEvent: (EventInstance) -> Unit = { event ->
|
||||||
val key = longArrayOf(
|
val key = longArrayOf(
|
||||||
event.eventId,
|
event.eventId,
|
||||||
@@ -312,6 +457,28 @@ fun CalendarHost(
|
|||||||
heldEditKey = key
|
heldEditKey = key
|
||||||
editKey = key
|
editKey = key
|
||||||
}
|
}
|
||||||
|
// A tap opens the edit form instead of the detail screen when the user chose
|
||||||
|
// so (#178) — only for events whose calendar takes writes. Both are read at
|
||||||
|
// tap time, so a tap before they've loaded still routes right.
|
||||||
|
val tapScope = rememberCoroutineScope()
|
||||||
|
val tapContext = LocalContext.current
|
||||||
|
val requireWriteOr = rememberWriteGateOr()
|
||||||
|
// A refused write upgrade is asked once; later taps just open the details.
|
||||||
|
var tapWriteRefused by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||||
|
tapScope.launch {
|
||||||
|
val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId)
|
||||||
|
when {
|
||||||
|
!toEditor -> onOpenDetail(event)
|
||||||
|
tapContext.canWriteCalendar() -> onEditEvent(event)
|
||||||
|
tapWriteRefused -> onOpenDetail(event)
|
||||||
|
else -> requireWriteOr({ onEditEvent(event) }) {
|
||||||
|
tapWriteRefused = true
|
||||||
|
onOpenDetail(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Off by preference (#173) switches off the gesture, not the scope: blocks
|
// Off by preference (#173) switches off the gesture, not the scope: blocks
|
||||||
// register no drag, while the edit form and its TalkBack action stay put.
|
// register no drag, while the edit form and its TalkBack action stay put.
|
||||||
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
|
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
|
||||||
@@ -328,6 +495,9 @@ fun CalendarHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
// Overlays leave from the predictive-back preview (#184): shrinking on from
|
||||||
|
// where the gesture let go instead of snapping back and sliding off.
|
||||||
|
val overlayExit = predictiveBackExit()
|
||||||
|
|
||||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||||
@@ -335,88 +505,206 @@ fun CalendarHost(
|
|||||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||||
editKey != null || showSettings || showCalendars || showBackup ||
|
editKey != null || showSettings || showCalendars || showBackup ||
|
||||||
importUri != null || importForm != null
|
importUri != null || importForm != null
|
||||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
// The view switch runs on a seekable transition, so a back gesture can scrub
|
||||||
viewStack = viewStack.dropLast(1)
|
// it (#185): the previous view morphs in under the finger, releasing finishes
|
||||||
|
// the pop and cancelling springs back. Reduced motion keeps a plain back.
|
||||||
|
val viewTransition = remember { SeekableTransitionState(view) }
|
||||||
|
LaunchedEffect(view) { viewTransition.animateTo(view) }
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val viewBackEnabled = !anyOverlayVisible && viewStack.size > 1
|
||||||
|
if (reduceMotion) {
|
||||||
|
BackHandler(enabled = viewBackEnabled) {
|
||||||
|
navigationVersion++
|
||||||
|
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++
|
||||||
|
viewStack = viewStack.dropLast(1)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
withContext(NonCancellable) { viewTransition.animateTo(viewTransition.currentState) }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
// Switching between the month, timeline and agenda screens is lateral
|
||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. What both views show morphs across
|
||||||
|
// (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade.
|
||||||
val viewSwitch = fadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
val morphBounds = rememberViewMorphBoundsTransform()
|
||||||
AnimatedContent(
|
val morphFade = rememberCalendarFadeSpec()
|
||||||
targetState = view,
|
val morphClip = remember { BelowTopBarClip() }
|
||||||
|
val stayingPut = remember { StayingPut() }
|
||||||
|
val chromeHost = remember { ChromeHost() }
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
|
||||||
|
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||||
|
val timelineScroll = remember { TimelineScrollMemory() }
|
||||||
|
// A timeline reached from another screen opens on the visible hours
|
||||||
|
// (#322); only a switch between timeline views carries the scroll over.
|
||||||
|
val timelineZoom = LocalTimelineZoom.current
|
||||||
|
LaunchedEffect(view.screen) {
|
||||||
|
if (view.screen != ViewScreen.Timeline && timelineZoom.hours.enabled) {
|
||||||
|
timelineScroll.midnightY = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalEventMove provides moveScope,
|
||||||
|
LocalTimelineScroll provides timelineScroll,
|
||||||
|
LocalViewFocus provides focus,
|
||||||
|
// The failure state's way out when every calendar is switched off
|
||||||
|
// (#239); nothing else in the calendar surfaces navigates here.
|
||||||
|
LocalManageCalendars provides remember { { showCalendars = true } },
|
||||||
|
) {
|
||||||
|
// One drawer, top bar and FAB for every screen (#184): a switch changes
|
||||||
|
// what they say, from what the screen on show publishes, rather than
|
||||||
|
// swapping them for another screen's.
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
// Open only via the menu button where edge-swipe would fight paging;
|
||||||
|
// the agenda has no paging to fight.
|
||||||
|
gesturesEnabled = drawerState.isOpen || view.screen == ViewScreen.Agenda,
|
||||||
|
drawerContent = {
|
||||||
|
CalendarDrawer(
|
||||||
|
currentView = view,
|
||||||
|
currentDate = chromeHost.chrome?.drawerDate ?: focus.date,
|
||||||
|
drawerState = drawerState,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
|
onSelectView = { target ->
|
||||||
|
// Once the drawer is out of the way, so the switch is seen whole.
|
||||||
|
val requestVersion = ++navigationVersion
|
||||||
|
switchScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
if (navigationVersion == requestVersion) onSelectView(target)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
chromeHost.actions?.onJumpToDate?.invoke(target)
|
||||||
|
switchScope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onSettings = {
|
||||||
|
onOpenSettings()
|
||||||
|
switchScope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
CalendarTopBar(
|
||||||
|
host = chromeHost,
|
||||||
|
selectedView = view,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
onCycleView = { onSelectView(viewStack.last().next(quickSwitchViews)) },
|
||||||
|
onOpenDrawer = { switchScope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
// 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).
|
||||||
|
openImportForm(form, ImportSource.Insert)
|
||||||
|
},
|
||||||
|
chipShown = moveOutcome != null || selectionFailure != null,
|
||||||
|
onDismissChip = {
|
||||||
|
reschedule.consumeOutcome()
|
||||||
|
eventActions.consumeFailure()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
CalendarFab(chromeHost, todayInToolbar, fabSelection)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
SharedTransitionLayout(
|
||||||
|
Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y },
|
||||||
|
) {
|
||||||
|
// Keyed on the screen, not the view: the day, multi-day and week views
|
||||||
|
// are one timeline that resizes between them itself (#184), so switching
|
||||||
|
// among them must not replay this transition.
|
||||||
|
rememberTransition(viewTransition, label = "view-switch").AnimatedContent(
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
label = "view-switch",
|
contentKey = { it.screen },
|
||||||
) { currentView ->
|
) { currentView ->
|
||||||
when (currentView) {
|
val screen = currentView.screen
|
||||||
CalendarView.Week -> WeekScreen(
|
val sharedScope = this@SharedTransitionLayout
|
||||||
selectedView = currentView,
|
val visibilityScope = this@AnimatedContent
|
||||||
onSelectView = onSelectView,
|
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) {
|
||||||
|
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut)
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalViewMorph provides viewMorph,
|
||||||
|
LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen),
|
||||||
|
) {
|
||||||
|
when (screen) {
|
||||||
|
ViewScreen.Timeline -> TimelineScreen(
|
||||||
|
view = currentView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
onCreateDraft = onCreateDraft,
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
todayInToolbar = todayInToolbar,
|
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
ViewScreen.Month -> MonthScreen(
|
||||||
selectedView = currentView,
|
onOpenDay = onOpenDay,
|
||||||
onSelectView = onSelectView,
|
onOpenWeek = onOpenWeek,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
initialDateIso = pendingDayIso,
|
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
todayInToolbar = todayInToolbar,
|
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
ViewScreen.Agenda -> AgendaScreen(
|
||||||
selectedView = currentView,
|
|
||||||
onSelectView = onSelectView,
|
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
restorePosition = returnedFromDay,
|
||||||
todayInToolbar = todayInToolbar,
|
|
||||||
)
|
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
|
||||||
selectedView = currentView,
|
|
||||||
onSelectView = onSelectView,
|
|
||||||
onOpenDay = onOpenDay,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
todayInToolbar = todayInToolbar,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
||||||
// right after the calendar views so later overlays cover it.
|
// 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
|
// 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.
|
// detail screen draws on top, and closing it returns to the results.
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSearch,
|
visible = showSearch,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
onBack = { showSearch = false },
|
onBack = { showSearch = false },
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
visible = showSearch,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +713,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = detailKey != null,
|
visible = detailKey != null,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
activeKey?.let { key ->
|
activeKey?.let { key ->
|
||||||
EventDetailScreen(
|
EventDetailScreen(
|
||||||
@@ -441,10 +729,10 @@ fun CalendarHost(
|
|||||||
// Reuse the prefilled-create overlay: a duplicate is a
|
// Reuse the prefilled-create overlay: a duplicate is a
|
||||||
// fresh event, so it applies the default reminder like an
|
// fresh event, so it applies the default reminder like an
|
||||||
// in-app new event (#52).
|
// in-app new event (#52).
|
||||||
importFormSource = ImportSource.Insert
|
openImportForm(form, ImportSource.Insert)
|
||||||
importForm = form
|
|
||||||
detailKey = null
|
detailKey = null
|
||||||
},
|
},
|
||||||
|
visible = detailKey != null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,15 +741,18 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = createDateIso != null,
|
visible = createDateIso != null,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
(createDateIso ?: heldCreateIso)?.let { iso ->
|
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
initialDateIso = iso,
|
initialDateIso = iso,
|
||||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||||
|
initialDraft = createDraftMillis ?: heldCreateDraftMillis,
|
||||||
onClose = { createDateIso = null },
|
onClose = { createDateIso = null },
|
||||||
onSaved = { createDateIso = null },
|
onSaved = { createDateIso = null },
|
||||||
onManageCalendars = { showCalendars = true },
|
onManageCalendars = { showCalendars = true },
|
||||||
|
visible = createDateIso != null,
|
||||||
|
viewModel = hiltViewModel(key = "create"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,7 +761,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = editKey != null,
|
visible = editKey != null,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
(editKey ?: heldEditKey)?.let { key ->
|
(editKey ?: heldEditKey)?.let { key ->
|
||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
@@ -482,6 +773,20 @@ fun CalendarHost(
|
|||||||
detailKey = null
|
detailKey = null
|
||||||
},
|
},
|
||||||
onManageCalendars = { showCalendars = true },
|
onManageCalendars = { showCalendars = true },
|
||||||
|
onDuplicate = { form ->
|
||||||
|
openImportForm(form, ImportSource.Insert)
|
||||||
|
},
|
||||||
|
// Opened straight from a tap (#178) there's no detail screen
|
||||||
|
// below to show why, so open it.
|
||||||
|
onLoadFailed = {
|
||||||
|
editKey = null
|
||||||
|
if (detailKey == null) {
|
||||||
|
heldKey = key
|
||||||
|
detailKey = key
|
||||||
|
}
|
||||||
|
},
|
||||||
|
visible = editKey != null,
|
||||||
|
viewModel = hiltViewModel(key = "edit"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,12 +795,13 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSettings,
|
visible = showSettings,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = { showSettings = false },
|
onBack = { showSettings = false },
|
||||||
onManageCalendars = { showCalendars = true },
|
onManageCalendars = { showCalendars = true },
|
||||||
onOpenBackup = { showBackup = true },
|
onOpenBackup = { showBackup = true },
|
||||||
|
visible = showSettings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,25 +810,46 @@ fun CalendarHost(
|
|||||||
importUri?.let { uri ->
|
importUri?.let { uri ->
|
||||||
ImportScreen(
|
ImportScreen(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
|
session = importSession,
|
||||||
forceMany = importForceMany,
|
forceMany = importForceMany,
|
||||||
onClose = { importUri = null },
|
onClose = {
|
||||||
onManageCalendars = { showCalendars = true },
|
importUri = null
|
||||||
|
// Back to the surface the restore started from, ready for
|
||||||
|
// the next file.
|
||||||
|
showCalendars = calendarsAfterImport
|
||||||
|
showBackup = backupAfterImport
|
||||||
|
calendarsAfterImport = false
|
||||||
|
backupAfterImport = false
|
||||||
|
},
|
||||||
|
onManageCalendars = {
|
||||||
|
// The manager is where this leads, so it must not be
|
||||||
|
// reopened underneath on close.
|
||||||
|
calendarsAfterImport = false
|
||||||
|
backupAfterImport = false
|
||||||
|
showCalendars = true
|
||||||
|
},
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
importFormSource = ImportSource.File
|
calendarsAfterImport = false
|
||||||
importForm = form
|
backupAfterImport = false
|
||||||
|
openImportForm(form, ImportSource.File)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
importForm?.let { form ->
|
importForm?.let { form ->
|
||||||
EventEditScreen(
|
key(importFormId) {
|
||||||
initialDateIso = null,
|
EventEditScreen(
|
||||||
initialForm = form,
|
initialDateIso = null,
|
||||||
initialFormSource = importFormSource,
|
initialForm = form,
|
||||||
onClose = { importForm = null },
|
initialFormSource = importFormSource,
|
||||||
onSaved = { importForm = null },
|
onClose = ::closeImportForm,
|
||||||
onManageCalendars = { showCalendars = true },
|
onSaved = ::closeImportForm,
|
||||||
)
|
onManageCalendars = { showCalendars = true },
|
||||||
|
leaveRequested = pendingInsert != null,
|
||||||
|
onLeaveDeclined = { pendingInsert = null },
|
||||||
|
viewModel = importViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Declared last so it covers every overlay that can open it: Settings,
|
// Declared last so it covers every overlay that can open it: Settings,
|
||||||
@@ -530,11 +857,12 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showCalendars,
|
visible = showCalendars,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
CalendarsScreen(
|
CalendarsScreen(
|
||||||
onBack = { showCalendars = false },
|
onBack = { showCalendars = false },
|
||||||
onOpenBackup = { showBackup = true },
|
onOpenBackup = { showBackup = true },
|
||||||
|
visible = showCalendars,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,20 +870,36 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showBackup,
|
visible = showBackup,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
BackupScreen(
|
// Settings can open this screen without the manager underneath, so
|
||||||
onBack = { showBackup = false },
|
// its failure states' "Manage calendars" way out has to open the
|
||||||
// Restore runs the normal .ics import, and both this screen and
|
// manager rather than just pop — popping alone lands on Settings
|
||||||
// the manager that can have opened it are declared above the
|
// (#304). Coming from the manager the flag is already set, so this
|
||||||
// import overlays — so both have to step aside.
|
// is the same pop-back there.
|
||||||
onImport = {
|
CompositionLocalProvider(
|
||||||
importUri = it
|
LocalManageCalendars provides {
|
||||||
importForceMany = true
|
showCalendars = true
|
||||||
showBackup = false
|
showBackup = false
|
||||||
showCalendars = false
|
|
||||||
},
|
},
|
||||||
)
|
) {
|
||||||
|
BackupScreen(
|
||||||
|
onBack = { showBackup = false },
|
||||||
|
// Restore runs the normal .ics import, and both this screen
|
||||||
|
// and the manager that can have opened it are declared above
|
||||||
|
// the import overlays — so both have to step aside.
|
||||||
|
onImport = {
|
||||||
|
importUri = it
|
||||||
|
importForceMany = true
|
||||||
|
importSession++
|
||||||
|
backupAfterImport = true
|
||||||
|
calendarsAfterImport = showCalendars
|
||||||
|
showBackup = false
|
||||||
|
showCalendars = false
|
||||||
|
},
|
||||||
|
visible = showBackup,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,3 +909,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
|||||||
save = { stack -> stack.map(CalendarView::ordinal) },
|
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||||
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun localToday(): LocalDate =
|
||||||
|
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,36 +24,39 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CalendarHostViewModel @Inject constructor(
|
class CalendarHostViewModel @Inject constructor(
|
||||||
prefs: SettingsPrefs,
|
prefs: SettingsPrefs,
|
||||||
|
todaySource: TodaySource,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
|
/** Today, kept current across midnight so the shared focus can follow it (#373). */
|
||||||
|
val today: StateFlow<LocalDate> = todaySource.today.stateWhileSubscribed(
|
||||||
|
scope = viewModelScope,
|
||||||
|
initialValue = currentDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = null,
|
initialValue = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
|
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
|
||||||
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
|
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
|
||||||
.map { it.cycle }
|
.map { it.cycle }
|
||||||
.stateIn(
|
.stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = IMPLEMENTED_VIEWS,
|
initialValue = IMPLEMENTED_VIEWS,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Order of the views in the navigation drawer (#24); every view always shown. */
|
/** Order of the views in the navigation drawer (#24); every view always shown. */
|
||||||
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
|
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
|
||||||
.stateIn(
|
.stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = IMPLEMENTED_VIEWS,
|
initialValue = IMPLEMENTED_VIEWS,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
|
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
|
||||||
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
|
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
|
||||||
.stateIn(
|
.stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,9 +68,13 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
* switched off to be rid of.
|
* switched off to be rid of.
|
||||||
*/
|
*/
|
||||||
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
|
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
|
||||||
.stateIn(
|
.stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val tapOpensEditorPref = prefs.tapOpensEditor
|
||||||
|
|
||||||
|
/** Whether tapping an event opens the edit form rather than the detail screen (#178). */
|
||||||
|
suspend fun tapOpensEditor(): Boolean = tapOpensEditorPref.first()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -20,7 +19,6 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
@@ -31,6 +29,7 @@ import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
|
|||||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
|
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
|
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
|
||||||
|
import de.jeanlucmakiola.floret.android.hasPermission
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
|
||||||
/** What the root is showing: a wizard step, the app, or neither yet. */
|
/** What the root is showing: a wizard step, the app, or neither yet. */
|
||||||
@@ -54,14 +53,14 @@ fun RootScreen(
|
|||||||
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
|
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
|
||||||
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
|
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
|
onExternalFormClosed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
requestedEditKey: LongArray? = null,
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
|
context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||||
== PackageManager.PERMISSION_GRANTED
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +72,7 @@ fun RootScreen(
|
|||||||
DisposableEffect(lifecycle) {
|
DisposableEffect(lifecycle) {
|
||||||
val obs = LifecycleEventObserver { _, event ->
|
val obs = LifecycleEventObserver { _, event ->
|
||||||
if (event == Lifecycle.Event.ON_RESUME) {
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
hasPermission = ContextCompat.checkSelfPermission(
|
hasPermission = context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||||
context, Manifest.permission.READ_CALENDAR
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
// Refresh the contact special-dates mirror on foreground (the
|
// Refresh the contact special-dates mirror on foreground (the
|
||||||
// worker is debounced and no-ops when the feature is off). Gated
|
// worker is debounced and no-ops when the feature is off). Gated
|
||||||
// on the permission so users without the opt-in never enqueue it.
|
// on the permission so users without the opt-in never enqueue it.
|
||||||
@@ -155,6 +152,7 @@ fun RootScreen(
|
|||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsertForm,
|
||||||
requestedInsertSource = requestedInsertSource,
|
requestedInsertSource = requestedInsertSource,
|
||||||
onInsertConsumed = onInsertConsumed,
|
onInsertConsumed = onInsertConsumed,
|
||||||
|
onExternalFormClosed = onExternalFormClosed,
|
||||||
requestedEditKey = requestedEditKey,
|
requestedEditKey = requestedEditKey,
|
||||||
onEditKeyConsumed = onEditKeyConsumed,
|
onEditKeyConsumed = onEditKeyConsumed,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -35,10 +34,10 @@ import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
@@ -127,7 +126,7 @@ internal fun AgendaEventRow(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = LocalFloretDarkTheme.current
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -13,55 +25,51 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
import androidx.compose.material.icons.filled.Menu
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.rememberDrawerState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.AgendaRangePicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
@@ -77,26 +85,34 @@ import kotlin.time.Instant
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AgendaScreen(
|
fun AgendaScreen(
|
||||||
selectedView: CalendarView,
|
|
||||||
onSelectView: (CalendarView) -> Unit,
|
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
|
||||||
onOpenSearch: () -> Unit,
|
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
|
||||||
todayInToolbar: Boolean = false,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
restorePosition: Boolean = false,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
// 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
|
||||||
|
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 state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showRangePicker by remember { mutableStateOf(false) }
|
var showRangePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -106,103 +122,107 @@ fun AgendaScreen(
|
|||||||
}
|
}
|
||||||
val successState = state as? AgendaUiState.Success
|
val successState = state as? AgendaUiState.Success
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
// The agenda window always starts at the anchor, so moving through it is a
|
||||||
drawerState = drawerState,
|
// list scroll, not an anchor change — "today" has to bring the list back to
|
||||||
drawerContent = {
|
// the top as well, and stay offered while it is scrolled away (#305).
|
||||||
CalendarDrawer(
|
val listState = rememberLazyListState()
|
||||||
currentView = selectedView,
|
// Past days load above the anchor (#367), so the anchor's row counts as the
|
||||||
currentDate = anchor,
|
// start of the list.
|
||||||
drawerState = drawerState,
|
val anchorIndex = remember { mutableIntStateOf(0) }
|
||||||
viewOrder = drawerViewOrder,
|
val scrolledAway by remember {
|
||||||
onSelectView = { view ->
|
derivedStateOf {
|
||||||
onSelectView(view)
|
listState.firstVisibleItemIndex != anchorIndex.intValue ||
|
||||||
scope.launch { drawerState.close() }
|
listState.firstVisibleItemScrollOffset > 0
|
||||||
},
|
}
|
||||||
onJumpToDate = { target ->
|
}
|
||||||
viewModel.goToDate(target)
|
val jumpToToday: () -> Unit = {
|
||||||
scope.launch { drawerState.close() }
|
// 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.
|
||||||
onSettings = {
|
if (isOnToday) scope.launch { listState.animateScrollToItem(anchorIndex.intValue) }
|
||||||
onOpenSettings()
|
viewModel.goToToday()
|
||||||
scope.launch { drawerState.close() }
|
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
|
||||||
},
|
}
|
||||||
)
|
|
||||||
},
|
PublishChrome(
|
||||||
) {
|
ScreenChrome(
|
||||||
Scaffold(
|
title = stringResource(R.string.view_agenda),
|
||||||
modifier = modifier,
|
titleDate = null,
|
||||||
topBar = {
|
drawerDate = anchor,
|
||||||
AgendaTopBar(
|
awayFromToday = !isOnToday || (scrolledAway && successState != null),
|
||||||
selectedView = selectedView,
|
todayText = stringResource(R.string.agenda_today_action),
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
),
|
||||||
quickSwitchViews = quickSwitchViews,
|
ScreenActions(
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onToday = jumpToToday,
|
||||||
onOpenSearch = onOpenSearch,
|
onJumpToDate = { target ->
|
||||||
showTodayButton = todayInToolbar,
|
focus?.date = target
|
||||||
onToday = viewModel::goToToday,
|
viewModel.goToDate(target)
|
||||||
)
|
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
onCreate = { onCreateEvent(anchor, null) },
|
||||||
CalendarFabColumn(
|
),
|
||||||
todayVisible = !isOnToday && !todayInToolbar,
|
)
|
||||||
todayText = stringResource(R.string.agenda_today_action),
|
|
||||||
onToday = viewModel::goToToday,
|
ScreenBody(modifier = modifier) { innerPadding ->
|
||||||
onCreate = { onCreateEvent(anchor, null) },
|
Column(
|
||||||
)
|
modifier = Modifier
|
||||||
},
|
.fillMaxSize()
|
||||||
) { innerPadding ->
|
.padding(innerPadding),
|
||||||
Column(
|
) {
|
||||||
modifier = Modifier
|
// One bar at the top: the "showing …" header on the left and the
|
||||||
.fillMaxSize()
|
// session range switcher on the right (one settings toggle).
|
||||||
.padding(innerPadding),
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
) {
|
// end lines the selector up with whatever ends the top bar:
|
||||||
// One bar at the top: the "showing …" header on the left and the
|
// the view switcher's background, or — once #150 hides it —
|
||||||
// session range switcher on the right (one settings toggle).
|
// the search icon's glyph.
|
||||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
||||||
// end lines the selector up with whatever ends the top bar:
|
AppBarSpacing.Inset
|
||||||
// the view switcher's background, or — once #150 hides it —
|
} else {
|
||||||
// the search icon's glyph.
|
AppBarSpacing.IconTrailingInset
|
||||||
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
|
||||||
AppBarSpacing.Inset
|
|
||||||
} else {
|
|
||||||
AppBarSpacing.IconTrailingInset
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(
|
|
||||||
start = RANGE_BAR_TEXT_INSET,
|
|
||||||
end = selectorEnd,
|
|
||||||
top = 8.dp,
|
|
||||||
bottom = 8.dp,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
AgendaRangeBanner(
|
|
||||||
range = s.range,
|
|
||||||
start = s.anchor,
|
|
||||||
end = s.rangeEnd,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
AgendaRangePill(
|
|
||||||
range = s.range,
|
|
||||||
isOverride = s.rangeIsOverride,
|
|
||||||
onClick = { showRangePicker = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
AgendaContent(
|
Row(
|
||||||
state = state,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
pastDisplay = pastDisplay,
|
|
||||||
showToday = showToday,
|
|
||||||
onRetry = viewModel::goToToday,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onOpenDay = onOpenDay,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.fillMaxWidth(),
|
.padding(
|
||||||
)
|
start = RANGE_BAR_TEXT_INSET,
|
||||||
|
end = selectorEnd,
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
AgendaRangeBanner(
|
||||||
|
range = s.range,
|
||||||
|
start = s.anchor,
|
||||||
|
end = s.rangeEnd,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
AgendaRangePill(
|
||||||
|
range = s.range,
|
||||||
|
isOverride = s.rangeIsOverride,
|
||||||
|
onClick = { showRangePicker = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
AgendaContent(
|
||||||
|
state = state,
|
||||||
|
pastDisplay = pastDisplay,
|
||||||
|
showToday = showToday,
|
||||||
|
listState = listState,
|
||||||
|
anchorIndex = anchorIndex,
|
||||||
|
initialTopDate = restoreTop,
|
||||||
|
onLoadEarlier = viewModel::loadEarlier,
|
||||||
|
onRetry = viewModel::goToToday,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onTopDay = if (successState?.anchor == anchor) {
|
||||||
|
{
|
||||||
|
focus?.date = it
|
||||||
|
viewModel.rememberTopDay(it)
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +318,11 @@ internal fun AgendaContent(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
anchorIndex: MutableIntState = remember { mutableIntStateOf(0) },
|
||||||
|
initialTopDate: LocalDate? = null,
|
||||||
|
onLoadEarlier: () -> Unit = {},
|
||||||
|
onTopDay: ((LocalDate) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
AgendaUiState.Loading -> Box(modifier)
|
AgendaUiState.Loading -> Box(modifier)
|
||||||
@@ -311,31 +336,42 @@ internal fun AgendaContent(
|
|||||||
// away (or fade) as they end while the screen stays open.
|
// away (or fade) as they end while the screen stays open.
|
||||||
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
|
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
state.days.mapNotNull { day ->
|
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)
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
state.days
|
state.days
|
||||||
}
|
}
|
||||||
// Anchor today with a "nothing left today" placeholder — but only when
|
// 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
|
// the agenda actually opens on today; a jumped-to date has no today
|
||||||
// it, so anchoring there would be misleading (#35).
|
// in it, so anchoring there would be misleading (#35).
|
||||||
val days = anchorTodayIfMissing(
|
val days = anchorTodayIfMissing(
|
||||||
days = filtered,
|
days = filtered,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
enabled = showToday && state.anchor == 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)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
AgendaList(
|
AgendaList(
|
||||||
days = days,
|
days = days,
|
||||||
|
anchor = state.anchor,
|
||||||
|
anchorIndex = anchorIndex,
|
||||||
|
initialTopDate = initialTopDate,
|
||||||
|
onLoadEarlier = onLoadEarlier,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
zone = state.zone,
|
zone = state.zone,
|
||||||
|
listState = listState,
|
||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onTopDay = onTopDay,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -347,16 +383,64 @@ internal fun AgendaContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AgendaList(
|
private fun AgendaList(
|
||||||
days: List<AgendaDay>,
|
days: List<AgendaDay>,
|
||||||
|
anchor: LocalDate,
|
||||||
|
anchorIndex: MutableIntState,
|
||||||
|
initialTopDate: LocalDate?,
|
||||||
|
onLoadEarlier: () -> Unit,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
|
listState: LazyListState,
|
||||||
dimPast: Boolean,
|
dimPast: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onTopDay: ((LocalDate) -> Unit)?,
|
||||||
modifier: Modifier = Modifier,
|
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 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 -> currentItemDates.getOrNull(index)?.let(currentOnTopDay) }
|
||||||
|
}
|
||||||
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier,
|
modifier = modifier.nestedScroll(loadOnScrollUp),
|
||||||
|
state = listState,
|
||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
@@ -385,8 +469,8 @@ private fun AgendaList(
|
|||||||
day = day.date,
|
day = day.date,
|
||||||
zone = zone,
|
zone = zone,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
dimmed = dimPast && event.isPastOn(day.date, now, zone),
|
||||||
modifier = animateItemMotion(),
|
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -396,6 +480,16 @@ 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 ->
|
||||||
|
// Header, the placeholder or the events, then the gap.
|
||||||
|
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||||
Column(
|
Column(
|
||||||
@@ -417,58 +511,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun AgendaTopBar(
|
|
||||||
selectedView: CalendarView,
|
|
||||||
onCycleView: () -> Unit,
|
|
||||||
quickSwitchViews: List<CalendarView>,
|
|
||||||
onOpenDrawer: () -> Unit,
|
|
||||||
onOpenSearch: () -> Unit,
|
|
||||||
showTodayButton: Boolean,
|
|
||||||
onToday: () -> Unit,
|
|
||||||
) {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
// A plain label rather than a CalendarTitleButton, so it takes that
|
|
||||||
// one's inset and one-line clamp itself.
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.view_agenda),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onOpenDrawer) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Menu,
|
|
||||||
contentDescription = stringResource(R.string.month_open_menu),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
TodayAction(show = showTodayButton, onToday = onToday)
|
|
||||||
IconButton(onClick = onOpenSearch) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Search,
|
|
||||||
contentDescription = stringResource(R.string.search_action),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ViewSwitcherPill(
|
|
||||||
current = selectedView,
|
|
||||||
cycle = quickSwitchViews,
|
|
||||||
onCycle = onCycleView,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
|
||||||
// it, but here the bar meets the header on the same surface and the
|
|
||||||
// tint is what makes that seam look like a separate block (#186).
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||||
@@ -86,32 +87,27 @@ fun groupAgendaDays(
|
|||||||
return byDay.map { (date, dayEvents) ->
|
return byDay.map { (date, dayEvents) ->
|
||||||
AgendaDay(
|
AgendaDay(
|
||||||
date = date,
|
date = date,
|
||||||
events = dayEvents.sortedWith(
|
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
|
||||||
compareByDescending<EventInstance> { it.isAllDay }
|
|
||||||
.thenBy { it.start }
|
|
||||||
.thenBy { it.title },
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure [today] surfaces as the first agenda day even when it carries no
|
* Ensure [today] surfaces in the agenda even when it carries no (remaining)
|
||||||
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
|
* 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
|
* 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
|
* when today already has its own day in [days]. Keeps today anchored so a glance
|
||||||
* so a glance tells today's events apart from a future day's (issue #35).
|
* tells today's events apart from a future day's (issue #35).
|
||||||
*/
|
*/
|
||||||
fun anchorTodayIfMissing(
|
fun anchorTodayIfMissing(
|
||||||
days: List<AgendaDay>,
|
days: List<AgendaDay>,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
): List<AgendaDay> =
|
): List<AgendaDay> {
|
||||||
if (enabled && days.none { it.date == today }) {
|
if (!enabled || days.any { it.date == today }) return days
|
||||||
listOf(AgendaDay(today, emptyList())) + days
|
val at = days.indexOfFirst { it.date > today }.let { if (it < 0) days.size else it }
|
||||||
} else {
|
return days.take(at) + AgendaDay(today, emptyList()) + days.drop(at)
|
||||||
days
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||||
@@ -121,8 +117,10 @@ sealed interface AgendaUiState {
|
|||||||
data object Loading : AgendaUiState
|
data object Loading : AgendaUiState
|
||||||
data class Failure(val reason: FailureReason) : AgendaUiState
|
data class Failure(val reason: FailureReason) : AgendaUiState
|
||||||
data class Success(
|
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,
|
val anchor: LocalDate,
|
||||||
|
/** First loaded day, before [anchor] once past days are loaded (#367). */
|
||||||
|
val windowStart: LocalDate,
|
||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val days: List<AgendaDay>,
|
val days: List<AgendaDay>,
|
||||||
/** The range currently in effect — the saved default or a session override. */
|
/** The range currently in effect — the saved default or a session override. */
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
|
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||||
|
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -20,6 +24,8 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -27,10 +33,9 @@ import kotlinx.datetime.LocalDate
|
|||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -39,6 +44,7 @@ import javax.inject.Inject
|
|||||||
class AgendaViewModel @Inject constructor(
|
class AgendaViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
settingsPrefs: SettingsPrefs,
|
settingsPrefs: SettingsPrefs,
|
||||||
|
todaySource: TodaySource,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -61,9 +67,8 @@ class AgendaViewModel @Inject constructor(
|
|||||||
* the screen combines it with a per-minute "now" to fade or drop past rows.
|
* the screen combines it with a per-minute "now" to fade or drop past rows.
|
||||||
*/
|
*/
|
||||||
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
|
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
|
||||||
.stateIn(
|
.stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = PastEventDisplay.SHOW,
|
initialValue = PastEventDisplay.SHOW,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,19 +79,42 @@ class AgendaViewModel @Inject constructor(
|
|||||||
* alongside the data state like [pastEventDisplay] rather than re-querying.
|
* alongside the data state like [pastEventDisplay] rather than re-querying.
|
||||||
*/
|
*/
|
||||||
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||||
.stateIn(
|
.stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = true,
|
initialValue = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = currentDate()
|
||||||
|
|
||||||
private val _anchor = MutableStateFlow(todayDate)
|
// The anchor and how many days before it are loaded, moved together so a
|
||||||
val anchor: StateFlow<LocalDate> = _anchor
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stateWhileSubscribed(viewModelScope, todayDate)
|
||||||
|
|
||||||
// A transient, in-view override of the saved agenda range. Held in memory
|
// A transient, in-view override of the saved agenda range. Held in memory
|
||||||
// (not persisted), so it survives view switches and rotation within the
|
// (not persisted), so it survives view switches and rotation within the
|
||||||
@@ -94,9 +122,17 @@ class AgendaViewModel @Inject constructor(
|
|||||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||||
|
|
||||||
val state: StateFlow<AgendaUiState> =
|
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(
|
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,
|
range = override ?: settings.range,
|
||||||
rangeIsOverride = override != null && override != settings.range,
|
rangeIsOverride = override != null && override != settings.range,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
@@ -105,32 +141,50 @@ class AgendaViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
.flatMapLatest { params ->
|
.flatMapLatest { params ->
|
||||||
val window = agendaRange(
|
val window = agendaRange(
|
||||||
params.anchor,
|
params.windowStart,
|
||||||
params.range.dayCount(params.anchor, params.weekStart) - 1,
|
params.pastDays + params.range.dayCount(params.anchor, params.weekStart) - 1,
|
||||||
zone,
|
zone,
|
||||||
)
|
)
|
||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
repository.instances(window),
|
repository.instances(window),
|
||||||
) { calendars, instances ->
|
today,
|
||||||
buildState(params, calendars, instances)
|
) { calendars, instances, today ->
|
||||||
|
buildState(params, calendars, instances, today)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
.stateIn(
|
.stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = AgendaUiState.Loading,
|
initialValue = AgendaUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun goToToday() {
|
fun goToToday() {
|
||||||
_anchor.value = todayDate
|
goToDate(todayDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */
|
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */
|
||||||
fun goToDate(date: LocalDate) {
|
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). */
|
/** Temporarily override the agenda range for this session (the bottom-left pill). */
|
||||||
@@ -143,31 +197,38 @@ class AgendaViewModel @Inject constructor(
|
|||||||
val showBar: Boolean,
|
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(
|
private data class AgendaParams(
|
||||||
val anchor: LocalDate,
|
val anchor: LocalDate,
|
||||||
|
val pastDays: Int,
|
||||||
val range: AgendaRange,
|
val range: AgendaRange,
|
||||||
val rangeIsOverride: Boolean,
|
val rangeIsOverride: Boolean,
|
||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
val showRangeBar: Boolean,
|
val showRangeBar: Boolean,
|
||||||
)
|
) {
|
||||||
|
val windowStart: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
params: AgendaParams,
|
params: AgendaParams,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
|
today: LocalDate,
|
||||||
): AgendaUiState {
|
): AgendaUiState {
|
||||||
if (calendars.isEmpty()) {
|
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
|
||||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
|
||||||
}
|
|
||||||
val anchor = params.anchor
|
val anchor = params.anchor
|
||||||
val rangeEnd = anchor.plus(
|
val rangeEnd = anchor.plus(
|
||||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||||
DateTimeUnit.DAY,
|
DateTimeUnit.DAY,
|
||||||
)
|
)
|
||||||
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
|
val days = groupAgendaDays(params.windowStart, rangeEnd, instances, zone)
|
||||||
return AgendaUiState.Success(
|
return AgendaUiState.Success(
|
||||||
anchor = anchor,
|
anchor = anchor,
|
||||||
today = todayDate,
|
windowStart = params.windowStart,
|
||||||
|
today = today,
|
||||||
days = days,
|
days = days,
|
||||||
range = params.range,
|
range = params.range,
|
||||||
rangeIsOverride = params.rangeIsOverride,
|
rangeIsOverride = params.rangeIsOverride,
|
||||||
@@ -178,6 +239,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. */
|
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
||||||
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
|
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
|
||||||
val from = anchor.atStartOfDayIn(zone)
|
val from = anchor.atStartOfDayIn(zone)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
|
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
|
||||||
|
import de.jeanlucmakiola.floret.components.ScaledViewPreview
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
@@ -51,6 +51,7 @@ private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.S
|
|||||||
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
|
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
|
||||||
return AgendaUiState.Success(
|
return AgendaUiState.Success(
|
||||||
anchor = today,
|
anchor = today,
|
||||||
|
windowStart = today,
|
||||||
today = today,
|
today = today,
|
||||||
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
|
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
|
||||||
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
|
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.calendars
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.text.format.DateUtils
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -18,6 +19,7 @@ import androidx.compose.material.icons.filled.Schedule
|
|||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
@@ -47,16 +49,20 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.locale.relativeTimeAgo
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
|
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
|
||||||
@@ -78,20 +84,16 @@ private val RESTORE_MIME_TYPES = arrayOf(
|
|||||||
fun BackupScreen(
|
fun BackupScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onImport: (Uri) -> Unit,
|
onImport: (Uri) -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
// Export covers local calendars only; managed special-dates mirrors are
|
|
||||||
// rebuilt from contacts. Restore can target anything the import picker offers.
|
|
||||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
|
||||||
val canImport = calendars.any { it.isEventTarget }
|
|
||||||
|
|
||||||
// Exports everything eligible (null); the per-calendar selector owns its
|
// Exports everything eligible (null); the per-calendar selector owns its
|
||||||
// own launcher.
|
// own launcher.
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
@@ -132,78 +134,43 @@ fun BackupScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_backup),
|
title = stringResource(R.string.settings_section_backup),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
// Loading and failure fill the screen and centre themselves, which they
|
||||||
|
// can only do in an unscrolled column — the scrolling one measures them
|
||||||
|
// against an unbounded height and leaves them hanging under the header.
|
||||||
|
scrollable = state is BackupUiState.Ready,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
when (val s = state) {
|
||||||
|
BackupUiState.Loading -> BackupLoading()
|
||||||
if (exportable.isNotEmpty()) {
|
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
|
||||||
GroupedRow(
|
is BackupUiState.Ready -> BackupContent(
|
||||||
title = stringResource(R.string.calendars_backup_action),
|
exportable = s.exportable,
|
||||||
position = Position.Top,
|
canImport = s.canImport,
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
autoBackup = autoBackup,
|
||||||
onClick = {
|
viewModel = viewModel,
|
||||||
// A single exportable calendar skips the selector.
|
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
if (exportable.size == 1) {
|
onPickFolder = { runCatching { pickFolder.launch(null) } },
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
onExportAll = {
|
||||||
} else {
|
runCatching { createBackup.launch(defaultBackupName()) }
|
||||||
showExportPicker = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
onExportPick = { showExportPicker = true },
|
||||||
GroupedRow(
|
onEditInterval = { showInterval = true },
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
summary = stringResource(R.string.calendars_restore_hint),
|
|
||||||
position = Position.Middle,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup),
|
|
||||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
|
||||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
|
||||||
trailing = {
|
|
||||||
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
|
||||||
)
|
|
||||||
if (autoBackup.enabled) {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
|
||||||
summary = rememberFolderName(autoBackup.folderUri)
|
|
||||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { runCatching { pickFolder.launch(null) } },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
|
||||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { showInterval = true },
|
|
||||||
)
|
|
||||||
HintText(backupStatusText(autoBackup.status))
|
|
||||||
}
|
|
||||||
} else if (canImport) {
|
|
||||||
// Nothing to back up, but restore is still possible — don't hide
|
|
||||||
// it behind export eligibility.
|
|
||||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
|
||||||
HintText(stringResource(R.string.calendars_restore_hint))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
position = Position.Alone,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showExportPicker) {
|
// Gated on Ready rather than resetting the flag when the state leaves it:
|
||||||
ExportCalendarPicker(
|
// flipping it here would be a write during composition.
|
||||||
calendars = exportable,
|
(state as? BackupUiState.Ready)?.let { ready ->
|
||||||
onExport = viewModel::exportBackup,
|
if (showExportPicker) {
|
||||||
onDismiss = { showExportPicker = false },
|
ExportCalendarPicker(
|
||||||
)
|
calendars = ready.exportable,
|
||||||
|
onExport = viewModel::exportBackup,
|
||||||
|
onDismiss = { showExportPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (showInterval) {
|
if (showInterval) {
|
||||||
BackupIntervalDialog(
|
BackupIntervalDialog(
|
||||||
@@ -214,6 +181,103 @@ fun BackupScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The screen's loading state, centred in the scaffold's content column. */
|
||||||
|
@Composable
|
||||||
|
private fun BackupLoading() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default file name for a one-shot export. */
|
||||||
|
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The working screen: export (when there are local calendars to export), restore,
|
||||||
|
* and the automatic-backup block. Restore is always on screen — when nothing can
|
||||||
|
* receive the events it says so and routes to the calendar manager rather than
|
||||||
|
* disappearing, which is what #304 reported as an invisible button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BackupContent(
|
||||||
|
exportable: List<CalendarSource>,
|
||||||
|
canImport: Boolean,
|
||||||
|
autoBackup: AutoBackupUiState,
|
||||||
|
viewModel: CalendarsViewModel,
|
||||||
|
onRestore: () -> Unit,
|
||||||
|
onPickFolder: () -> Unit,
|
||||||
|
onExportAll: () -> Unit,
|
||||||
|
onExportPick: () -> Unit,
|
||||||
|
onEditInterval: () -> Unit,
|
||||||
|
) {
|
||||||
|
val manageCalendars = LocalManageCalendars.current
|
||||||
|
// Restore never depends on export eligibility, and never disappears: when no
|
||||||
|
// calendar can receive events it explains that and offers the way to fix it.
|
||||||
|
val restoreSummary = if (canImport) {
|
||||||
|
stringResource(R.string.calendars_restore_hint)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_restore_unavailable)
|
||||||
|
}
|
||||||
|
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
|
||||||
|
|
||||||
|
if (exportable.isEmpty()) {
|
||||||
|
GroupedSectionHeader(stringResource(R.string.calendars_restore_header))
|
||||||
|
GroupedHint(restoreSummary, style = MaterialTheme.typography.bodySmall)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = onRestoreClick,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupedHint(stringResource(R.string.calendars_backup_hint), style = MaterialTheme.typography.bodySmall)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
|
position = Position.Top,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
|
// A single exportable calendar skips the selector.
|
||||||
|
onClick = if (exportable.size == 1) onExportAll else onExportPick,
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
summary = restoreSummary,
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = onRestoreClick,
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup),
|
||||||
|
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||||
|
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||||
|
)
|
||||||
|
if (autoBackup.enabled) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||||
|
summary = rememberFolderName(autoBackup.folderUri)
|
||||||
|
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = onPickFolder,
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||||
|
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = onEditInterval,
|
||||||
|
)
|
||||||
|
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||||
@@ -247,12 +311,13 @@ private fun ExportCalendarPicker(
|
|||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.calendars_export_title),
|
title = stringResource(R.string.calendars_export_title),
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
HintText(stringResource(R.string.calendars_export_hint))
|
GroupedHint(stringResource(R.string.calendars_export_hint), style = MaterialTheme.typography.bodySmall)
|
||||||
calendars.forEachIndexed { index, calendar ->
|
calendars.forEachIndexed { index, calendar ->
|
||||||
val isSelected = calendar.id in selected
|
val isSelected = calendar.id in selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
summary = calendar.description,
|
summary = calendar.description,
|
||||||
position = positionOf(index, calendars.size),
|
position = positionOf(index, calendars.size),
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
@@ -314,9 +379,7 @@ private fun backupIntervalLabel(minutes: Long): String {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun backupStatusText(status: BackupStatus): String {
|
private fun backupStatusText(status: BackupStatus): String {
|
||||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||||
val relative = DateUtils.getRelativeTimeSpanString(
|
val relative = relativeTimeAgo(status.lastRun)
|
||||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
|
||||||
).toString()
|
|
||||||
return if (status.lastSuccess) {
|
return if (status.lastSuccess) {
|
||||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State of the Backup & restore screen (#69). Three states, because the calendar
|
||||||
|
* list it is derived from has all three: it arrives empty, it can throw, and
|
||||||
|
* "loaded but nothing to offer" is a real outcome the screen used to render as a
|
||||||
|
* blank page (#304).
|
||||||
|
*/
|
||||||
|
sealed interface BackupUiState {
|
||||||
|
data object Loading : BackupUiState
|
||||||
|
data class Failure(val reason: FailureReason) : BackupUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At least one half of the screen works. [exportable] may be empty (restore
|
||||||
|
* only) and [canImport] may be false (export only), but never both — that is
|
||||||
|
* a [Failure].
|
||||||
|
*/
|
||||||
|
data class Ready(
|
||||||
|
val exportable: List<CalendarSource>,
|
||||||
|
val canImport: Boolean,
|
||||||
|
) : BackupUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the screen can offer for this calendar list.
|
||||||
|
*
|
||||||
|
* Export covers the app's own local calendars; managed special-dates mirrors are
|
||||||
|
* rebuilt from contacts, so they are excluded. Restore can target anything the
|
||||||
|
* import picker offers ([isEventTarget]).
|
||||||
|
*
|
||||||
|
* A failure is raised only when *neither* is possible — deliberately not
|
||||||
|
* [calendarListFailure] on its own, which would call an all-hidden device a
|
||||||
|
* failure while its local calendars are still perfectly exportable. When both
|
||||||
|
* halves are dead the list itself usually says why (no calendars, everything
|
||||||
|
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
|
||||||
|
* calendars exist and are visible but every one of them is read-only, managed or
|
||||||
|
* not synced to the device.
|
||||||
|
*/
|
||||||
|
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
|
||||||
|
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||||
|
val canImport = calendars.any { it.isEventTarget }
|
||||||
|
if (exportable.isEmpty() && !canImport) {
|
||||||
|
return BackupUiState.Failure(
|
||||||
|
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
|
||||||
|
}
|
||||||
@@ -6,34 +6,27 @@ import android.content.Intent
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Backup
|
import androidx.compose.material.icons.filled.Backup
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Palette
|
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
@@ -41,20 +34,15 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -90,18 +78,35 @@ import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||||
|
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
|
|
||||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
@@ -118,6 +123,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
|||||||
fun CalendarsScreen(
|
fun CalendarsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenBackup: () -> Unit,
|
onOpenBackup: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
@@ -162,6 +168,7 @@ fun CalendarsScreen(
|
|||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
onOpenBackup = onOpenBackup,
|
onOpenBackup = onOpenBackup,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
visible = visible,
|
||||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
onSetVisible = viewModel::setCalendarVisible,
|
onSetVisible = viewModel::setCalendarVisible,
|
||||||
@@ -178,6 +185,7 @@ private fun CalendarsList(
|
|||||||
onConsumeError: () -> Unit,
|
onConsumeError: () -> Unit,
|
||||||
onOpenBackup: () -> Unit,
|
onOpenBackup: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
visible: Boolean,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
onSetVisible: (Long, Boolean) -> Unit,
|
onSetVisible: (Long, Boolean) -> Unit,
|
||||||
@@ -203,9 +211,10 @@ private fun CalendarsList(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
// What the per-calendar / per-account switches below actually do.
|
// What the per-calendar / per-account switches below actually do.
|
||||||
HintText(stringResource(R.string.calendars_visibility_hint))
|
GroupedHint(stringResource(R.string.calendars_visibility_hint), style = MaterialTheme.typography.bodySmall)
|
||||||
|
|
||||||
// Local (device-only) calendars — one collapsible group. The header's
|
// Local (device-only) calendars — one collapsible group. The header's
|
||||||
// "+" adds a calendar; the switch enables/disables them all at once;
|
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||||
@@ -226,12 +235,12 @@ private fun CalendarsList(
|
|||||||
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
|
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
|
||||||
) {
|
) {
|
||||||
if (local.isEmpty()) {
|
if (local.isEmpty()) {
|
||||||
HintText(stringResource(R.string.calendars_local_empty))
|
GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall)
|
||||||
} else {
|
} else {
|
||||||
local.forEachIndexed { index, calendar ->
|
local.forEachIndexed { index, calendar ->
|
||||||
val disabled = !calendar.isVisibleInSystem
|
val disabled = !calendar.isVisibleInSystem
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
summary = calendarRowSummary(calendar),
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
@@ -239,7 +248,7 @@ private fun CalendarsList(
|
|||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = {
|
||||||
EnableSwitch(
|
EnableSwitch(
|
||||||
calendarName = calendar.displayName,
|
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
enabled = !disabled,
|
enabled = !disabled,
|
||||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||||
)
|
)
|
||||||
@@ -272,8 +281,8 @@ private fun CalendarsList(
|
|||||||
// Synced calendars — read-only, grouped by account. Each account is a
|
// Synced calendars — read-only, grouped by account. Each account is a
|
||||||
// collapsible group whose header opens the source app (icon) and toggles
|
// collapsible group whose header opens the source app (icon) and toggles
|
||||||
// all of its calendars at once (switch).
|
// all of its calendars at once (switch).
|
||||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
GroupedSectionHeader(stringResource(R.string.calendars_synced_header))
|
||||||
HintText(stringResource(R.string.calendars_synced_hint))
|
GroupedHint(stringResource(R.string.calendars_synced_hint), style = MaterialTheme.typography.bodySmall)
|
||||||
synced
|
synced
|
||||||
.groupByAccount()
|
.groupByAccount()
|
||||||
.forEach { group ->
|
.forEach { group ->
|
||||||
@@ -316,7 +325,7 @@ private fun CalendarsList(
|
|||||||
ordered.forEachIndexed { index, calendar ->
|
ordered.forEachIndexed { index, calendar ->
|
||||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
summary = calendarRowSummary(calendar),
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
@@ -325,7 +334,7 @@ private fun CalendarsList(
|
|||||||
trailing = if (calendar.hasVisibilitySwitch) {
|
trailing = if (calendar.hasVisibilitySwitch) {
|
||||||
{
|
{
|
||||||
EnableSwitch(
|
EnableSwitch(
|
||||||
calendarName = calendar.displayName,
|
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
enabled = calendar.isVisibleInSystem,
|
enabled = calendar.isVisibleInSystem,
|
||||||
onToggle = { enabled ->
|
onToggle = { enabled ->
|
||||||
onSetVisible(calendar.id, enabled)
|
onSetVisible(calendar.id, enabled)
|
||||||
@@ -343,9 +352,15 @@ private fun CalendarsList(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
private const val SWATCHES_PER_ROW = 6
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or edit a local calendar: a name field over the palette of calendar
|
||||||
|
* colours, on the family's full-screen sheet with the commit in its title bar.
|
||||||
|
* The same sheet Agendula edits a task list in, so the two read as one product.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun CalendarEditor(
|
internal fun CalendarEditor(
|
||||||
sessionKey: Int,
|
sessionKey: Int,
|
||||||
isNew: Boolean,
|
isNew: Boolean,
|
||||||
initialName: String,
|
initialName: String,
|
||||||
@@ -360,121 +375,50 @@ private fun CalendarEditor(
|
|||||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val dark = isSystemInDarkTheme()
|
val focusRequester = remember { FocusRequester() }
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
|
|
||||||
Scaffold(
|
val valid = name.isNotBlank()
|
||||||
modifier = Modifier
|
val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) }
|
||||||
.predictiveBack(onBack = onClose)
|
|
||||||
.fillMaxSize()
|
FullScreenPicker(
|
||||||
.background(MaterialTheme.colorScheme.surface),
|
title = stringResource(
|
||||||
topBar = {
|
if (isNew) R.string.calendars_new_title else R.string.calendars_edit_title,
|
||||||
TopAppBar(
|
),
|
||||||
title = {
|
onDismiss = onClose,
|
||||||
Text(
|
predictiveBack = true,
|
||||||
stringResource(
|
actions = {
|
||||||
if (isNew) R.string.calendars_new_title
|
Button(
|
||||||
else R.string.calendars_edit_title,
|
onClick = commit,
|
||||||
),
|
enabled = valid,
|
||||||
)
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
},
|
) { Text(stringResource(R.string.event_edit_save)) }
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onClose) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = stringResource(R.string.event_edit_close),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (!isNew) {
|
|
||||||
// Disabled rather than hidden while the special-dates
|
|
||||||
// sync owns this calendar; the card below says why.
|
|
||||||
IconButton(
|
|
||||||
onClick = { confirmDelete = true },
|
|
||||||
enabled = !deleteLocked,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Delete,
|
|
||||||
contentDescription = stringResource(R.string.event_detail_delete),
|
|
||||||
tint = if (deleteLocked) {
|
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Filled save button, matching the event editor's top bar.
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onSave(name.trim(), color, description.trim().ifEmpty { null })
|
|
||||||
},
|
|
||||||
enabled = name.isNotBlank(),
|
|
||||||
modifier = Modifier.padding(end = 12.dp),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.event_edit_save))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) {
|
||||||
Column(
|
if (deleteLocked) {
|
||||||
modifier = Modifier
|
ManagedNote()
|
||||||
.padding(innerPadding)
|
Spacer(Modifier.height(20.dp))
|
||||||
.fillMaxSize()
|
}
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
CalendarNameField(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
name = name,
|
||||||
) {
|
color = color,
|
||||||
if (deleteLocked) {
|
focusRequester = focusRequester,
|
||||||
EditorCard(
|
onNameChange = { name = it },
|
||||||
icon = Icons.Default.Info,
|
onImeAction = commit,
|
||||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
)
|
||||||
iconAtTop = true,
|
// Inside the picker: it is a Dialog, so a request made from the caller's
|
||||||
) {
|
// composition can run before this field's node exists.
|
||||||
Text(
|
// A new calendar opens with the keyboard up: naming it is the whole task.
|
||||||
text = stringResource(R.string.calendars_managed_delete_locked),
|
LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() }
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Spacer(Modifier.height(20.dp))
|
||||||
)
|
EditorSectionLabel(stringResource(R.string.calendars_color_label))
|
||||||
}
|
CalendarColorGrid(selected = color, onSelect = { color = it })
|
||||||
}
|
|
||||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
Spacer(Modifier.height(20.dp))
|
||||||
InlineTextField(
|
EditorSectionLabel(stringResource(R.string.calendars_description_label))
|
||||||
value = name,
|
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
onValueChange = { name = it },
|
Box(Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(16.dp)) {
|
||||||
placeholder = stringResource(R.string.calendars_name_label),
|
|
||||||
textStyle = MaterialTheme.typography.titleLarge,
|
|
||||||
capitalization = KeyboardCapitalization.Sentences,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
EditorCard(
|
|
||||||
icon = Icons.Default.Palette,
|
|
||||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
iconAtTop = true,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.calendars_color_label),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
ColorSwatchRow(
|
|
||||||
colors = CalendarColorPalette.all,
|
|
||||||
selected = color,
|
|
||||||
onSelect = { color = it },
|
|
||||||
dark = dark,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
EditorCard(
|
|
||||||
icon = Icons.AutoMirrored.Filled.Notes,
|
|
||||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
iconAtTop = true,
|
|
||||||
) {
|
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = description,
|
value = description,
|
||||||
onValueChange = { description = it },
|
onValueChange = { description = it },
|
||||||
@@ -485,20 +429,21 @@ private fun CalendarEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isNew && !deleteLocked) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
DeleteCalendarRow(onClick = { confirmDelete = true })
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirmDelete) {
|
if (confirmDelete) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmDelete = false },
|
onDismissRequest = { confirmDelete = false },
|
||||||
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
|
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
|
||||||
text = {
|
text = { Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) },
|
||||||
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
|
|
||||||
},
|
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = { confirmDelete = false; onDelete() }) {
|
||||||
confirmDelete = false
|
|
||||||
onDelete()
|
|
||||||
}) {
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.event_detail_delete),
|
stringResource(R.string.event_detail_delete),
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
@@ -514,6 +459,205 @@ private fun CalendarEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The name, with the chosen colour beside it so the two read as one thing. */
|
||||||
|
@Composable
|
||||||
|
private fun CalendarNameField(
|
||||||
|
name: String,
|
||||||
|
color: Int,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
onNameChange: (String) -> Unit,
|
||||||
|
onImeAction: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = LocalFloretDarkTheme.current
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CalendarMonth,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = eventAccent(color, dark, soften),
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InlineTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
placeholder = stringResource(R.string.calendars_name_label),
|
||||||
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
onImeAction = onImeAction,
|
||||||
|
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The palette as two rows of round swatches; the chosen one carries a check.
|
||||||
|
*
|
||||||
|
* Drawn through the app's own tone pass rather than raw, so a swatch is the
|
||||||
|
* colour the calendar's events will actually appear in — the user's "soften
|
||||||
|
* colours" preference included.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) {
|
||||||
|
val dark = LocalFloretDarkTheme.current
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||||
|
.selectableGroup(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
CalendarColorPalette.all.chunked(SWATCHES_PER_ROW).forEach { row ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
row.forEach { swatch ->
|
||||||
|
ColorSwatch(
|
||||||
|
fill = eventFill(swatch, dark, soften),
|
||||||
|
label = stringResource(swatchLabel(swatch)),
|
||||||
|
selected = swatch == selected,
|
||||||
|
onClick = { onSelect(swatch) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Keeps a short final row's swatches the size a full row's
|
||||||
|
// are, rather than stretching them across the width.
|
||||||
|
repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColorSwatch(
|
||||||
|
fill: Color,
|
||||||
|
label: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(fill)
|
||||||
|
.then(
|
||||||
|
if (selected) {
|
||||||
|
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// selectable, not clickable: the swatch carries its chosen state in
|
||||||
|
// semantics too, so the check is not the only cue.
|
||||||
|
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||||
|
.semantics { contentDescription = label },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = eventInk(fill, alpha = 0.7f),
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names the swatch for a screen reader — twelve circles are otherwise one label. */
|
||||||
|
@StringRes
|
||||||
|
private fun swatchLabel(argb: Int): Int = when (argb) {
|
||||||
|
CalendarColorPalette.Mauve -> R.string.color_name_mauve
|
||||||
|
CalendarColorPalette.Red -> R.string.color_name_red
|
||||||
|
CalendarColorPalette.Orange -> R.string.color_name_orange
|
||||||
|
CalendarColorPalette.Amber -> R.string.color_name_amber
|
||||||
|
CalendarColorPalette.Olive -> R.string.color_name_olive
|
||||||
|
CalendarColorPalette.Green -> R.string.color_name_green
|
||||||
|
CalendarColorPalette.Teal -> R.string.color_name_teal
|
||||||
|
CalendarColorPalette.Cyan -> R.string.color_name_cyan
|
||||||
|
CalendarColorPalette.Blue -> R.string.color_name_blue
|
||||||
|
CalendarColorPalette.Indigo -> R.string.color_name_indigo
|
||||||
|
CalendarColorPalette.Purple -> R.string.color_name_purple
|
||||||
|
else -> R.string.color_name_pink
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Said in the sheet rather than on a disabled button: the sync owns this one. */
|
||||||
|
@Composable
|
||||||
|
private fun ManagedNote() {
|
||||||
|
GroupedSurface(
|
||||||
|
position = Position.Alone,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Info, contentDescription = null)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeleteCalendarRow(onClick: () -> Unit) {
|
||||||
|
GroupedSurface(
|
||||||
|
position = Position.Alone,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
onClick = onClick,
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_detail_delete),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EditorSectionLabel(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The row's supporting line: the states that make this calendar behave unlike a
|
* The row's supporting line: the states that make this calendar behave unlike a
|
||||||
* plain writable one (#76), then its own description.
|
* plain writable one (#76), then its own description.
|
||||||
@@ -556,36 +700,6 @@ private fun EnableSwitch(
|
|||||||
private fun dimIf(disabled: Boolean): Modifier =
|
private fun dimIf(disabled: Boolean): Modifier =
|
||||||
if (disabled) Modifier.alpha(0.38f) else Modifier
|
if (disabled) Modifier.alpha(0.38f) else Modifier
|
||||||
|
|
||||||
/** Tonal field card matching the event editor's design (icon + content). */
|
|
||||||
@Composable
|
|
||||||
private fun EditorCard(
|
|
||||||
icon: ImageVector,
|
|
||||||
iconTint: Color,
|
|
||||||
iconAtTop: Boolean = false,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = iconTint,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = if (iconAtTop) 2.dp else 0.dp)
|
|
||||||
.size(24.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) { content() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One collapsible calendar group rendered as a connected card. The header row is
|
* One collapsible calendar group rendered as a connected card. The header row is
|
||||||
@@ -717,33 +831,6 @@ private fun CalendarGroupMenu(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun SectionHeader(text: String) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
// The cards' own edge, so header and group share a left margin.
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = GroupedListInset,
|
|
||||||
end = GroupedListInset,
|
|
||||||
top = 16.dp,
|
|
||||||
bottom = 4.dp,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun HintText(text: String) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the app to open for managing a synced calendar's account. The account's
|
* Pick the app to open for managing a synced calendar's account. The account's
|
||||||
* own authenticator package (resolved from [AccountManager], no permission
|
* own authenticator package (resolved from [AccountManager], no permission
|
||||||
|
|||||||
@@ -9,22 +9,23 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
|
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||||
|
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
@@ -50,12 +51,26 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
repository.calendars()
|
repository.calendars()
|
||||||
.catch { emit(emptyList()) }
|
.catch { emit(emptyList()) }
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
.stateIn(
|
.stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = emptyList(),
|
initialValue = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Backup & restore screen's own view of that list, with the loading and
|
||||||
|
* failure states [calendars] flattens away — it starts empty and catches to
|
||||||
|
* empty, which that screen used to render as a blank page (#304).
|
||||||
|
*/
|
||||||
|
val backupState: StateFlow<BackupUiState> =
|
||||||
|
repository.calendars()
|
||||||
|
.map { backupUiState(it) }
|
||||||
|
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateWhileSubscribed(
|
||||||
|
scope = viewModelScope,
|
||||||
|
initialValue = BackupUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||||
settingsPrefs.autoBackupEnabled,
|
settingsPrefs.autoBackupEnabled,
|
||||||
@@ -66,9 +81,8 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
AutoBackupUiState(enabled, interval, folder, status)
|
AutoBackupUiState(enabled, interval, folder, status)
|
||||||
}
|
}
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
.stateIn(
|
.stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = AutoBackupUiState(),
|
initialValue = AutoBackupUiState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,9 +99,8 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
|
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
|
||||||
}
|
}
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
.stateIn(
|
.stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = emptySet(),
|
initialValue = emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -56,12 +56,14 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
|||||||
* account comes from when another account shares the name (#77).
|
* account comes from when another account shares the name (#77).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
fun accountGroupTitle(group: CalendarAccountGroup): String {
|
||||||
if (!group.ambiguous) {
|
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||||
group.label
|
return if (!group.ambiguous) {
|
||||||
|
label
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
|
stringResource(R.string.calendars_account_from_source, label, sourceAppName(group.accountType))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The human name of the app backing [accountType], falling back to the raw
|
* The human name of the app backing [accountType], falling back to the raw
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.snap
|
import androidx.compose.animation.core.snap
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -13,15 +14,19 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.layout.ParentDataModifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/** Gap a timed block leaves to its neighbours in the column. */
|
/** Gap a timed block leaves to its neighbours in the column. */
|
||||||
val BLOCK_OUTER_INSET = 1.dp
|
val BLOCK_OUTER_INSET = 1.dp
|
||||||
@@ -32,8 +37,67 @@ val BLOCK_TEXT_PADDING = 4.dp
|
|||||||
/** The same, above and below — what a block's height has to pay before any text. */
|
/** The same, above and below — what a block's height has to pay before any text. */
|
||||||
val BLOCK_TEXT_INSET = 2.dp
|
val BLOCK_TEXT_INSET = 2.dp
|
||||||
|
|
||||||
/** Most lines a time label may wrap over before it is worth more than a title line. */
|
/**
|
||||||
const val MAX_TIME_LINES = 2
|
* What a timed block of a given height has to spend on text, and what each line
|
||||||
|
* of it costs.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class BlockTextMetrics(
|
||||||
|
/** Padding above and below the text — see [rememberBlockTextMetrics]. */
|
||||||
|
val inset: Dp,
|
||||||
|
/** Height left for text once [inset] is paid at both edges. */
|
||||||
|
val available: Dp,
|
||||||
|
/** What the first line of a title draws in. */
|
||||||
|
val titleLine: Dp,
|
||||||
|
/** What every title line after the first adds. */
|
||||||
|
val titleLeading: Dp,
|
||||||
|
/** What the time label's one line draws in. */
|
||||||
|
val timeLine: Dp,
|
||||||
|
) {
|
||||||
|
/** Whether the block can draw a title at all. */
|
||||||
|
val fitsTitle: Boolean get() = available >= titleLine
|
||||||
|
|
||||||
|
/** Height a title of [lines] lines occupies. */
|
||||||
|
fun titleHeight(lines: Int): Dp =
|
||||||
|
if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1)
|
||||||
|
|
||||||
|
/** Title lines that fit [within], which may be none. */
|
||||||
|
fun titleBudget(within: Dp): Int =
|
||||||
|
if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vertical padding a block [height] tall can afford around a title line of
|
||||||
|
* [titleLine].
|
||||||
|
*
|
||||||
|
* The inset is what the block gives up first: breathing room is worth having
|
||||||
|
* where there is room to breathe, but on a block down to its last few pixels a
|
||||||
|
* bare colour chip where a label would have fit reads as a rendering fault. It
|
||||||
|
* tapers rather than snapping, so a pinch closes the gap gradually instead of
|
||||||
|
* dropping it in one frame (#289).
|
||||||
|
*/
|
||||||
|
internal fun blockTextInset(height: Dp, titleLine: Dp): Dp =
|
||||||
|
minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp)
|
||||||
|
|
||||||
|
/** Text metrics for a timed block [height] tall. */
|
||||||
|
@Composable
|
||||||
|
fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
|
||||||
|
val titleStyle = MaterialTheme.typography.labelMedium
|
||||||
|
val titleLine = rememberTrimmedLineHeight(titleStyle)
|
||||||
|
// Packed, so a second line costs what the first did rather than a whole
|
||||||
|
// Material line box — the gap between two lines of a wrapped title is the
|
||||||
|
// one place a block pays that leading twice (#190).
|
||||||
|
val titleLeading = titleLine
|
||||||
|
val timeLine = rememberTrimmedLineHeight(MaterialTheme.typography.labelSmall.asEventTime())
|
||||||
|
val inset = blockTextInset(height, titleLine)
|
||||||
|
return BlockTextMetrics(
|
||||||
|
inset = inset,
|
||||||
|
available = height - inset * 2,
|
||||||
|
titleLine = titleLine,
|
||||||
|
titleLeading = titleLeading,
|
||||||
|
timeLine = timeLine,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lines [text] needs to render whole at [textWidth], capped at [max].
|
* Lines [text] needs to render whole at [textWidth], capped at [max].
|
||||||
@@ -64,29 +128,65 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
|
|||||||
* Lines the time label may take at [textWidth], out of the [spare] height left
|
* Lines the time label may take at [textWidth], out of the [spare] height left
|
||||||
* once the title and the label's own first line are paid for.
|
* once the title and the label's own first line are paid for.
|
||||||
*
|
*
|
||||||
* A week column is narrower than a "09:30–11:00" range, so the label takes a
|
* A week column is narrower than a "09:30–11:00" range — and a 12-hour
|
||||||
* second line rather than lose its end — but only out of a line the title
|
* "6:00 PM–7:00 PM" can want four lines — so the label wraps over as many as it
|
||||||
* measured itself as not needing, never one it would have filled.
|
* needs rather than lose its end, but only out of lines the title measured
|
||||||
|
* itself as not needing, never one it would have filled.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
||||||
val timeLineHeight = with(LocalDensity.current) {
|
val timeLineHeight = rememberTrimmedLineHeight(
|
||||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
MaterialTheme.typography.labelSmall.asEventTime(),
|
||||||
}
|
)
|
||||||
return if (spare >= timeLineHeight) {
|
return blockTextLines(
|
||||||
blockTextLines(
|
text = label,
|
||||||
text = label,
|
// The style it is drawn in, or the budget measures a line the label
|
||||||
// The style it is drawn in, or the budget measures a line the label
|
// never uses (#219).
|
||||||
// never uses (#219).
|
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
textWidth = textWidth,
|
||||||
textWidth = textWidth,
|
max = timeLinesWithin(spare, timeLineHeight),
|
||||||
max = MAX_TIME_LINES,
|
)
|
||||||
)
|
}
|
||||||
} else {
|
|
||||||
1
|
/** Lines a time label may take: its own first one, plus every whole [timeLine] in [spare]. */
|
||||||
|
internal fun timeLinesWithin(spare: Dp, timeLine: Dp): Int =
|
||||||
|
if (spare < timeLine || timeLine <= 0.dp) 1 else 1 + (spare / timeLine).toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time range as a timed block draws it.
|
||||||
|
*
|
||||||
|
* Each time stays whole where the column is wide enough for one, so a 12-hour
|
||||||
|
* label never parts "6:00" from its "PM": the range breaks at the dash, which
|
||||||
|
* rides on whichever side has room for it. Where not even one time fits, the
|
||||||
|
* text wraps wherever it can.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberBlockTimeRange(start: String, end: String, textWidth: Dp): String {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val style = MaterialTheme.typography.labelSmall.asEventTime()
|
||||||
|
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
|
||||||
|
return remember(start, end, style, widthPx, measurer) {
|
||||||
|
blockTimeRange(start, end) {
|
||||||
|
measurer.measure(text = it, style = style, softWrap = false).size.width <= widthPx
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun blockTimeRange(start: String, end: String, fits: (String) -> Boolean): String {
|
||||||
|
val from = start.replace(' ', NO_BREAK_SPACE)
|
||||||
|
val to = end.replace(' ', NO_BREAK_SPACE)
|
||||||
|
return when {
|
||||||
|
fits("$from–$to") -> "$from–$to"
|
||||||
|
!fits(from) || !fits(to) -> "$start–$ZERO_WIDTH_SPACE$end"
|
||||||
|
fits("$from–") -> "$from–\n$to"
|
||||||
|
fits("–$to") -> "$from\n–$to"
|
||||||
|
else -> "$from\n$to"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NO_BREAK_SPACE = ' '
|
||||||
|
private const val ZERO_WIDTH_SPACE = ''
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A timed block's title, over at most [maxLines], breaking at word boundaries.
|
* A timed block's title, over at most [maxLines], breaking at word boundaries.
|
||||||
*
|
*
|
||||||
@@ -108,8 +208,10 @@ fun BlockTitle(
|
|||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
style = MaterialTheme.typography.labelMedium
|
style = rememberPackedLines(
|
||||||
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
MaterialTheme.typography.labelMedium
|
||||||
|
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
||||||
|
),
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
overflow = overflow.overflow,
|
overflow = overflow.overflow,
|
||||||
softWrap = overflow.softWrap,
|
softWrap = overflow.softWrap,
|
||||||
@@ -141,6 +243,8 @@ fun BlockTimeLabel(
|
|||||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||||
}
|
}
|
||||||
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||||
|
// Regular weight against the title's medium above it (#219).
|
||||||
|
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState = label,
|
targetState = label,
|
||||||
animationSpec = spec,
|
animationSpec = spec,
|
||||||
@@ -149,8 +253,7 @@ fun BlockTimeLabel(
|
|||||||
) { text ->
|
) { text ->
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
// Regular weight against the title's medium above it (#219).
|
style = style,
|
||||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
overflow = overflow.overflow,
|
overflow = overflow.overflow,
|
||||||
softWrap = overflow.softWrap,
|
softWrap = overflow.softWrap,
|
||||||
@@ -159,9 +262,13 @@ fun BlockTimeLabel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where a timed block sits in its column, after tweening. */
|
/**
|
||||||
|
* Where a timed block sits in its column, after tweening: across as shares of
|
||||||
|
* the column's width, so a column resizing carries its blocks with it (#184),
|
||||||
|
* down in the timeline's own units.
|
||||||
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y: Dp, val height: Dp)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
||||||
@@ -171,20 +278,63 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
|||||||
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
|
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
|
||||||
* grid lines follow it instantly — so the tween stands down for the gesture
|
* grid lines follow it instantly — so the tween stands down for the gesture
|
||||||
* rather than leaving the blocks trailing the ruler they are measured against.
|
* rather than leaving the blocks trailing the ruler they are measured against.
|
||||||
|
* A [landing] block, under a dropped copy, moves without the spring's bounce.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
fun animatedBlockPlacement(
|
||||||
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
|
xFraction: Float,
|
||||||
LocalTimelineZoom.current.isPinching
|
widthFraction: Float,
|
||||||
) {
|
y: Dp,
|
||||||
snap()
|
height: Dp,
|
||||||
} else {
|
landing: Boolean = false,
|
||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
): BlockPlacement {
|
||||||
|
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
|
||||||
|
val dpSpec: FiniteAnimationSpec<Dp> = when {
|
||||||
|
still -> snap()
|
||||||
|
landing -> MaterialTheme.motionScheme.fastEffectsSpec()
|
||||||
|
else -> MaterialTheme.motionScheme.fastSpatialSpec()
|
||||||
|
}
|
||||||
|
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||||
|
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
|
||||||
|
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
|
||||||
|
val animatedY by animateDpAsState(y, dpSpec, label = "block-y")
|
||||||
|
val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height")
|
||||||
|
return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seats a block across its [placement]'s share of a [LaneColumn]. */
|
||||||
|
fun Modifier.laneSlot(placement: BlockPlacement): Modifier =
|
||||||
|
then(LaneSlot(placement.xFraction, placement.widthFraction))
|
||||||
|
|
||||||
|
private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier {
|
||||||
|
override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day column's blocks, each measured across the share of the column its
|
||||||
|
* [laneSlot] gives it; anything else fills the column. The shares are applied
|
||||||
|
* at layout time, so the column can change width every frame without its
|
||||||
|
* blocks recomposing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||||
|
Layout(content = content, modifier = modifier) { measurables, constraints ->
|
||||||
|
val width = constraints.maxWidth
|
||||||
|
val loose = constraints.copy(minWidth = 0, minHeight = 0)
|
||||||
|
val placed = measurables.map { measurable ->
|
||||||
|
val slot = measurable.parentData as? LaneSlot
|
||||||
|
if (slot == null) {
|
||||||
|
measurable.measure(loose) to 0
|
||||||
|
} else {
|
||||||
|
val left = (slot.x * width).roundToInt()
|
||||||
|
val right = ((slot.x + slot.width) * width).roundToInt()
|
||||||
|
val laneWidth = (right - left).coerceAtLeast(0)
|
||||||
|
measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout(width, constraints.maxHeight) {
|
||||||
|
placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val animatedX by animateDpAsState(x, spec, label = "block-x")
|
|
||||||
val animatedY by animateDpAsState(y, spec, label = "block-y")
|
|
||||||
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
|
|
||||||
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
|
|
||||||
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
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
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.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
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a calendar screen shows in the top bar, the FAB and the drawer, which the
|
||||||
|
* host draws once for all of them (#184): switching screens changes what they
|
||||||
|
* say rather than swapping one bar for another.
|
||||||
|
*
|
||||||
|
* @param titleDate the date the title's picker opens on, or null for a title
|
||||||
|
* that is only a label.
|
||||||
|
* @param drawerDate the date the drawer's picker opens on.
|
||||||
|
* @param awayFromToday whether "today" is worth offering.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class ScreenChrome(
|
||||||
|
val title: String,
|
||||||
|
val shortTitle: String = title,
|
||||||
|
val titleDate: LocalDate?,
|
||||||
|
val drawerDate: LocalDate,
|
||||||
|
val awayFromToday: Boolean,
|
||||||
|
val todayText: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What the chrome does for the screen on show; read when used, so not observed. */
|
||||||
|
class ScreenActions(
|
||||||
|
val onToday: () -> Unit,
|
||||||
|
val onJumpToDate: (LocalDate) -> Unit,
|
||||||
|
val onCreate: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* [create] is set for a block drafted on an empty slot (#153), which has no
|
||||||
|
* event yet, so the bar offers to create one.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class EventSelection(
|
||||||
|
val event: EventInstance,
|
||||||
|
val dismiss: () -> Unit,
|
||||||
|
val create: (() -> Unit)? = null,
|
||||||
|
/** The event at the times an ongoing move or resize would give it, else null. */
|
||||||
|
val preview: () -> EventInstance? = { null },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
|
||||||
|
internal fun publish(chrome: ScreenChrome, actions: ScreenActions) {
|
||||||
|
this.chrome = chrome
|
||||||
|
this.actions = actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */
|
||||||
|
class ChromeSlot(val host: ChromeHost, val current: Boolean)
|
||||||
|
|
||||||
|
val LocalChromeSlot = staticCompositionLocalOf<ChromeSlot?> { null }
|
||||||
|
|
||||||
|
/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */
|
||||||
|
@Composable
|
||||||
|
fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
|
||||||
|
val slot = LocalChromeSlot.current ?: return
|
||||||
|
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
onCreate: (() -> Unit)? = null,
|
||||||
|
preview: () -> EventInstance? = { null },
|
||||||
|
) {
|
||||||
|
val slot = LocalChromeSlot.current ?: return
|
||||||
|
val currentDismiss by rememberUpdatedState(onDismiss)
|
||||||
|
val currentCreate by rememberUpdatedState(onCreate)
|
||||||
|
val currentPreview by rememberUpdatedState(preview)
|
||||||
|
val drafted = onCreate != null
|
||||||
|
val selection = remember(event, drafted) {
|
||||||
|
event?.let {
|
||||||
|
EventSelection(
|
||||||
|
event = it,
|
||||||
|
dismiss = { currentDismiss() },
|
||||||
|
create = if (drafted) ({ currentCreate?.invoke() }) else null,
|
||||||
|
preview = { currentPreview() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
selectedView: CalendarView,
|
||||||
|
quickSwitchViews: List<CalendarView>,
|
||||||
|
onCycleView: () -> Unit,
|
||||||
|
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(
|
||||||
|
title = {
|
||||||
|
if (chrome != null) {
|
||||||
|
CalendarTitleButton(
|
||||||
|
title = chrome.title,
|
||||||
|
currentDate = chrome.titleDate,
|
||||||
|
onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) },
|
||||||
|
shortTitle = chrome.shortTitle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onOpenDrawer) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Menu,
|
||||||
|
contentDescription = stringResource(R.string.month_open_menu),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() })
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ViewSwitcherPill(
|
||||||
|
current = selectedView,
|
||||||
|
cycle = quickSwitchViews,
|
||||||
|
onCycle = onCycleView,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||||
|
// it, but here the bar meets the header on the same surface and the
|
||||||
|
// tint is what makes that seam look like a separate block (#186).
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The FAB column every calendar screen shares. 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, selection: FabSelection? = null) {
|
||||||
|
val chrome = host.chrome ?: return
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A calendar screen's body under the shared chrome, on the ground the screen's
|
||||||
|
* own Scaffold used to give it; the host's Scaffold has already made room for
|
||||||
|
* the bars, so [content] gets no padding of its own.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) {
|
||||||
|
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
|
content(PaddingValues())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
@@ -14,6 +13,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
||||||
@@ -23,7 +23,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = LocalFloretDarkTheme.current
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ fun CalendarDrawer(
|
|||||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||||
viewOrder.forEachIndexed { index, view ->
|
viewOrder.forEachIndexed { index, view ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(view.labelRes),
|
title = view.label(),
|
||||||
position = positionOf(index, viewOrder.size),
|
position = positionOf(index, viewOrder.size),
|
||||||
selected = view == currentView,
|
selected = view == currentView,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
@@ -15,15 +24,38 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.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.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
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
|
* 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
|
* 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)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -32,13 +64,14 @@ fun CalendarFabColumn(
|
|||||||
todayText: String,
|
todayText: String,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
onCreate: () -> Unit,
|
onCreate: () -> Unit,
|
||||||
|
selection: FabSelection? = null,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.End,
|
horizontalAlignment = Alignment.End,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = todayVisible,
|
visible = todayVisible && selection == null,
|
||||||
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
|
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||||
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
|
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||||
) {
|
) {
|
||||||
@@ -48,11 +81,71 @@ fun CalendarFabColumn(
|
|||||||
text = { Text(todayText) },
|
text = { Text(todayText) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
FloatingActionButton(onClick = onCreate) {
|
// Held past the selection clearing, so the bar has something to fold away.
|
||||||
Icon(
|
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
|
||||||
imageVector = Icons.Default.Add,
|
if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
|
||||||
contentDescription = stringResource(R.string.event_edit_new_title),
|
// 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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,40 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.outlined.EditOff
|
||||||
|
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||||
|
import androidx.compose.material.icons.outlined.EventBusy
|
||||||
|
import androidx.compose.material.icons.outlined.Lock
|
||||||
|
import androidx.compose.material.icons.outlined.SyncProblem
|
||||||
|
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -20,37 +43,168 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen failure state shared by every calendar screen (spec §7).
|
* Full-screen failure state shared by every calendar screen (spec §7).
|
||||||
* One explanation line + one recovery action, never a toast.
|
* A tonal icon, one headline, one supporting line and one recovery action —
|
||||||
|
* never a toast.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
fun CalendarFailure(
|
||||||
val titleRes = when (reason) {
|
reason: FailureReason,
|
||||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
onRetry: () -> Unit,
|
||||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
modifier: Modifier = Modifier,
|
||||||
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
) {
|
||||||
FailureReason.Unknown,
|
val context = LocalContext.current
|
||||||
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
val manageCalendars = LocalManageCalendars.current
|
||||||
}
|
val copy = failureCopy(reason)
|
||||||
val actionRes = when (reason) {
|
val onAction: () -> Unit = when (reason) {
|
||||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
FailureReason.NoCalendarsConfigured -> {
|
||||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
{ context.startCalendarSetup() }
|
||||||
else -> R.string.state_retry
|
}
|
||||||
|
FailureReason.AllCalendarsHidden,
|
||||||
|
FailureReason.NoImportTarget,
|
||||||
|
-> manageCalendars ?: onRetry
|
||||||
|
else -> onRetry
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(32.dp),
|
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
FailureIcon(icon = copy.icon, isError = copy.isError)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(titleRes),
|
text = stringResource(copy.title),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
copy.body?.let { body ->
|
||||||
FilledTonalButton(onClick = onRetry) {
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(stringResource(actionRes))
|
Text(
|
||||||
|
text = stringResource(body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
FilledTonalButton(onClick = onAction) {
|
||||||
|
Text(stringResource(copy.action))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The state's mark: the reason's icon in a tonal circle above the headline. */
|
||||||
|
@Composable
|
||||||
|
private fun FailureIcon(icon: ImageVector, isError: Boolean) {
|
||||||
|
val container: Color
|
||||||
|
val content: Color
|
||||||
|
if (isError) {
|
||||||
|
container = MaterialTheme.colorScheme.errorContainer
|
||||||
|
content = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
} else {
|
||||||
|
container = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
content = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.background(container, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = content,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a reason says and offers. Headline and supporting line are separate so
|
||||||
|
* the explanation reads as body text instead of a headline that runs over three
|
||||||
|
* lines; [isError] tints the mark for the two reasons that are an actual fault
|
||||||
|
* rather than a calendar the user can switch back on.
|
||||||
|
*/
|
||||||
|
private data class FailureCopy(
|
||||||
|
val icon: ImageVector,
|
||||||
|
@StringRes val title: Int,
|
||||||
|
@StringRes val body: Int?,
|
||||||
|
@StringRes val action: Int,
|
||||||
|
val isError: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) {
|
||||||
|
FailureReason.PermissionRevoked -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.Lock,
|
||||||
|
title = R.string.state_failure_permission,
|
||||||
|
body = R.string.state_failure_permission_body,
|
||||||
|
action = R.string.state_failure_permission_action,
|
||||||
|
)
|
||||||
|
FailureReason.NoCalendarsConfigured -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.CalendarMonth,
|
||||||
|
title = R.string.state_failure_no_calendars,
|
||||||
|
body = R.string.state_failure_no_calendars_body,
|
||||||
|
action = R.string.state_failure_no_calendars_action,
|
||||||
|
)
|
||||||
|
FailureReason.AllCalendarsHidden -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.VisibilityOff,
|
||||||
|
title = R.string.state_failure_all_hidden,
|
||||||
|
body = R.string.state_failure_all_hidden_body,
|
||||||
|
action = R.string.state_failure_all_hidden_action,
|
||||||
|
)
|
||||||
|
FailureReason.NoImportTarget -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.EditOff,
|
||||||
|
title = R.string.state_failure_no_import_target,
|
||||||
|
body = R.string.state_failure_no_import_target_body,
|
||||||
|
action = R.string.state_failure_all_hidden_action,
|
||||||
|
)
|
||||||
|
FailureReason.EventNotFound -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.EventBusy,
|
||||||
|
title = R.string.state_failure_event_not_found,
|
||||||
|
body = R.string.state_failure_event_not_found_body,
|
||||||
|
action = R.string.state_retry,
|
||||||
|
)
|
||||||
|
FailureReason.ProviderUnavailable -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.SyncProblem,
|
||||||
|
title = R.string.state_failure_provider,
|
||||||
|
body = R.string.state_failure_provider_body,
|
||||||
|
action = R.string.state_retry,
|
||||||
|
isError = true,
|
||||||
|
)
|
||||||
|
FailureReason.Unknown -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.ErrorOutline,
|
||||||
|
title = R.string.state_failure_unknown,
|
||||||
|
body = null,
|
||||||
|
action = R.string.state_retry,
|
||||||
|
isError = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the headline and its supporting line at a readable measure. */
|
||||||
|
private val TEXT_MAX_WIDTH = 320.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens Settings → Calendars, the only screen that can switch a calendar back
|
||||||
|
* on. Null outside the calendar host — screens without it never raise the
|
||||||
|
* reason that needs it, and fall back to their own retry.
|
||||||
|
*/
|
||||||
|
val LocalManageCalendars = compositionLocalOf<(() -> Unit)?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the system screen for putting a calendar on the device: the account
|
||||||
|
* chooser filtered to calendar providers, falling back to account settings
|
||||||
|
* where no chooser resolves. Both are system components, so nothing here needs
|
||||||
|
* a `<queries>` entry.
|
||||||
|
*/
|
||||||
|
private fun Context.startCalendarSetup() {
|
||||||
|
val intents = listOf(
|
||||||
|
Intent(Settings.ACTION_ADD_ACCOUNT)
|
||||||
|
.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)),
|
||||||
|
Intent(Settings.ACTION_SYNC_SETTINGS),
|
||||||
|
)
|
||||||
|
intents.firstOrNull { runCatching { startActivity(it) }.isSuccess }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.core.VectorConverter
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.lerp
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One height for every page of something that varies from page to page — the
|
||||||
|
* timelines' all-day strip, the split grid's rows — blended between the two
|
||||||
|
* pages a swipe sits between, so what sits below it moves with the finger rather
|
||||||
|
* than jumping on settle. It follows the swipe directly and only springs when a
|
||||||
|
* page's own height changes at rest. [fallback] stands in until the pages have
|
||||||
|
* reported theirs, so it doesn't open from zero.
|
||||||
|
*
|
||||||
|
* @param heights each composed page's own height, by page index.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun pagedHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
|
||||||
|
val currentFallback by rememberUpdatedState(fallback)
|
||||||
|
val target by remember(pagerState, heights) {
|
||||||
|
derivedStateOf {
|
||||||
|
val position = pagerState.currentPage + pagerState.currentPageOffsetFraction
|
||||||
|
val from = floor(position).toInt()
|
||||||
|
val a = heights[from]
|
||||||
|
val b = heights[from + 1]
|
||||||
|
lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val height = remember { Animatable(target, Dp.VectorConverter) }
|
||||||
|
LaunchedEffect(height) {
|
||||||
|
snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) ->
|
||||||
|
if (scrolling) height.snapTo(h) else height.animateTo(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return height.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A deliberate jump (Today, jump-to-date): animated, or snapped under reduced motion. */
|
||||||
|
suspend fun PagerState.jumpToPage(
|
||||||
|
page: Int,
|
||||||
|
reduceMotion: Boolean,
|
||||||
|
spec: FiniteAnimationSpec<Float>,
|
||||||
|
) {
|
||||||
|
if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec)
|
||||||
|
}
|
||||||
@@ -34,7 +34,6 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's single "which calendar" selection list, shared by the event editor
|
* The app's single "which calendar" selection list, shared by the event editor
|
||||||
@@ -114,17 +113,11 @@ private fun CalendarPickerGroup(
|
|||||||
leading = leading,
|
leading = leading,
|
||||||
)
|
)
|
||||||
calendars.forEachIndexed { index, calendar ->
|
calendars.forEachIndexed { index, calendar ->
|
||||||
val isSelected = calendar.id == selectedId
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||||
selected = isSelected,
|
checked = calendar.id == selectedId,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
trailing = if (isSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { onSelect(calendar.id) },
|
onClick = { onSelect(calendar.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drag distance that turns a calendar page, shared by the month, week and day
|
|
||||||
* views so all three answer a swipe at the same point.
|
|
||||||
*
|
|
||||||
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
|
|
||||||
* changed the month, and a page that turns on an unintended gesture reads as the
|
|
||||||
* animation misfiring rather than as the gesture being over-eager.
|
|
||||||
*/
|
|
||||||
val CALENDAR_SWIPE_THRESHOLD = 24.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The whole-page horizontal swipe: one page per gesture, committed **the moment
|
|
||||||
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
|
|
||||||
*
|
|
||||||
* Waiting for the lift meant the page sat still under a finger that had already
|
|
||||||
* travelled far enough to ask for it, and the answer only arrived once you let
|
|
||||||
* go — which reads as the view being slow rather than as a deliberate commit.
|
|
||||||
* Firing on the threshold is what makes the gesture feel like it is being
|
|
||||||
* followed. The trade is that a drag can no longer be taken back by dragging the
|
|
||||||
* other way; in practice, once you have moved 24dp deliberately you meant it, and
|
|
||||||
* the page you land on is one swipe back.
|
|
||||||
*
|
|
||||||
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
|
|
||||||
* underneath this, and a two-dimensional detector here would claim those drags
|
|
||||||
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
|
|
||||||
* detector's slop while a vertical one is consumed below, and the two coexist.
|
|
||||||
* (The month view's split style needs a vertical axis as well, so it keeps its
|
|
||||||
* own axis-locking detector rather than using this.)
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberCalendarPageSwipe(
|
|
||||||
onSwipeNext: () -> Unit,
|
|
||||||
onSwipePrev: () -> Unit,
|
|
||||||
): Modifier {
|
|
||||||
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
|
|
||||||
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
|
|
||||||
var accum = 0f
|
|
||||||
// One page per gesture: without this a long drag would keep re-firing
|
|
||||||
// every time the accumulator crossed the threshold again.
|
|
||||||
var fired = false
|
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = {
|
|
||||||
accum = 0f
|
|
||||||
fired = false
|
|
||||||
},
|
|
||||||
onDragEnd = {
|
|
||||||
accum = 0f
|
|
||||||
fired = false
|
|
||||||
},
|
|
||||||
onDragCancel = {
|
|
||||||
accum = 0f
|
|
||||||
fired = false
|
|
||||||
},
|
|
||||||
onHorizontalDrag = { _, drag ->
|
|
||||||
accum += drag
|
|
||||||
if (!fired) {
|
|
||||||
val commit = when {
|
|
||||||
accum < -threshold -> onSwipeNext
|
|
||||||
accum > threshold -> onSwipePrev
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (commit != null) {
|
|
||||||
fired = true
|
|
||||||
commit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.style.LineHeightStyle
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
|
||||||
|
* a 16sp line box — room a label wants when it stands on its own, and close to
|
||||||
|
* a fifth of an event row when it doesn't. The outer edges only, so a wrapped
|
||||||
|
* title keeps its interior line spacing (#190).
|
||||||
|
*
|
||||||
|
* Trimmed rather than set to a smaller line height: the font picker can load a
|
||||||
|
* serif, a monospace or a file of the user's own, and a line height under a
|
||||||
|
* face's own ascent and descent overlaps its lines. There is nothing to trim
|
||||||
|
* below that, so this is safe whatever font is chosen.
|
||||||
|
*/
|
||||||
|
private val TrimmedLines = LineHeightStyle(
|
||||||
|
alignment = LineHeightStyle.Alignment.Center,
|
||||||
|
trim = LineHeightStyle.Trim.Both,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Ascenders and descenders both, so a line is measured at its full extent. */
|
||||||
|
private const val LINE_SAMPLE = "Ag"
|
||||||
|
|
||||||
|
/** [this] with Material's outer leading trimmed — see [TrimmedLines]. */
|
||||||
|
fun TextStyle.trimmedLines(): TextStyle = copy(lineHeightStyle = TrimmedLines)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [style] with its wrapped lines packed onto the face's own extent instead of
|
||||||
|
* Material's line box.
|
||||||
|
*
|
||||||
|
* [trimmedLines] takes the leading off the outer edges of a run of text; this
|
||||||
|
* takes it from between the lines as well, which is the half a wrapped event
|
||||||
|
* title pays for twice over. The line height is *measured* from the font rather
|
||||||
|
* than picked, so it lands exactly on the face's ascent-plus-descent and can
|
||||||
|
* never be short enough to overlap — whatever the font picker has loaded (#190).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberPackedLines(style: TextStyle): TextStyle {
|
||||||
|
val line = rememberTrimmedLineHeight(style)
|
||||||
|
return with(LocalDensity.current) { style.trimmedLines().copy(lineHeight = line.toSp()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What one trimmed line of [style] actually draws in. */
|
||||||
|
@Composable
|
||||||
|
fun rememberTrimmedLineHeight(style: TextStyle): Dp {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
return remember(style, density, measurer) {
|
||||||
|
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -10,12 +18,12 @@ import androidx.compose.material.icons.filled.ArrowDropDown
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -33,7 +41,8 @@ import kotlinx.datetime.LocalDate
|
|||||||
* drawer uses and reporting the chosen day through [onJumpToDate].
|
* drawer uses and reporting the chosen day through [onJumpToDate].
|
||||||
*
|
*
|
||||||
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
||||||
* visible day, week start or month anchor).
|
* visible day, week start or month anchor); null makes the title a plain label,
|
||||||
|
* as the agenda's is, and the caret folds away.
|
||||||
*
|
*
|
||||||
* [shortTitle] replaces [title] when the full one does not fit the width the app
|
* [shortTitle] replaces [title] when the full one does not fit the width the app
|
||||||
* bar hands the title slot (#165). Either way the line clamps to one and
|
* bar hands the title slot (#165). Either way the line clamps to one and
|
||||||
@@ -42,7 +51,7 @@ import kotlinx.datetime.LocalDate
|
|||||||
@Composable
|
@Composable
|
||||||
fun CalendarTitleButton(
|
fun CalendarTitleButton(
|
||||||
title: String,
|
title: String,
|
||||||
currentDate: LocalDate,
|
currentDate: LocalDate?,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
shortTitle: String = title,
|
shortTitle: String = title,
|
||||||
@@ -54,6 +63,7 @@ fun CalendarTitleButton(
|
|||||||
.heightIn(min = 48.dp)
|
.heightIn(min = 48.dp)
|
||||||
.clip(MaterialTheme.shapes.large)
|
.clip(MaterialTheme.shapes.large)
|
||||||
.clickable(
|
.clickable(
|
||||||
|
enabled = currentDate != null,
|
||||||
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
||||||
role = Role.Button,
|
role = Role.Button,
|
||||||
) { showDatePicker = true }
|
) { showDatePicker = true }
|
||||||
@@ -71,21 +81,38 @@ fun CalendarTitleButton(
|
|||||||
availableWidth = constraints.maxWidth,
|
availableWidth = constraints.maxWidth,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
// Cross-fades, so switching views or paging turns the title over
|
||||||
text = shown,
|
// rather than snapping it (#184).
|
||||||
style = style,
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
maxLines = 1,
|
AnimatedContent(
|
||||||
overflow = TextOverflow.Ellipsis,
|
targetState = shown,
|
||||||
|
transitionSpec = {
|
||||||
|
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) { text ->
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = style,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = currentDate != null,
|
||||||
|
enter = fadeIn(rememberCalendarFadeSpec()) + expandHorizontally(),
|
||||||
|
exit = fadeOut(rememberCalendarFadeSpec()) + shrinkHorizontally(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ArrowDropDown,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDatePicker) {
|
if (showDatePicker && currentDate != null) {
|
||||||
CalendarDatePickerDialog(
|
CalendarDatePickerDialog(
|
||||||
initial = currentDate,
|
initial = currentDate,
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.ContentTransform
|
|
||||||
import androidx.compose.animation.SizeTransform
|
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -19,12 +13,12 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
* the reduce-motion check — now live in floret-kit's identity module
|
* the reduce-motion check — now live in floret-kit's identity module
|
||||||
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
||||||
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
||||||
* here is only what's specific to paging the calendar grid: the directional
|
* here is only what's specific to paging the calendar grid: the specs its
|
||||||
* month/week/day slide and the specs that feed it.
|
* pagers settle with, and that the view switch reuses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The M3 Expressive spatial spring used for the month/week/day slide: the
|
* The M3 Expressive spatial spring the calendar's motion is built on: the
|
||||||
* *default* spring-physics spec from the active motion scheme, rather than a
|
* *default* spring-physics spec from the active motion scheme, rather than a
|
||||||
* fixed easing curve.
|
* fixed easing curve.
|
||||||
*
|
*
|
||||||
@@ -40,6 +34,12 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
|
/** The same spring as [rememberCalendarSlideSpec], for a pager settling onto its page. */
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
|
||||||
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The effects spec from the active motion scheme, for the opacity half of the
|
* The effects spec from the active motion scheme, for the opacity half of the
|
||||||
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||||
@@ -55,48 +55,17 @@ fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
|||||||
MaterialTheme.motionScheme.defaultEffectsSpec()
|
MaterialTheme.motionScheme.defaultEffectsSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
|
* A switch between views (#184): the default spatial spring, a little softer, as
|
||||||
* outgoing page slides and fades one way while the incoming one arrives from the
|
* the whole screen reshapes rather than a page sliding by; softer still when
|
||||||
* other, position on a spring and opacity on an easing curve.
|
* [long], for a change the eye has further to follow.
|
||||||
*
|
|
||||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
|
||||||
* (e.g. "today"); a jump reuses the forward direction.
|
|
||||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
|
||||||
* @param fadeSpec effects spec for the opacity half, and for the whole
|
|
||||||
* transition under reduced motion; typically
|
|
||||||
* [rememberCalendarFadeSpec].
|
|
||||||
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
|
||||||
*/
|
*/
|
||||||
fun calendarSlideTransition(
|
@Composable
|
||||||
slideDir: Int,
|
fun <T> rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec<T> =
|
||||||
spec: FiniteAnimationSpec<IntOffset>,
|
spring(
|
||||||
fadeSpec: FiniteAnimationSpec<Float>,
|
dampingRatio = VIEW_SWITCH_DAMPING,
|
||||||
reduceMotion: Boolean,
|
stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS,
|
||||||
): ContentTransform {
|
|
||||||
if (reduceMotion) {
|
|
||||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
|
||||||
}
|
|
||||||
val dir = if (slideDir == 0) 1 else slideDir
|
|
||||||
return ContentTransform(
|
|
||||||
targetContentEnter =
|
|
||||||
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
|
|
||||||
initialContentExit =
|
|
||||||
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
|
||||||
// AnimatedContent clips to the animating container by default, which
|
|
||||||
// shears the pages against the viewport edge as they pass. There is no
|
|
||||||
// size change here to contain — both pages are the same grid.
|
|
||||||
sizeTransform = SizeTransform(clip = false),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
private const val VIEW_SWITCH_DAMPING = 0.9f
|
||||||
* How far a page travels, as a fraction of the container width.
|
private const val VIEW_SWITCH_STIFFNESS = 450f
|
||||||
*
|
private const val LONG_VIEW_SWITCH_STIFFNESS = 340f
|
||||||
* A full width was the obvious reading of "paging", but the two pages are
|
|
||||||
* stacked and both opaque, so a full-width slide showed one grid racing across
|
|
||||||
* another — the movement carried the whole transition and had a long way to go.
|
|
||||||
* Under M3's shared-axis pattern the offset only has to *hint* the direction
|
|
||||||
* while the cross-fade does the swapping, so a fifth of the width is plenty and
|
|
||||||
* leaves nothing skating past.
|
|
||||||
*/
|
|
||||||
private const val SLIDE_TRAVEL_DIVISOR = 5
|
|
||||||
|
|||||||
@@ -1,37 +1,53 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CalendarViewDay
|
import androidx.compose.material.icons.filled.CalendarViewDay
|
||||||
import androidx.compose.material.icons.filled.CalendarViewMonth
|
import androidx.compose.material.icons.filled.CalendarViewMonth
|
||||||
import androidx.compose.material.icons.filled.CalendarViewWeek
|
import androidx.compose.material.icons.filled.CalendarViewWeek
|
||||||
import androidx.compose.material.icons.filled.ViewAgenda
|
import androidx.compose.material.icons.filled.ViewAgenda
|
||||||
|
import androidx.compose.material.icons.filled.ViewColumn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
|
||||||
/** The top-level calendar views the user can switch between (spec M1). */
|
/** The top-level calendar views the user can switch between (spec M1). */
|
||||||
enum class CalendarView {
|
enum class CalendarView {
|
||||||
Month,
|
Month,
|
||||||
Week,
|
Week,
|
||||||
|
MultiDay,
|
||||||
Day,
|
Day,
|
||||||
Agenda,
|
Agenda,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
|
/** How many days the multi-day view shows (#193), provided once at the app root. */
|
||||||
@get:StringRes
|
val LocalMultiDayCount = staticCompositionLocalOf { SettingsPrefs.DEFAULT_MULTI_DAY_COUNT }
|
||||||
val CalendarView.labelRes: Int
|
|
||||||
get() = when (this) {
|
/**
|
||||||
CalendarView.Month -> R.string.view_month
|
* The view's name, shared by the top-bar pill, the drawer and Settings. The
|
||||||
CalendarView.Week -> R.string.view_week
|
* multi-day view is named after its day count ("3 days").
|
||||||
CalendarView.Day -> R.string.view_day
|
*/
|
||||||
CalendarView.Agenda -> R.string.view_agenda
|
@Composable
|
||||||
|
fun CalendarView.label(): String = when (this) {
|
||||||
|
CalendarView.Month -> stringResource(R.string.view_month)
|
||||||
|
CalendarView.Week -> stringResource(R.string.view_week)
|
||||||
|
CalendarView.MultiDay -> {
|
||||||
|
val count = LocalMultiDayCount.current
|
||||||
|
pluralStringResource(R.plurals.view_multi_day, count, count)
|
||||||
}
|
}
|
||||||
|
CalendarView.Day -> stringResource(R.string.view_day)
|
||||||
|
CalendarView.Agenda -> stringResource(R.string.view_agenda)
|
||||||
|
}
|
||||||
|
|
||||||
/** Leading icon for the view in the drawer's View section. */
|
/** Leading icon for the view in the drawer's View section. */
|
||||||
val CalendarView.icon: ImageVector
|
val CalendarView.icon: ImageVector
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
||||||
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
||||||
|
CalendarView.MultiDay -> Icons.Filled.ViewColumn
|
||||||
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
||||||
CalendarView.Agenda -> Icons.Filled.ViewAgenda
|
CalendarView.Agenda -> Icons.Filled.ViewAgenda
|
||||||
}
|
}
|
||||||
@@ -41,7 +57,13 @@ val CalendarView.icon: ImageVector
|
|||||||
* through these in order.
|
* through these in order.
|
||||||
*/
|
*/
|
||||||
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||||
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
|
listOf(
|
||||||
|
CalendarView.Month,
|
||||||
|
CalendarView.Week,
|
||||||
|
CalendarView.MultiDay,
|
||||||
|
CalendarView.Day,
|
||||||
|
CalendarView.Agenda,
|
||||||
|
)
|
||||||
|
|
||||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||||
@@ -107,3 +129,16 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
|
|||||||
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
||||||
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||||
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
||||||
|
|
||||||
|
/** The screens the views are drawn by: the day, multi-day and week views share one timeline (#184). */
|
||||||
|
enum class ViewScreen { Month, Timeline, Agenda }
|
||||||
|
|
||||||
|
val CalendarView.screen: ViewScreen
|
||||||
|
get() = when (this) {
|
||||||
|
CalendarView.Month -> ViewScreen.Month
|
||||||
|
CalendarView.Agenda -> ViewScreen.Agenda
|
||||||
|
CalendarView.Week, CalendarView.MultiDay, CalendarView.Day -> ViewScreen.Timeline
|
||||||
|
}
|
||||||
|
/** Push the week view over the month after a week-number tap. */
|
||||||
|
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
|
||||||
|
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
@@ -65,11 +67,14 @@ fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
|
|||||||
/**
|
/**
|
||||||
* Long-press to copy [text] whole, filed on the clipboard under [label].
|
* Long-press to copy [text] whole, filed on the clipboard under [label].
|
||||||
*
|
*
|
||||||
* [onTap] carries a field's existing tap action through; a field without one
|
* [onTap] carries a field's existing tap action through, announced as
|
||||||
* stays unclickable rather than growing a ripple that leads nowhere, and gets
|
* [onTapLabel]; a field without one stays unclickable rather than growing a
|
||||||
* its long press announced through semantics instead. That branch merges the
|
* ripple that leads nowhere. The long press is announced through semantics,
|
||||||
* node it sits on, so a screen reader lands on the field itself and finds the
|
* merging the node it sits on, so a screen reader lands on the field itself
|
||||||
* action there — a bare container is never focused.
|
* and finds the action there — a bare container is never focused.
|
||||||
|
*
|
||||||
|
* The press is caught ahead of the field's children, so a long press on a
|
||||||
|
* link inside the text copies too instead of leaving that stretch dead.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.copyOnLongPress(
|
fun Modifier.copyOnLongPress(
|
||||||
@@ -77,28 +82,48 @@ fun Modifier.copyOnLongPress(
|
|||||||
text: String,
|
text: String,
|
||||||
copy: FieldCopier,
|
copy: FieldCopier,
|
||||||
onTap: (() -> Unit)? = null,
|
onTap: (() -> Unit)? = null,
|
||||||
|
onTapLabel: String? = null,
|
||||||
): Modifier {
|
): Modifier {
|
||||||
val actionLabel = stringResource(R.string.field_copy_action)
|
val actionLabel = stringResource(R.string.field_copy_action)
|
||||||
return if (onTap != null) {
|
val haptics = LocalHapticFeedback.current
|
||||||
combinedClickable(
|
return longPressAheadOfChildren(label, text, copy) {
|
||||||
onClick = onTap,
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
onLongClickLabel = actionLabel,
|
copy(label, text)
|
||||||
onLongClick = { copy(label, text) },
|
}
|
||||||
)
|
.then(if (onTap != null) Modifier.clickable(onClickLabel = onTapLabel, onClick = onTap) else Modifier)
|
||||||
} else {
|
.semantics(mergeDescendants = true) {
|
||||||
val haptics = LocalHapticFeedback.current
|
|
||||||
pointerInput(label, text, copy) {
|
|
||||||
detectTapGestures(
|
|
||||||
onLongPress = {
|
|
||||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
||||||
copy(label, text)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}.semantics(mergeDescendants = true) {
|
|
||||||
onLongClick(actionLabel) {
|
onLongClick(actionLabel) {
|
||||||
copy(label, text)
|
copy(label, text)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run [onLongPress] once a press has held still for the long-press timeout,
|
||||||
|
* watching on the initial pass — before the children see the events — and
|
||||||
|
* then swallow the rest of that press, so neither a link under the finger nor
|
||||||
|
* a tap handler fires on release.
|
||||||
|
*/
|
||||||
|
private fun Modifier.longPressAheadOfChildren(vararg keys: Any?, onLongPress: () -> Unit): Modifier =
|
||||||
|
pointerInput(*keys) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||||
|
val endedEarly = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
|
||||||
|
while (true) {
|
||||||
|
val change = awaitPointerEvent(PointerEventPass.Initial).changes
|
||||||
|
.firstOrNull { it.id == down.id }
|
||||||
|
val moved = change != null &&
|
||||||
|
(change.position - down.position).getDistance() > viewConfiguration.touchSlop
|
||||||
|
if (change == null || !change.pressed || moved) break
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
if (endedEarly != null) return@awaitEachGesture
|
||||||
|
onLongPress()
|
||||||
|
do {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
} while (event.changes.any { it.pressed })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
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 de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
|
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.android.hasPermission
|
||||||
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a [tryDeleteEvent] ended. */
|
||||||
|
enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed }
|
||||||
|
|
||||||
|
/** Whether this install may write to the calendar; v1.0 installs only hold read access. */
|
||||||
|
fun Context.canWriteCalendar(): Boolean =
|
||||||
|
hasPermission(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
|
||||||
|
/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */
|
||||||
|
suspend fun CalendarRepository.tryDeleteEvent(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
scope: RecurringWriteScope,
|
||||||
|
): DeleteOutcome = try {
|
||||||
|
deleteEvent(eventId, beginMillis, scope)
|
||||||
|
DeleteOutcome.Deleted
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
DeleteOutcome.NeedsPermission
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
DeleteOutcome.Gone
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("EventActions", "Delete failed (scope=$scope, eventId=$eventId)", e)
|
||||||
|
DeleteOutcome.Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 gate = rememberWriteGateOr()
|
||||||
|
return { action -> gate(action) {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [rememberWriteGate], running the fallback instead when the user refuses. */
|
||||||
|
@Composable
|
||||||
|
fun rememberWriteGateOr(): (action: () -> Unit, fallback: () -> Unit) -> Unit {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var pending by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) }
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
pending?.let { (action, fallback) -> if (granted) action() else fallback() }
|
||||||
|
pending = null
|
||||||
|
}
|
||||||
|
return { action, fallback ->
|
||||||
|
if (context.canWriteCalendar()) {
|
||||||
|
action()
|
||||||
|
} else {
|
||||||
|
pending = action to fallback
|
||||||
|
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,137 @@
|
|||||||
|
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.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.atOccurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
|
||||||
|
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||||
|
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
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)
|
||||||
|
.stateWhileSubscribed(
|
||||||
|
scope = viewModelScope,
|
||||||
|
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 = when (repository.tryDeleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope)) {
|
||||||
|
DeleteOutcome.Deleted -> null
|
||||||
|
DeleteOutcome.Gone -> EventActionFailure.Gone
|
||||||
|
DeleteOutcome.NeedsPermission -> EventActionFailure.WriteDenied
|
||||||
|
DeleteOutcome.Failed -> 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether [calendarId] takes writes, asking the provider while [calendars] hasn't loaded yet. */
|
||||||
|
suspend fun canModify(calendarId: Long): Boolean {
|
||||||
|
val calendar = calendars.value[calendarId] ?: try {
|
||||||
|
repository.calendars().first().firstOrNull { it.id == calendarId }
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
return calendar?.canModifyContents == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun load(event: EventInstance): EventDetail? = try {
|
||||||
|
// The row holds the series start; the actions work on this occurrence.
|
||||||
|
repository.eventDetail(event.eventId)
|
||||||
|
.atOccurrence(event.start.toEpochMilliseconds(), event.end.toEpochMilliseconds())
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
_failure.value = EventActionFailure.Gone
|
||||||
|
null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_failure.value = EventActionFailure.LoadFailed
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,38 +24,57 @@ import androidx.compose.ui.unit.dp
|
|||||||
/** How far the finger may wander during the hold — well under touch slop. */
|
/** How far the finger may wander during the hold — well under touch slop. */
|
||||||
private val PICKUP_TOLERANCE = 6.dp
|
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
|
* Pick an event block up with a long press and drag it, without
|
||||||
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
|
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
|
||||||
* ancestor consumes or the finger leaves the block, which a
|
* ancestor consumes or the finger leaves the block, which a
|
||||||
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
|
* `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.
|
* 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
|
@Composable
|
||||||
fun rememberEventDragSource(
|
fun rememberEventDragSource(
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
key: Any?,
|
key: Any?,
|
||||||
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
|
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> PickUp,
|
||||||
onMove: (pointerInRoot: Offset) -> Unit,
|
onMove: (pointerInRoot: Offset) -> Unit,
|
||||||
onDrop: () -> Unit,
|
onDrop: () -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
): Modifier = rememberDragSurface(
|
onRelease: (() -> Unit)? = null,
|
||||||
|
immediate: Boolean = false,
|
||||||
|
): Modifier = rememberPickUpSurface(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
key = key,
|
key = key,
|
||||||
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
|
immediate = immediate,
|
||||||
onPickUp(pointerInRoot, nodeInRoot, size)
|
onPickUp = { _, pointerInRoot, nodeInRoot, size -> onPickUp(pointerInRoot, nodeInRoot, size) },
|
||||||
true
|
|
||||||
},
|
|
||||||
onMove = onMove,
|
onMove = onMove,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
onCancel = onCancel,
|
onCancel = onCancel,
|
||||||
|
onRelease = onRelease,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The same pickup, for a surface that carries many draggable pieces rather than
|
* The same pickup, for a surface that carries many draggable pieces rather than
|
||||||
* being one itself — the month grid, whose chips are covered by a full-bleed tap
|
* being one itself — the month grid, whose chips are covered by a full-bleed tap
|
||||||
* layer. [onPickUp] receives the press position local to this node and answers
|
* layer. [onPickUp] receives the press position local to this node and answers
|
||||||
* whether anything is there; false abandons the gesture.
|
* whether anything is there; false abandons the gesture. [skipClaimedDown] leaves
|
||||||
|
* a press a descendant already consumed alone.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberDragSurface(
|
fun rememberDragSurface(
|
||||||
@@ -65,6 +84,33 @@ fun rememberDragSurface(
|
|||||||
onMove: (pointerInRoot: Offset) -> Unit,
|
onMove: (pointerInRoot: Offset) -> Unit,
|
||||||
onDrop: () -> Unit,
|
onDrop: () -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
|
onRelease: (() -> Unit)? = null,
|
||||||
|
skipClaimedDown: Boolean = false,
|
||||||
|
): 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 = onRelease,
|
||||||
|
skipClaimedDown = skipClaimedDown,
|
||||||
|
)
|
||||||
|
|
||||||
|
@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)?,
|
||||||
|
skipClaimedDown: Boolean = false,
|
||||||
): Modifier {
|
): Modifier {
|
||||||
if (!enabled) return Modifier
|
if (!enabled) return Modifier
|
||||||
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||||
@@ -74,27 +120,44 @@ fun rememberDragSurface(
|
|||||||
val currentMove by rememberUpdatedState(onMove)
|
val currentMove by rememberUpdatedState(onMove)
|
||||||
val currentDrop by rememberUpdatedState(onDrop)
|
val currentDrop by rememberUpdatedState(onDrop)
|
||||||
val currentCancel by rememberUpdatedState(onCancel)
|
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
|
return Modifier
|
||||||
.onGloballyPositioned { coordinates[0] = it }
|
.onGloballyPositioned { coordinates[0] = it }
|
||||||
.pointerInput(key) {
|
.pointerInput(key) {
|
||||||
|
val slop = viewConfiguration.touchSlop
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
// A child that took the press, such as a resize handle, keeps it.
|
||||||
|
if (skipClaimedDown && down.isConsumed) return@awaitEachGesture
|
||||||
val origin = down.position
|
val origin = down.position
|
||||||
val heldStill = withTimeoutOrNull(holdMillis) {
|
val immediate = currentImmediate
|
||||||
while (true) {
|
// 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 main = awaitPointerEvent()
|
||||||
val change = main.changes.firstOrNull { it.id == down.id } ?: break
|
val change = main.changes.firstOrNull { it.id == down.id }
|
||||||
if (!change.pressed) break
|
val distance = change?.let { (it.position - origin).getDistance() } ?: 0f
|
||||||
if (main.changes.count { it.pressed } > 1) break
|
result = when {
|
||||||
if ((change.position - origin).getDistance() > tolerance) break
|
change == null || !change.pressed -> false
|
||||||
// An ancestor's claim only becomes visible once the whole
|
main.changes.count { it.pressed } > 1 -> false
|
||||||
// main pass has run, so look again on the final pass.
|
immediate && distance > slop && !change.isConsumed -> {
|
||||||
val final = awaitPointerEvent(PointerEventPass.Final)
|
change.consume()
|
||||||
if (final.changes.any { it.isConsumed }) break
|
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
|
result
|
||||||
if (!heldStill) return@awaitEachGesture
|
}
|
||||||
|
if (outcome == false) return@awaitEachGesture
|
||||||
|
|
||||||
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
||||||
val took = currentPickUp(
|
val took = currentPickUp(
|
||||||
@@ -103,8 +166,11 @@ fun rememberDragSurface(
|
|||||||
layout.positionInRoot(),
|
layout.positionInRoot(),
|
||||||
layout.size,
|
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 dropped = false
|
||||||
|
var released = false
|
||||||
try {
|
try {
|
||||||
// Driven on the initial pass, which runs parent → child: an
|
// Driven on the initial pass, which runs parent → child: an
|
||||||
// ancestor that outranks us (the pinch) has already consumed
|
// ancestor that outranks us (the pinch) has already consumed
|
||||||
@@ -116,28 +182,51 @@ fun rememberDragSurface(
|
|||||||
if (change.isConsumed) break
|
if (change.isConsumed) break
|
||||||
if (!change.pressed) {
|
if (!change.pressed) {
|
||||||
change.consume()
|
change.consume()
|
||||||
dropped = true
|
if (took == PickUp.Hold || (!moved && currentRelease != null)) {
|
||||||
|
released = true
|
||||||
|
} else {
|
||||||
|
dropped = true
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (event.changes.count { it.pressed } > 1) break
|
if (event.changes.count { it.pressed } > 1) break
|
||||||
change.consume()
|
change.consume()
|
||||||
|
if (took == PickUp.Hold) continue
|
||||||
|
if ((change.position - origin).getDistance() > slop) moved = true
|
||||||
coordinates[0]?.takeIf { it.isAttached }
|
coordinates[0]?.takeIf { it.isAttached }
|
||||||
?.let { currentMove(it.localToRoot(change.position)) }
|
?.let { currentMove(it.localToRoot(change.position)) }
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Also reached when the pointer node is disposed mid-drag —
|
// Also reached when the pointer node is disposed mid-drag —
|
||||||
// a cancel, not a drop, and must never write.
|
// 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */
|
/**
|
||||||
|
* A lift on pickup, a tick every time the drop target snaps to a new slot, and a
|
||||||
|
* confirm each time [drops] counts a real drop.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DragSnapHaptics(slot: Any?) {
|
fun DragSnapHaptics(slot: Any?, drops: Int) {
|
||||||
val haptics = LocalHapticFeedback.current
|
val haptics = LocalHapticFeedback.current
|
||||||
val previous = remember { arrayOfNulls<Any>(1) }
|
val previous = remember { arrayOfNulls<Any>(1) }
|
||||||
|
val dropsSeen = remember { intArrayOf(drops) }
|
||||||
|
LaunchedEffect(drops) {
|
||||||
|
if (drops != dropsSeen[0]) {
|
||||||
|
dropsSeen[0] = drops
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||||
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(slot) {
|
LaunchedEffect(slot) {
|
||||||
val had = previous[0]
|
val had = previous[0]
|
||||||
previous[0] = slot
|
previous[0] = slot
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ private val FAB_BAND = 88.dp
|
|||||||
* a pill on the FAB's band, leaving the calendar visible behind it.
|
* a pill on the FAB's band, leaving the calendar visible behind it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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 prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
||||||
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||||
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
||||||
@@ -58,7 +63,9 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
|
|||||||
|
|
||||||
prompt?.let { pending ->
|
prompt?.let { pending ->
|
||||||
RecurringScopeDialog(
|
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,
|
onSelect = viewModel::moveWithScope,
|
||||||
onDismiss = viewModel::cancelScope,
|
onDismiss = viewModel::cancelScope,
|
||||||
allowSeries = !pending.occurrenceOnly,
|
allowSeries = !pending.occurrenceOnly,
|
||||||
@@ -69,9 +76,17 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
|
|||||||
|
|
||||||
val moved = outcome as? MoveOutcome.Moved
|
val moved = outcome as? MoveOutcome.Moved
|
||||||
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
|
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) {
|
val message = when (outcome) {
|
||||||
null -> null
|
null -> actionFailure
|
||||||
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
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.Undone -> stringResource(R.string.event_move_undone)
|
||||||
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||||
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
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)
|
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
|
||||||
viewModel.consumeOutcome()
|
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()) {
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
val chipMaxWidth = maxWidth - FAB_BAND
|
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.
|
// write anyway, so offering it would be a button that does nothing.
|
||||||
val undo = content.undo?.takeIf { !writeInFlight }
|
val undo = content.undo?.takeIf { !writeInFlight }
|
||||||
SnackChip(
|
SnackChip(
|
||||||
visible = outcome != null,
|
visible = outcome != null || actionFailure != null,
|
||||||
message = content.message,
|
message = content.message,
|
||||||
maxWidth = chipMaxWidth,
|
maxWidth = chipMaxWidth,
|
||||||
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
||||||
@@ -142,3 +163,7 @@ private fun formatMovedTo(
|
|||||||
return localizedDateFormatter(locale, skeleton)
|
return localizedDateFormatter(locale, skeleton)
|
||||||
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
|
.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)
|
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
|
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
|
||||||
* pointer-only. Null when this event can't be moved.
|
* pointer-only. Null when this event can't be moved.
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A drafted block's bar (#153): create the event, or let the draft go. */
|
||||||
|
@Composable
|
||||||
|
fun DraftSelectionBar(onCreate: () -> Unit, onDiscard: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
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),
|
||||||
|
) {
|
||||||
|
BarAction(Icons.Default.Close, R.string.event_draft_discard, onDiscard)
|
||||||
|
BarAction(Icons.Default.Check, R.string.event_draft_create, onCreate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) {
|
||||||
|
IconButton(onClick = onClick) {
|
||||||
|
Icon(imageVector = icon, contentDescription = stringResource(label))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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.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
|
||||||
|
selection.create?.let { create ->
|
||||||
|
return remember(selection, expanded, chipShown) {
|
||||||
|
FabSelection(
|
||||||
|
expanded = expanded,
|
||||||
|
onToggle = {
|
||||||
|
if (chipShown) currentDismissChip()
|
||||||
|
chosen = !expanded
|
||||||
|
},
|
||||||
|
bar = {
|
||||||
|
DraftSelectionBar(
|
||||||
|
onCreate = { currentWrite { create() } },
|
||||||
|
onDiscard = selection.dismiss,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,11 @@ internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Fl
|
|||||||
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
|
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
|
||||||
* height; [color] is resolved by the caller from the theme.
|
* height; [color] is resolved by the caller from the theme.
|
||||||
*/
|
*/
|
||||||
fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
|
fun Modifier.hourGridCells(
|
||||||
|
show: Boolean,
|
||||||
|
hourHeightPx: Float,
|
||||||
|
color: Color,
|
||||||
|
): Modifier =
|
||||||
if (!show) {
|
if (!show) {
|
||||||
this
|
this
|
||||||
} else {
|
} else {
|
||||||
@@ -71,10 +75,11 @@ fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Mo
|
|||||||
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
|
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
|
||||||
)
|
)
|
||||||
val cellSize = Size(size.width, cellHeight)
|
val cellSize = Size(size.width, cellHeight)
|
||||||
|
val top = inset
|
||||||
for (hour in 0 until 24) {
|
for (hour in 0 until 24) {
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = color,
|
color = color,
|
||||||
topLeft = Offset(0f, hour * hourHeightPx + inset),
|
topLeft = Offset(0f, hour * hourHeightPx + top),
|
||||||
size = cellSize,
|
size = cellSize,
|
||||||
cornerRadius = radius,
|
cornerRadius = radius,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,12 +19,15 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
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
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
||||||
@@ -67,7 +70,7 @@ fun HourGutter(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
// Derived: the drag is rewritten every frame, its snapped start once a slot.
|
// Derived: the drag is rewritten every frame, its snapped start once a slot.
|
||||||
val dragStartMin by remember(dragController) {
|
val dragStartMin by remember(dragController) {
|
||||||
derivedStateOf { dragController.drag?.startMin }
|
derivedStateOf { dragController.drag?.markerMin }
|
||||||
}
|
}
|
||||||
val hourAlpha by animateFloatAsState(
|
val hourAlpha by animateFloatAsState(
|
||||||
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
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)
|
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.offset(y = top)
|
.offset(y = top)
|
||||||
|
.graphicsLayer {
|
||||||
|
alpha = badgeIn
|
||||||
|
scaleX = 0.8f + 0.2f * badgeIn
|
||||||
|
scaleY = 0.8f + 0.2f * badgeIn
|
||||||
|
}
|
||||||
.height(BADGE_HEIGHT)
|
.height(BADGE_HEIGHT)
|
||||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
@@ -121,7 +137,8 @@ fun HourGutter(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.onClick
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** Thickness of the out-of-view edge bar. */
|
||||||
|
private val EDGE_BAR_THICKNESS = 3.dp
|
||||||
|
|
||||||
|
/** Band the bar sits in, and the room a revealed event keeps from the edge. */
|
||||||
|
private val EDGE_TOUCH_HEIGHT = 24.dp
|
||||||
|
|
||||||
|
/** How far from the viewport's edge a tap on empty column still hits the bar. */
|
||||||
|
private val EDGE_TAP_REACH = 16.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the bar sits in from the viewport's edges — enough to clear the
|
||||||
|
* timeline's rounded 16dp corners on the outer columns.
|
||||||
|
*/
|
||||||
|
private val EDGE_BAR_VERTICAL_INSET = 4.dp
|
||||||
|
private val EDGE_BAR_HORIZONTAL_INSET = 6.dp
|
||||||
|
|
||||||
|
/** Distance outside the viewport over which a bar fades before its event appears. */
|
||||||
|
private val EDGE_FADE_LEAD = 24.dp
|
||||||
|
|
||||||
|
/** The nearest timed blocks wholly above and below a column's viewport. */
|
||||||
|
@Immutable
|
||||||
|
data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) {
|
||||||
|
companion object {
|
||||||
|
val None = OutOfViewEvents(null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which edge of the viewport a hidden event sits past. */
|
||||||
|
enum class ViewportEdge { Top, Bottom }
|
||||||
|
|
||||||
|
/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */
|
||||||
|
internal fun renderedEndMin(block: TimedBlock): Float =
|
||||||
|
maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin]
|
||||||
|
* (minutes of the day), nearest first. A block leaves this set as soon as any
|
||||||
|
* part of its rendered height enters the viewport.
|
||||||
|
*/
|
||||||
|
fun outOfViewEvents(
|
||||||
|
blocks: List<TimedBlock>,
|
||||||
|
topMin: Float,
|
||||||
|
bottomMin: Float,
|
||||||
|
): OutOfViewEvents {
|
||||||
|
val above = blocks
|
||||||
|
.filter { renderedEndMin(it) <= topMin }
|
||||||
|
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
|
||||||
|
val below = blocks
|
||||||
|
.filter { it.startMin >= bottomMin }
|
||||||
|
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
|
||||||
|
return OutOfViewEvents(above, below)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scroll offset that brings [block] into a [viewportPx]-tall viewport from
|
||||||
|
* past [edge], with [marginPx] of room so it clears the edge bar. An event above
|
||||||
|
* lands with its start at the top; one below lands whole at the bottom when it
|
||||||
|
* fits, and with its start at the top when it does not.
|
||||||
|
*/
|
||||||
|
fun revealScrollTarget(
|
||||||
|
block: TimedBlock,
|
||||||
|
edge: ViewportEdge,
|
||||||
|
hourPx: Float,
|
||||||
|
viewportPx: Int,
|
||||||
|
marginPx: Float,
|
||||||
|
maxScroll: Int,
|
||||||
|
): Int {
|
||||||
|
val startPx = block.startMin / 60f * hourPx
|
||||||
|
val endPx = renderedEndMin(block) / 60f * hourPx
|
||||||
|
val target = when (edge) {
|
||||||
|
ViewportEdge.Top -> startPx - marginPx
|
||||||
|
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
|
||||||
|
}
|
||||||
|
return target.roundToInt().coerceIn(0, maxOf(maxScroll, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The edge whose bar a tap at [y] (content px) lands on, if that bar is showing
|
||||||
|
* and the tap is within [reachPx] of the viewport's edge.
|
||||||
|
*/
|
||||||
|
fun tappedEdge(
|
||||||
|
events: OutOfViewEvents,
|
||||||
|
y: Float,
|
||||||
|
viewportTop: Float,
|
||||||
|
viewportPx: Int,
|
||||||
|
reachPx: Float,
|
||||||
|
): ViewportEdge? = when {
|
||||||
|
events.above != null && y - viewportTop <= reachPx -> ViewportEdge.Top
|
||||||
|
events.below != null && viewportTop + viewportPx - y <= reachPx -> ViewportEdge.Bottom
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fade while the event is still outside; never draw its bar at the boundary. */
|
||||||
|
internal fun edgeBarAlpha(gapPx: Float, fadeLeadPx: Float): Float = when {
|
||||||
|
gapPx <= 0f -> 0f
|
||||||
|
fadeLeadPx <= 0f -> 1f
|
||||||
|
else -> (gapPx / fadeLeadPx).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The out-of-view state of one day column (#188): which events hide past its
|
||||||
|
* edges, and the scroll that reveals them.
|
||||||
|
*
|
||||||
|
* The bars take no touches themselves, so event blocks under them keep their
|
||||||
|
* taps and drags; the column's own tap handler offers taps to [tap] first.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class OutOfViewEdges internal constructor(
|
||||||
|
private val scrollState: ScrollState,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
internal var blocks by mutableStateOf(emptyList<TimedBlock>())
|
||||||
|
internal var hourPx by mutableFloatStateOf(0f)
|
||||||
|
internal var fadeLeadPx by mutableFloatStateOf(0f)
|
||||||
|
internal var marginPx by mutableFloatStateOf(0f)
|
||||||
|
internal var reachPx by mutableFloatStateOf(0f)
|
||||||
|
internal var reduceMotion = false
|
||||||
|
internal var revealSpec: FiniteAnimationSpec<Float>? = null
|
||||||
|
|
||||||
|
val events: OutOfViewEvents by derivedStateOf {
|
||||||
|
val viewport = scrollState.viewportSize
|
||||||
|
if (viewport <= 0 || hourPx <= 0f) {
|
||||||
|
OutOfViewEvents.None
|
||||||
|
} else {
|
||||||
|
val top = scrollState.value.toFloat()
|
||||||
|
outOfViewEvents(
|
||||||
|
blocks = blocks,
|
||||||
|
topMin = top / hourPx * 60f,
|
||||||
|
bottomMin = (top + viewport) / hourPx * 60f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scrolls the event hidden past [edge] into view, if there is one. */
|
||||||
|
fun reveal(edge: ViewportEdge) {
|
||||||
|
val block = if (edge == ViewportEdge.Top) events.above else events.below
|
||||||
|
block ?: return
|
||||||
|
val target = revealScrollTarget(
|
||||||
|
block = block,
|
||||||
|
edge = edge,
|
||||||
|
hourPx = hourPx,
|
||||||
|
viewportPx = scrollState.viewportSize,
|
||||||
|
marginPx = marginPx,
|
||||||
|
maxScroll = scrollState.maxValue,
|
||||||
|
)
|
||||||
|
val spec = revealSpec
|
||||||
|
scope.launch {
|
||||||
|
if (reduceMotion || spec == null) {
|
||||||
|
scrollState.scrollTo(target)
|
||||||
|
} else {
|
||||||
|
scrollState.animateScrollTo(target, spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a press at [y] (content px) lands on an edge bar (#153). */
|
||||||
|
fun covers(y: Float): Boolean = edgeAt(y) != null
|
||||||
|
|
||||||
|
private fun edgeAt(y: Float) = tappedEdge(
|
||||||
|
events = events,
|
||||||
|
y = y,
|
||||||
|
viewportTop = scrollState.value.toFloat(),
|
||||||
|
viewportPx = scrollState.viewportSize,
|
||||||
|
reachPx = reachPx,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
|
||||||
|
fun tap(y: Float): Boolean {
|
||||||
|
val edge = edgeAt(y) ?: return false
|
||||||
|
reveal(edge)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberOutOfViewEdges(
|
||||||
|
blocks: List<TimedBlock>,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
hourPx: Float,
|
||||||
|
): OutOfViewEdges {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val edges = remember(scrollState, scope) { OutOfViewEdges(scrollState, scope) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val revealSpec = rememberCalendarPageSpec()
|
||||||
|
SideEffect {
|
||||||
|
edges.blocks = blocks
|
||||||
|
edges.hourPx = hourPx
|
||||||
|
edges.fadeLeadPx = with(density) { EDGE_FADE_LEAD.toPx() }
|
||||||
|
edges.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() }
|
||||||
|
edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() }
|
||||||
|
edges.reduceMotion = reduceMotion
|
||||||
|
edges.revealSpec = revealSpec
|
||||||
|
}
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin bars at the top and bottom of a day column's visible viewport, tinted in
|
||||||
|
* the colour of the nearest timed event hidden past that edge (#188).
|
||||||
|
*
|
||||||
|
* Lives inside the column's scrolling content and follows the viewport by
|
||||||
|
* offsetting against [scrollState] at placement rather than recomposing every
|
||||||
|
* frame. Opacity follows the gap to the hidden event and reaches zero before
|
||||||
|
* the event enters view. Taps reach it through [OutOfViewEdges.tap]; screen
|
||||||
|
* readers get a button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BoxScope.OutOfViewEdgeBars(
|
||||||
|
edges: OutOfViewEdges,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
dark: Boolean,
|
||||||
|
) {
|
||||||
|
val touchPx = with(LocalDensity.current) { EDGE_TOUCH_HEIGHT.roundToPx() }
|
||||||
|
val events = edges.events
|
||||||
|
EdgeBar(
|
||||||
|
block = events.above,
|
||||||
|
edge = ViewportEdge.Top,
|
||||||
|
dark = dark,
|
||||||
|
onReveal = edges::reveal,
|
||||||
|
scrollState = scrollState,
|
||||||
|
hourPx = edges.hourPx,
|
||||||
|
fadeLeadPx = edges.fadeLeadPx,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.offset { IntOffset(0, scrollState.value) },
|
||||||
|
)
|
||||||
|
EdgeBar(
|
||||||
|
block = events.below,
|
||||||
|
edge = ViewportEdge.Bottom,
|
||||||
|
dark = dark,
|
||||||
|
onReveal = edges::reveal,
|
||||||
|
scrollState = scrollState,
|
||||||
|
hourPx = edges.hourPx,
|
||||||
|
fadeLeadPx = edges.fadeLeadPx,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EdgeBar(
|
||||||
|
block: TimedBlock?,
|
||||||
|
edge: ViewportEdge,
|
||||||
|
dark: Boolean,
|
||||||
|
onReveal: (ViewportEdge) -> Unit,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
hourPx: Float,
|
||||||
|
fadeLeadPx: Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
block ?: return
|
||||||
|
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
|
||||||
|
val label = stringResource(
|
||||||
|
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(EDGE_TOUCH_HEIGHT)
|
||||||
|
.graphicsLayer {
|
||||||
|
val gapPx = when (edge) {
|
||||||
|
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
|
||||||
|
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
|
||||||
|
(scrollState.value + scrollState.viewportSize)
|
||||||
|
}
|
||||||
|
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
|
||||||
|
}
|
||||||
|
.semantics {
|
||||||
|
role = Role.Button
|
||||||
|
contentDescription = label
|
||||||
|
onClick {
|
||||||
|
onReveal(edge)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
horizontal = EDGE_BAR_HORIZONTAL_INSET,
|
||||||
|
vertical = EDGE_BAR_VERTICAL_INSET,
|
||||||
|
)
|
||||||
|
.height(EDGE_BAR_THICKNESS)
|
||||||
|
.background(color, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shared state per pager page — a week, a day, a month — so a page and the
|
||||||
|
* screen's anchor read the same query, and the pages either side stay loaded
|
||||||
|
* across a swipe.
|
||||||
|
*
|
||||||
|
* Every entry shares on its own job, cancelled when [keep] lets it go: a
|
||||||
|
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
|
||||||
|
* until the ViewModel itself was cleared. Main thread only.
|
||||||
|
*
|
||||||
|
* @param keep whether a cached key is still worth holding once [get] asks for another.
|
||||||
|
*/
|
||||||
|
class PageStateCache<K, S>(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val initial: S,
|
||||||
|
private val keep: (cached: K, requested: K) -> Boolean,
|
||||||
|
private val load: (K) -> Flow<S>,
|
||||||
|
) {
|
||||||
|
private class Entry<S>(val state: StateFlow<S>, val job: Job)
|
||||||
|
|
||||||
|
private val entries = HashMap<K, Entry<S>>()
|
||||||
|
|
||||||
|
/** The number of entries held, for tests. */
|
||||||
|
internal val size: Int get() = entries.size
|
||||||
|
|
||||||
|
fun get(key: K): StateFlow<S> {
|
||||||
|
val iterator = entries.iterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val (cached, entry) = iterator.next()
|
||||||
|
if (cached != key && !keep(cached, key)) {
|
||||||
|
entry.job.cancel()
|
||||||
|
iterator.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries.getOrPut(key) {
|
||||||
|
val job = SupervisorJob(scope.coroutineContext[Job])
|
||||||
|
Entry(
|
||||||
|
state = load(key).stateWhileSubscribed(scope + job, initial),
|
||||||
|
job = job,
|
||||||
|
)
|
||||||
|
}.state
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ import androidx.compose.ui.text.AnnotatedString
|
|||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
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
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +21,17 @@ import kotlin.time.Instant
|
|||||||
*/
|
*/
|
||||||
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
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. */
|
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||||
const val EventDimAlpha = 0.4f
|
const val EventDimAlpha = 0.4f
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -22,17 +16,16 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||||
|
import de.jeanlucmakiola.floret.components.CustomExpandRow
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
|
||||||
@@ -90,7 +83,7 @@ fun ReminderDefaultPicker(
|
|||||||
}
|
}
|
||||||
val inherits = current is ReminderOverride.Inherit
|
val inherits = current is ReminderOverride.Inherit
|
||||||
val isNone = current is ReminderOverride.None
|
val isNone = current is ReminderOverride.None
|
||||||
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
val selectedMinutes = current.minutesOrEmpty()
|
||||||
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
||||||
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
||||||
// which would strand a hand-entered value with no way to re-check it.
|
// which would strand a hand-entered value with no way to re-check it.
|
||||||
@@ -121,24 +114,14 @@ fun ReminderDefaultPicker(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.reminder_use_default),
|
title = stringResource(R.string.reminder_use_default),
|
||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
selected = inherits,
|
checked = inherits,
|
||||||
trailing = if (inherits) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { apply(ReminderOverride.Inherit) },
|
onClick = { apply(ReminderOverride.Inherit) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.reminder_none),
|
title = stringResource(R.string.reminder_none),
|
||||||
position = if (allowInherit) Position.Bottom else Position.Alone,
|
position = if (allowInherit) Position.Bottom else Position.Alone,
|
||||||
selected = isNone,
|
checked = isNone,
|
||||||
trailing = if (isNone) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { apply(ReminderOverride.None) },
|
onClick = { apply(ReminderOverride.None) },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
@@ -154,17 +137,11 @@ fun ReminderDefaultPicker(
|
|||||||
onClick = { toggle(minute) },
|
onClick = { toggle(minute) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// When expanded, the Custom row connects downward into the editor card
|
CustomExpandRow(
|
||||||
// so the two read as one grouped container (the per-calendar pattern).
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.event_edit_reminder_custom),
|
title = stringResource(R.string.event_edit_reminder_custom),
|
||||||
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
|
position = positionOf(rows.size, rowCount),
|
||||||
onClick = { customExpanded = !customExpanded },
|
expanded = customExpanded,
|
||||||
)
|
onExpandedChange = { customExpanded = it },
|
||||||
AnimatedVisibility(
|
|
||||||
visible = customExpanded,
|
|
||||||
enter = expandEnter(),
|
|
||||||
exit = collapseExit(),
|
|
||||||
) {
|
) {
|
||||||
CustomReminderEditor(
|
CustomReminderEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -212,19 +189,6 @@ private fun CustomReminderEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A short explanatory paragraph shown under a picker's title, above the rows,
|
|
||||||
* on the same left margin as the rows themselves. */
|
|
||||||
@Composable
|
|
||||||
internal fun PickerDescription(text: String) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||||
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||||
@@ -265,17 +229,11 @@ fun AgendaRangePicker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||||
val isSelected = option == selected
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = agendaRangeLabel(option),
|
title = agendaRangeLabel(option),
|
||||||
summary = windowSummary(option),
|
summary = windowSummary(option),
|
||||||
position = position,
|
position = position,
|
||||||
selected = isSelected,
|
checked = option == selected,
|
||||||
trailing = if (isSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = {
|
onClick = {
|
||||||
onSelect(option)
|
onSelect(option)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
@@ -284,7 +242,7 @@ fun AgendaRangePicker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
PickerDescription(description)
|
GroupedHint(description, verticalPadding = 8.dp)
|
||||||
|
|
||||||
// Calendar-aligned windows.
|
// Calendar-aligned windows.
|
||||||
calendarAligned.forEachIndexed { index, option ->
|
calendarAligned.forEachIndexed { index, option ->
|
||||||
@@ -297,7 +255,7 @@ fun AgendaRangePicker(
|
|||||||
rolling.forEachIndexed { index, option ->
|
rolling.forEachIndexed { index, option ->
|
||||||
rangeRow(option, positionOf(index, rollingRowCount))
|
rangeRow(option, positionOf(index, rollingRowCount))
|
||||||
}
|
}
|
||||||
GroupedRow(
|
CustomExpandRow(
|
||||||
title = if (customSelected) {
|
title = if (customSelected) {
|
||||||
agendaRangeLabel(selected)
|
agendaRangeLabel(selected)
|
||||||
} else {
|
} else {
|
||||||
@@ -306,19 +264,10 @@ fun AgendaRangePicker(
|
|||||||
// An unset custom window has no day count yet, so the row stays
|
// An unset custom window has no day count yet, so the row stays
|
||||||
// single-line until it does.
|
// single-line until it does.
|
||||||
summary = if (customSelected) windowSummary(selected) else null,
|
summary = if (customSelected) windowSummary(selected) else null,
|
||||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
position = positionOf(rolling.size, rollingRowCount),
|
||||||
selected = customSelected,
|
expanded = customExpanded,
|
||||||
trailing = if (customSelected) {
|
onExpandedChange = { customExpanded = it },
|
||||||
{ SelectedCheck() }
|
checked = customSelected,
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { customExpanded = !customExpanded },
|
|
||||||
)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = customExpanded,
|
|
||||||
enter = expandEnter(),
|
|
||||||
exit = collapseExit(),
|
|
||||||
) {
|
) {
|
||||||
CustomDaysEditor(
|
CustomDaysEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -396,17 +345,12 @@ fun DurationPicker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||||
if (description != null) PickerDescription(description)
|
if (description != null) GroupedHint(description, verticalPadding = 8.dp)
|
||||||
if (inheritLabel != null) {
|
if (inheritLabel != null) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = inheritLabel,
|
title = inheritLabel,
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
selected = inherits,
|
checked = inherits,
|
||||||
trailing = if (inherits) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = {
|
onClick = {
|
||||||
onSelect(null)
|
onSelect(null)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
@@ -415,39 +359,22 @@ fun DurationPicker(
|
|||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
presets.forEachIndexed { index, minute ->
|
presets.forEachIndexed { index, minute ->
|
||||||
val isSelected = minute == selected
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = label(minute),
|
title = label(minute),
|
||||||
position = positionOf(index, rowCount),
|
position = positionOf(index, rowCount),
|
||||||
selected = isSelected,
|
checked = minute == selected,
|
||||||
trailing = if (isSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = {
|
onClick = {
|
||||||
onSelect(minute)
|
onSelect(minute)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// The Custom row connects downward into the editor card when expanded, so
|
CustomExpandRow(
|
||||||
// the two read as one grouped container (the shared custom-expand pattern).
|
|
||||||
GroupedRow(
|
|
||||||
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
|
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
|
||||||
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
position = positionOf(presets.size, rowCount),
|
||||||
selected = custom != null,
|
expanded = customExpanded,
|
||||||
trailing = if (custom != null) {
|
onExpandedChange = { customExpanded = it },
|
||||||
{ SelectedCheck() }
|
checked = custom != null,
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { customExpanded = !customExpanded },
|
|
||||||
)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = customExpanded,
|
|
||||||
enter = expandEnter(),
|
|
||||||
exit = collapseExit(),
|
|
||||||
) {
|
) {
|
||||||
CustomDurationEditor(
|
CustomDurationEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
@@ -15,26 +15,28 @@ import de.jeanlucmakiola.calendula.domain.allowsEventMove
|
|||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.resizedTo
|
||||||
|
import de.jeanlucmakiola.calendula.domain.endInstant
|
||||||
|
import de.jeanlucmakiola.calendula.domain.startInstant
|
||||||
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||||
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
||||||
import de.jeanlucmakiola.calendula.domain.shiftedTo
|
import de.jeanlucmakiola.calendula.domain.shiftedTo
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||||
|
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||||
|
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -54,6 +56,9 @@ sealed interface MoveTarget {
|
|||||||
* disagree by a day over which zone the event's first day is resolved in.
|
* disagree by a day over which zone the event's first day is resolved in.
|
||||||
*/
|
*/
|
||||||
data class ByDays(val days: Int) : MoveTarget
|
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 +79,7 @@ data class MoveRequest(
|
|||||||
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
|
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
|
||||||
* across the same midnights the occurrence crossed.
|
* 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. */
|
/** The inverse of a completed move, for the undo action. */
|
||||||
data class MoveUndo(
|
data class MoveUndo(
|
||||||
@@ -88,12 +93,14 @@ data class MoveUndo(
|
|||||||
sealed interface MoveOutcome {
|
sealed interface MoveOutcome {
|
||||||
/**
|
/**
|
||||||
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
* 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(
|
data class Moved(
|
||||||
val startMillis: Long,
|
val startMillis: Long,
|
||||||
val isAllDay: Boolean,
|
val isAllDay: Boolean,
|
||||||
val undo: MoveUndo?,
|
val undo: MoveUndo?,
|
||||||
|
val endMillis: Long? = null,
|
||||||
) : MoveOutcome
|
) : MoveOutcome
|
||||||
|
|
||||||
data object Undone : MoveOutcome
|
data object Undone : MoveOutcome
|
||||||
@@ -122,6 +129,7 @@ sealed interface MoveOutcome {
|
|||||||
class RescheduleViewModel @Inject constructor(
|
class RescheduleViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
private val zoneSource: ZoneSource,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
|
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
|
||||||
@@ -179,9 +187,8 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
|
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
|
||||||
.catch { emit(emptySet<Long>()) }
|
.catch { emit(emptySet<Long>()) }
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
.stateIn(
|
.stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = emptySet(),
|
initialValue = emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -226,7 +233,10 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
// Still busy: the scope dialog is now the thing in flight.
|
// Still busy: the scope dialog is now the thing in flight.
|
||||||
pending = prepared
|
pending = prepared
|
||||||
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
|
_scopePrompt.value = MoveScopePrompt(
|
||||||
|
occurrenceOnly = !prepared.canRealign,
|
||||||
|
resize = request.target is MoveTarget.Span,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -308,18 +318,19 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val zone = TimeZone.currentSystemDefault()
|
val zone = zoneSource.current()
|
||||||
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
||||||
val shifted = when (val target = request.target) {
|
val shifted = when (val target = request.target) {
|
||||||
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
||||||
is MoveTarget.ByDays -> original.shiftedByDays(target.days, 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.
|
// A zero-distance drop is not a write.
|
||||||
if (shifted == original) return null
|
if (shifted == original) return null
|
||||||
|
|
||||||
// The UNTIL check is deferred to the write, which knows how far the move
|
// The UNTIL check is deferred to the write, which knows how far the move
|
||||||
// reaches and so which date has to clear it.
|
// reaches and so which date has to clear it.
|
||||||
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
||||||
_outcome.value = MoveOutcome.Failed
|
_outcome.value = MoveOutcome.Failed
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -396,6 +407,7 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
startMillis = movedStartMillis(prepared),
|
startMillis = movedStartMillis(prepared),
|
||||||
isAllDay = prepared.updated.isAllDay,
|
isAllDay = prepared.updated.isAllDay,
|
||||||
undo = undoFor(prepared, scope),
|
undo = undoFor(prepared, scope),
|
||||||
|
endMillis = resizedEndMillis(prepared),
|
||||||
)
|
)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -480,6 +492,13 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
null
|
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.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the dragged occurrence now begins, in the same anchoring the views
|
* Where the dragged occurrence now begins, in the same anchoring the views
|
||||||
* read dates back in: UTC midnight for an all-day event.
|
* read dates back in: UTC midnight for an all-day event.
|
||||||
@@ -489,8 +508,7 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
return if (form.isAllDay) {
|
return if (form.isAllDay) {
|
||||||
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||||
} else {
|
} else {
|
||||||
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
|
form.startInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
|
||||||
.toEpochMilliseconds()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,96 @@
|
|||||||
|
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.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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
|
||||||
|
// Follows a move or resize, so the times stay readable under the finger.
|
||||||
|
val preview by remember(selection) { derivedStateOf { selection.preview() } }
|
||||||
|
val shown = preview ?: event
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
val whenText = remember(shown.start, shown.end, use24Hour, locale) {
|
||||||
|
formatSelectionWhen(shown, use24Hour, locale)
|
||||||
|
}
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = selection.dismiss) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(
|
||||||
|
if (selection.create != null) R.string.event_draft_discard else 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,7 +1,12 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import de.jeanlucmakiola.calendula.domain.inRepeatedHour
|
||||||
|
import de.jeanlucmakiola.calendula.domain.zoneAbbreviationAt
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -68,3 +73,15 @@ fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String =
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val MINUTES_PER_DAY = 1_440
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [formatted] — [instant]'s time in [zone] — with the zone's abbreviation after
|
||||||
|
* it ("2:30 AM CEST") when the fall-back hour repeats that time, the one case
|
||||||
|
* where the time alone doesn't say which instant it is (#387).
|
||||||
|
*/
|
||||||
|
fun withRepeatedHourZone(formatted: String, instant: Instant, zone: TimeZone, locale: Locale): String {
|
||||||
|
if (!instant.inRepeatedHour(zone)) return formatted
|
||||||
|
val abbreviation = zoneAbbreviationAt(zone.id, locale, instant.toJavaInstant(), ::icuTimeZoneRegion)
|
||||||
|
?: return formatted
|
||||||
|
return "$formatted $abbreviation"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -39,11 +38,10 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
|||||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -118,19 +116,14 @@ fun TimeZonePickerDialog(
|
|||||||
title = stringResource(R.string.event_edit_timezone_device),
|
title = stringResource(R.string.event_edit_timezone_device),
|
||||||
summary = deviceSummary,
|
summary = deviceSummary,
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
selected = selected == null,
|
checked = selected == null,
|
||||||
leading = { Icon(Icons.Default.Public, contentDescription = null) },
|
leading = { Icon(Icons.Default.Public, contentDescription = null) },
|
||||||
trailing = if (selected == null) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { choose(null) },
|
onClick = { choose(null) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (recentZones.isNotEmpty()) {
|
if (recentZones.isNotEmpty()) {
|
||||||
item(key = "recent_header") {
|
item(key = "recent_header") {
|
||||||
SectionHeader(stringResource(R.string.event_edit_timezone_recent))
|
GroupedSectionHeader(stringResource(R.string.event_edit_timezone_recent))
|
||||||
}
|
}
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = recentZones,
|
items = recentZones,
|
||||||
@@ -145,7 +138,7 @@ fun TimeZonePickerDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
item(key = "all_header") {
|
item(key = "all_header") {
|
||||||
SectionHeader(stringResource(R.string.event_edit_timezone_all))
|
GroupedSectionHeader(stringResource(R.string.event_edit_timezone_all))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,23 +227,6 @@ private fun SearchField(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small primary-coloured group label, matching the settings screens. */
|
|
||||||
@Composable
|
|
||||||
private fun SectionHeader(text: String) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = GroupedListInset,
|
|
||||||
end = GroupedListInset,
|
|
||||||
top = 16.dp,
|
|
||||||
bottom = 4.dp,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ZoneRow(
|
private fun ZoneRow(
|
||||||
zone: TimeZoneOption,
|
zone: TimeZoneOption,
|
||||||
@@ -262,12 +238,7 @@ private fun ZoneRow(
|
|||||||
title = zone.label,
|
title = zone.label,
|
||||||
summary = zoneDescriptor(zone),
|
summary = zoneDescriptor(zone),
|
||||||
position = position,
|
position = position,
|
||||||
selected = selected,
|
checked = selected,
|
||||||
trailing = if (selected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user