Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b2a31ecda | ||
|
|
0f75d2638b | ||
|
|
9fe1ebb0fb | ||
|
|
951ad9cd6d | ||
|
|
6cb2f62627 | ||
|
|
427e87c6e6 | ||
|
|
dce83005ea | ||
|
|
1ac486cc4b | ||
|
|
549ef1dfbf | ||
|
|
164b5fb25d | ||
|
|
6afcfd49f1 | ||
|
|
5f77cac7ea | ||
|
|
20a6548ac8 | ||
|
|
9c35712573 | ||
|
|
4342c6ab6d | ||
|
|
57bad5c74c | ||
|
|
ebf833d0bd | ||
|
|
2f24a9878c | ||
|
|
2313671965 | ||
|
|
e1b80d9f89 | ||
|
|
99bb6adf80 | ||
|
|
06ee010f07 |
@@ -37,9 +37,24 @@ jobs:
|
|||||||
- name: Reproducible-release invariant
|
- name: Reproducible-release invariant
|
||||||
run: bash scripts/check_reproducible_release.sh
|
run: bash scripts/check_reproducible_release.sh
|
||||||
|
|
||||||
|
# versionCode must match versionName (the official F-Droid repo builds the
|
||||||
|
# tag as committed), and a beta must never reach main.
|
||||||
|
- name: Committed version is well-formed
|
||||||
|
env:
|
||||||
|
BASE: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
bash scripts/version_info.sh --check
|
||||||
|
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
|
||||||
|
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
|
||||||
|
echo "Set the stable version (and its versionCode) before merging into main." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Play rejects a "What's New" over 500 characters, which would fail the
|
# Play rejects a "What's New" over 500 characters, which would fail the
|
||||||
# upload after the release had already shipped everywhere else. Cheap, so
|
# upload after the release had already shipped everywhere else. Cheap, so
|
||||||
# it runs on every PR rather than only on the release merge.
|
# it runs on every PR rather than only on the release merge. A beta ships
|
||||||
|
# no What's New, so only the older files are checked for one.
|
||||||
- name: Changelog length invariant
|
- name: Changelog length invariant
|
||||||
run: bash scripts/check_changelog_lengths.sh
|
run: bash scripts/check_changelog_lengths.sh
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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]).
|
||||||
|
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **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
|
## [2.22.0] — 2026-10-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1817,3 +1854,14 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#322]: https://codeberg.org/jlmakiola/calendula/issues/322
|
[#322]: https://codeberg.org/jlmakiola/calendula/issues/322
|
||||||
[#331]: https://codeberg.org/jlmakiola/calendula/issues/331
|
[#331]: https://codeberg.org/jlmakiola/calendula/issues/331
|
||||||
[#336]: https://codeberg.org/jlmakiola/calendula/issues/336
|
[#336]: https://codeberg.org/jlmakiola/calendula/issues/336
|
||||||
|
[#357]: https://codeberg.org/jlmakiola/calendula/issues/357
|
||||||
|
[#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
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ For automatic updates from Codeberg, use
|
|||||||
[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula),
|
[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula),
|
||||||
or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`,
|
or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`,
|
||||||
then *Add*. Obtainium then watches the releases and tells you when a new one
|
then *Add*. Obtainium then watches the releases and tells you when a new one
|
||||||
is out.
|
is out. Betas of upcoming versions are published there too, as pre-releases; to
|
||||||
|
test them, switch on *Include prereleases* for Calendula in Obtainium.
|
||||||
|
|
||||||
### Google Play
|
### Google Play
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 22200
|
// versionCode is derived from versionName by scripts/version_info.sh
|
||||||
versionName = "2.22.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 = 2230003
|
||||||
|
versionName = "2.23.0-beta.3"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ import androidx.core.net.toUri
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
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.LocalMultiDayCount
|
||||||
@@ -56,6 +58,7 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** A prefilled create form from an external launch, with the source it came from. */
|
/** A prefilled create form from an external launch, with the source it came from. */
|
||||||
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
||||||
@@ -63,6 +66,8 @@ private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
|||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var calendarRepository: CalendarRepository
|
||||||
|
|
||||||
// Which of light/dark the system bars are drawn for. The styles installed
|
// Which of light/dark the system bars are drawn for. The styles installed
|
||||||
// in onCreate read this field live, so androidx's config-change replay
|
// in onCreate read this field live, so androidx's config-change replay
|
||||||
// picks up the in-app override instead of the night resource qualifier.
|
// picks up the in-app override instead of the night resource qualifier.
|
||||||
@@ -82,10 +87,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.
|
||||||
@@ -124,7 +137,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
//
|
//
|
||||||
// Only those channels are held back. An .ics import or a prefilled
|
// 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
|
// create form keeps no saved state of its own, so re-reading the intent
|
||||||
// is what carries one through a recreation.
|
// is what carries one through a recreation — the create form only while
|
||||||
|
// it's still open.
|
||||||
val replaying = savedInstanceState != null || intent.isRelaunch()
|
val replaying = savedInstanceState != null || intent.isRelaunch()
|
||||||
if (!replaying) {
|
if (!replaying) {
|
||||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
@@ -133,7 +147,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsert = intent.insertRequestOrNull()
|
val insertOpenBefore = savedInstanceState?.getBoolean(KEY_INSERT_FORM_OPEN) == true
|
||||||
|
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
|
||||||
@@ -204,6 +222,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 },
|
||||||
)
|
)
|
||||||
@@ -245,18 +267,33 @@ 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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +315,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;
|
||||||
@@ -323,6 +362,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
|
||||||
@@ -448,6 +517,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"
|
||||||
|
|||||||
@@ -610,8 +610,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// cancelled exception for the one instance (#47). A NULL status is a
|
// cancelled exception for the one instance (#47). A NULL status is a
|
||||||
// normal, un-cancelled event, so it must survive the filter — a bare
|
// normal, un-cancelled event, so it must survive the filter — a bare
|
||||||
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
||||||
"${CalendarContract.Instances.STATUS} IS NULL OR " +
|
// DELETED = 0: a provider that soft-deletes may keep the row's
|
||||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
|
// instances until a purge (#364).
|
||||||
|
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
|
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0",
|
||||||
null,
|
null,
|
||||||
CalendarContract.Instances.BEGIN + " ASC",
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import kotlin.time.Instant
|
|||||||
interface CalendarRepository {
|
interface CalendarRepository {
|
||||||
fun calendars(): Flow<List<CalendarSource>>
|
fun calendars(): Flow<List<CalendarSource>>
|
||||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
|
|
||||||
|
/** Re-query every open [calendars] / [instances] flow. */
|
||||||
|
fun refresh()
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+34
-17
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+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,6 +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 de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -61,6 +65,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
|
||||||
|
|||||||
@@ -346,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
|
||||||
@@ -1019,6 +1032,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")
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.time
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.flow.transformLatest
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/** The current local date, re-emitted whenever it changes (#373). */
|
||||||
|
interface TodaySource {
|
||||||
|
val today: Flow<LocalDate>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-reads the date on every minute boundary while collected, and at once on the
|
||||||
|
* system's date, time and zone broadcasts, which can arrive late or not at all.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class AndroidTodaySource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : TodaySource {
|
||||||
|
|
||||||
|
override val today: Flow<LocalDate> = callbackFlow {
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
trySend(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(Intent.ACTION_DATE_CHANGED)
|
||||||
|
addAction(Intent.ACTION_TIME_CHANGED)
|
||||||
|
addAction(Intent.ACTION_TIMEZONE_CHANGED)
|
||||||
|
}
|
||||||
|
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||||
|
awaitClose { context.unregisterReceiver(receiver) }
|
||||||
|
}
|
||||||
|
.onStart { emit(Unit) }
|
||||||
|
.transformLatest {
|
||||||
|
while (true) {
|
||||||
|
emit(currentDate())
|
||||||
|
delay(MINUTE_MILLIS - Clock.System.now().toEpochMilliseconds() % MINUTE_MILLIS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MINUTE_MILLIS = 60_000L
|
||||||
|
|
||||||
|
fun currentDate(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.time
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
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> = callbackFlow {
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
trySend(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val filter = IntentFilter(Intent.ACTION_TIMEZONE_CHANGED)
|
||||||
|
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||||
|
awaitClose { context.unregisterReceiver(receiver) }
|
||||||
|
}
|
||||||
|
.onStart { emit(Unit) }
|
||||||
|
.map { current() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
|
override fun current(): TimeZone = TimeZone.currentSystemDefault()
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.setValue
|
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
|
||||||
@@ -55,7 +56,12 @@ import de.jeanlucmakiola.floret.identity.fadeThrough
|
|||||||
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
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.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.LocalManageCalendars
|
||||||
@@ -76,6 +82,7 @@ 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.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
|
||||||
@@ -91,6 +98,7 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
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
|
||||||
@@ -126,6 +134,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(),
|
||||||
@@ -146,14 +155,27 @@ fun CalendarHost(
|
|||||||
mutableStateOf(listOf(defaultView))
|
mutableStateOf(listOf(defaultView))
|
||||||
}
|
}
|
||||||
var navigationVersion by remember { mutableIntStateOf(0) }
|
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()
|
||||||
// The date every view opens on and carries along (#184); tapping a day
|
// The date every view opens on and carries along (#184); tapping a day
|
||||||
// anywhere focuses it and opens the day view there.
|
// anywhere focuses it and opens the day view there.
|
||||||
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
|
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
|
||||||
|
// A focus still on yesterday follows the date over, so the next view opened
|
||||||
|
// doesn't land on the old day (#373); one moved elsewhere stays put.
|
||||||
|
val today = viewModel.today.collectAsStateWithLifecycle().value
|
||||||
|
var focusToday by rememberSaveable { mutableStateOf(today.toString()) }
|
||||||
|
LaunchedEffect(today) {
|
||||||
|
val old = LocalDate.parse(focusToday)
|
||||||
|
if (old != today && focus.date == old) focus.date = today
|
||||||
|
focusToday = today.toString()
|
||||||
|
}
|
||||||
|
|
||||||
val switchScope = rememberCoroutineScope()
|
val switchScope = rememberCoroutineScope()
|
||||||
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
|
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
|
||||||
navigationVersion++
|
navigationVersion++
|
||||||
|
returnedFromDay = false
|
||||||
viewStack = move(viewStack)
|
viewStack = move(viewStack)
|
||||||
}
|
}
|
||||||
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
|
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
|
||||||
@@ -175,7 +197,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(),
|
||||||
@@ -222,11 +244,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
|
||||||
@@ -246,6 +280,34 @@ 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
|
||||||
@@ -275,8 +337,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,6 +357,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
|
||||||
@@ -313,7 +381,10 @@ 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++
|
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
|
||||||
@@ -360,6 +431,8 @@ fun CalendarHost(
|
|||||||
createDateIso = iso
|
createDateIso = iso
|
||||||
heldCreateMinutes = null
|
heldCreateMinutes = null
|
||||||
createStartMinutes = null
|
createStartMinutes = null
|
||||||
|
heldCreateDraftMillis = null
|
||||||
|
createDraftMillis = null
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
null -> {}
|
null -> {}
|
||||||
@@ -370,6 +443,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,
|
||||||
@@ -379,6 +455,18 @@ 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.
|
||||||
|
val tapOpensEditor = viewModel.tapOpensEditor.collectAsStateWithLifecycle().value
|
||||||
|
val actionCalendars by eventActions.calendars.collectAsStateWithLifecycle()
|
||||||
|
val requireWriteOr = rememberWriteGateOr()
|
||||||
|
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||||
|
if (tapOpensEditor && actionCalendars[event.calendarId]?.canModifyContents == true) {
|
||||||
|
requireWriteOr({ onEditEvent(event) }, { onOpenDetail(event) })
|
||||||
|
} else {
|
||||||
|
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
|
||||||
@@ -415,11 +503,14 @@ fun CalendarHost(
|
|||||||
if (reduceMotion) {
|
if (reduceMotion) {
|
||||||
BackHandler(enabled = viewBackEnabled) {
|
BackHandler(enabled = viewBackEnabled) {
|
||||||
navigationVersion++
|
navigationVersion++
|
||||||
|
returnedFromDay = viewStack.last() == CalendarView.Day
|
||||||
viewStack = viewStack.dropLast(1)
|
viewStack = viewStack.dropLast(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
|
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
|
||||||
val previous = viewStack[viewStack.size - 2]
|
val previous = viewStack[viewStack.size - 2]
|
||||||
|
// Set before the seek, which already composes the previous view.
|
||||||
|
returnedFromDay = viewStack.last() == CalendarView.Day
|
||||||
try {
|
try {
|
||||||
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
|
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
|
||||||
navigationVersion++
|
navigationVersion++
|
||||||
@@ -508,7 +599,24 @@ fun CalendarHost(
|
|||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
|
floatingActionButton = {
|
||||||
|
// While an event is selected the FAB is its action bar's toggle (#141).
|
||||||
|
val fabSelection = rememberFabSelection(
|
||||||
|
host = chromeHost,
|
||||||
|
viewModel = eventActions,
|
||||||
|
onEdit = onEditEvent,
|
||||||
|
onDuplicate = { form ->
|
||||||
|
// A fresh event, like a duplicate from the detail screen (#52).
|
||||||
|
openImportForm(form, ImportSource.Insert)
|
||||||
|
},
|
||||||
|
chipShown = moveOutcome != null || selectionFailure != null,
|
||||||
|
onDismissChip = {
|
||||||
|
reschedule.consumeOutcome()
|
||||||
|
eventActions.consumeFailure()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
CalendarFab(chromeHost, todayInToolbar, fabSelection)
|
||||||
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
SharedTransitionLayout(
|
SharedTransitionLayout(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -539,6 +647,7 @@ fun CalendarHost(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
|
onCreateDraft = onCreateDraft,
|
||||||
)
|
)
|
||||||
ViewScreen.Month -> MonthScreen(
|
ViewScreen.Month -> MonthScreen(
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
@@ -551,6 +660,7 @@ fun CalendarHost(
|
|||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
restorePosition = returnedFromDay,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,7 +672,15 @@ fun CalendarHost(
|
|||||||
|
|
||||||
// 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.
|
||||||
@@ -599,8 +717,7 @@ 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,
|
visible = detailKey != null,
|
||||||
@@ -618,10 +735,12 @@ fun CalendarHost(
|
|||||||
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,
|
visible = createDateIso != null,
|
||||||
|
viewModel = hiltViewModel(key = "create"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -642,7 +761,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,
|
visible = editKey != null,
|
||||||
|
viewModel = hiltViewModel(key = "edit"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -688,20 +820,24 @@ fun CalendarHost(
|
|||||||
importUri = null
|
importUri = null
|
||||||
calendarsAfterImport = false
|
calendarsAfterImport = false
|
||||||
backupAfterImport = false
|
backupAfterImport = false
|
||||||
importFormSource = ImportSource.File
|
openImportForm(form, ImportSource.File)
|
||||||
importForm = form
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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,
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,8 +24,16 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CalendarHostViewModel @Inject constructor(
|
class CalendarHostViewModel @Inject constructor(
|
||||||
prefs: SettingsPrefs,
|
prefs: SettingsPrefs,
|
||||||
|
todaySource: TodaySource,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Today, kept current across midnight so the shared focus can follow it (#373). */
|
||||||
|
val today: StateFlow<LocalDate> = todaySource.today.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = currentDate(),
|
||||||
|
)
|
||||||
|
|
||||||
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
|
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
@@ -67,4 +78,12 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Whether tapping an event opens the edit form rather than the detail screen (#178). */
|
||||||
|
val tapOpensEditor: StateFlow<Boolean> = prefs.tapOpensEditor
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ 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 = {},
|
||||||
) {
|
) {
|
||||||
@@ -155,6 +156,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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
|
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
|
|
||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -38,7 +37,16 @@ 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.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.derivedStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -53,7 +61,7 @@ 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
|
||||||
@@ -82,11 +90,23 @@ fun AgendaScreen(
|
|||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
restorePosition: Boolean = false,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
// Opens on the focused date and hands back the day scrolled to the top.
|
// Opens on today, not on the day another view was on (#367). Backing out of
|
||||||
|
// a day opened from here, or a recreated screen, returns to the day last at
|
||||||
|
// the top. The top day still goes back to the shared focus.
|
||||||
val focus = LocalViewFocus.current
|
val focus = LocalViewFocus.current
|
||||||
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
|
val opened = rememberSaveable { mutableStateOf(false) }
|
||||||
|
val restoreTop = remember(viewModel) {
|
||||||
|
if (restorePosition || opened.value) {
|
||||||
|
viewModel.lastTopDate
|
||||||
|
} else {
|
||||||
|
viewModel.goToToday()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SideEffect { opened.value = true }
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val 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()
|
||||||
@@ -106,20 +126,19 @@ fun AgendaScreen(
|
|||||||
// list scroll, not an anchor change — "today" has to bring the list back to
|
// list scroll, not an anchor change — "today" has to bring the list back to
|
||||||
// the top as well, and stay offered while it is scrolled away (#305).
|
// the top as well, and stay offered while it is scrolled away (#305).
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
// Past days load above the anchor (#367), so the anchor's row counts as the
|
||||||
|
// start of the list.
|
||||||
|
val anchorIndex = remember { mutableIntStateOf(0) }
|
||||||
val scrolledAway by remember {
|
val scrolledAway by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
listState.firstVisibleItemIndex != anchorIndex.intValue ||
|
||||||
|
listState.firstVisibleItemScrollOffset > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A new window is rendered from its first day, so the list belongs at the
|
|
||||||
// top. Keyed on the anchor the days arrived with, not the one just
|
|
||||||
// requested: scrolling before the new rows compose leaves the list keyed to
|
|
||||||
// a row that reappears further down, and it follows it there.
|
|
||||||
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
|
|
||||||
val jumpToToday: () -> Unit = {
|
val jumpToToday: () -> Unit = {
|
||||||
// Off today, moving the anchor is what resets the list (above); on today
|
// Off today, moving the anchor is what repositions the list; on today
|
||||||
// the window doesn't change, so the scroll back is the whole action.
|
// the window doesn't change, so the scroll back is the whole action.
|
||||||
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
|
if (isOnToday) scope.launch { listState.animateScrollToItem(anchorIndex.intValue) }
|
||||||
viewModel.goToToday()
|
viewModel.goToToday()
|
||||||
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
|
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
|
||||||
}
|
}
|
||||||
@@ -188,11 +207,17 @@ fun AgendaScreen(
|
|||||||
pastDisplay = pastDisplay,
|
pastDisplay = pastDisplay,
|
||||||
showToday = showToday,
|
showToday = showToday,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
|
anchorIndex = anchorIndex,
|
||||||
|
initialTopDate = restoreTop,
|
||||||
|
onLoadEarlier = viewModel::loadEarlier,
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onTopDay = if (successState?.anchor == anchor) {
|
onTopDay = if (successState?.anchor == anchor) {
|
||||||
{ focus?.date = it }
|
{
|
||||||
|
focus?.date = it
|
||||||
|
viewModel.rememberTopDay(it)
|
||||||
|
}
|
||||||
} else null,
|
} else null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -294,6 +319,9 @@ internal fun AgendaContent(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
listState: LazyListState = rememberLazyListState(),
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
anchorIndex: MutableIntState = remember { mutableIntStateOf(0) },
|
||||||
|
initialTopDate: LocalDate? = null,
|
||||||
|
onLoadEarlier: () -> Unit = {},
|
||||||
onTopDay: ((LocalDate) -> Unit)? = null,
|
onTopDay: ((LocalDate) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
@@ -308,28 +336,34 @@ 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
|
// Nothing to scroll: drop the position a previous list left, so
|
||||||
// the today FAB doesn't linger over an empty screen.
|
// the today FAB doesn't linger over an empty screen.
|
||||||
LaunchedEffect(Unit) { listState.scrollToItem(0) }
|
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,
|
listState = listState,
|
||||||
@@ -349,6 +383,10 @@ 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,
|
listState: LazyListState,
|
||||||
@@ -359,19 +397,49 @@ private fun AgendaList(
|
|||||||
onTopDay: ((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) {
|
if (onTopDay != null) {
|
||||||
val itemDates by rememberUpdatedState(agendaItemDates(days))
|
val currentItemDates by rememberUpdatedState(itemDates)
|
||||||
val currentOnTopDay by rememberUpdatedState(onTopDay)
|
val currentOnTopDay by rememberUpdatedState(onTopDay)
|
||||||
// The day the list opened on is where the view already is; only a
|
// The day the list opened on is where the view already is; only a
|
||||||
// scroll moves it on.
|
// scroll moves it on.
|
||||||
LaunchedEffect(listState) {
|
LaunchedEffect(listState) {
|
||||||
snapshotFlow { listState.firstVisibleItemIndex }
|
snapshotFlow { listState.firstVisibleItemIndex }
|
||||||
.drop(1)
|
.drop(1)
|
||||||
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
|
.collect { index -> currentItemDates.getOrNull(index)?.let(currentOnTopDay) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier,
|
modifier = modifier.nestedScroll(loadOnScrollUp),
|
||||||
state = listState,
|
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),
|
||||||
@@ -401,7 +469,7 @@ 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().morphEvent(ViewMorphKey.event(event, day.date)),
|
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
@@ -412,6 +480,9 @@ private fun AgendaList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How close to the first loaded row an upward scroll starts loading earlier days. */
|
||||||
|
private const val LOAD_EARLIER_WITHIN = 10
|
||||||
|
|
||||||
/** The day of each item [AgendaList] lays out for [days], in its order. */
|
/** The day of each item [AgendaList] lays out for [days], in its order. */
|
||||||
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
|
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
|
||||||
days.flatMap { day ->
|
days.flatMap { day ->
|
||||||
|
|||||||
@@ -93,22 +93,21 @@ fun groupAgendaDays(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -118,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. */
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -21,6 +23,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
|
||||||
@@ -28,10 +32,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
|
||||||
|
|
||||||
@@ -40,6 +43,7 @@ import javax.inject.Inject
|
|||||||
class AgendaViewModel @Inject constructor(
|
class AgendaViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
settingsPrefs: SettingsPrefs,
|
settingsPrefs: SettingsPrefs,
|
||||||
|
todaySource: TodaySource,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -84,10 +88,34 @@ class AgendaViewModel @Inject constructor(
|
|||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = currentDate()
|
||||||
|
|
||||||
private val _anchor = MutableStateFlow(todayDate)
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
|
||||||
|
|
||||||
// A transient, in-view override of the saved agenda range. Held in memory
|
// A transient, in-view override of the saved agenda range. Held in memory
|
||||||
// (not persisted), so it survives view switches and rotation within the
|
// (not persisted), so it survives view switches and rotation within the
|
||||||
@@ -95,9 +123,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,
|
||||||
@@ -106,15 +142,16 @@ 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)) }
|
||||||
@@ -126,12 +163,30 @@ class AgendaViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
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). */
|
||||||
@@ -144,18 +199,26 @@ 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 {
|
||||||
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
|
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
|
||||||
val anchor = params.anchor
|
val anchor = params.anchor
|
||||||
@@ -163,10 +226,11 @@ class AgendaViewModel @Inject constructor(
|
|||||||
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,
|
||||||
@@ -177,6 +241,9 @@ class AgendaViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many days each scroll back into the past loads (#367). */
|
||||||
|
internal const val PAST_CHUNK_DAYS = 30
|
||||||
|
|
||||||
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
/** 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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -278,12 +278,23 @@ data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y:
|
|||||||
* 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(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
|
fun animatedBlockPlacement(
|
||||||
|
xFraction: Float,
|
||||||
|
widthFraction: Float,
|
||||||
|
y: Dp,
|
||||||
|
height: Dp,
|
||||||
|
landing: Boolean = false,
|
||||||
|
): BlockPlacement {
|
||||||
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
|
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
|
||||||
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
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 shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||||
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
|
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
|
||||||
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
|
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
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.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.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -16,6 +25,9 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.Composable
|
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.Immutable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
@@ -24,6 +36,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,12 +66,37 @@ class ScreenActions(
|
|||||||
val onCreate: () -> 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 screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
|
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
|
||||||
@Stable
|
@Stable
|
||||||
class ChromeHost {
|
class ChromeHost {
|
||||||
var chrome by mutableStateOf<ScreenChrome?>(null)
|
var chrome by mutableStateOf<ScreenChrome?>(null)
|
||||||
private set
|
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
|
var actions: ScreenActions? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -80,8 +118,51 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
|
|||||||
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
|
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The top bar every calendar screen shares. */
|
/** Hands the screen's selected [event] to the host, for as long as it stays selected. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@Composable
|
||||||
|
fun PublishSelection(
|
||||||
|
event: EventInstance?,
|
||||||
|
busy: Boolean,
|
||||||
|
compact: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onCreate: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val slot = LocalChromeSlot.current ?: return
|
||||||
|
val currentDismiss by rememberUpdatedState(onDismiss)
|
||||||
|
val currentCreate by rememberUpdatedState(onCreate)
|
||||||
|
val drafted = onCreate != null
|
||||||
|
val selection = remember(event, drafted) {
|
||||||
|
event?.let {
|
||||||
|
EventSelection(
|
||||||
|
event = it,
|
||||||
|
dismiss = { currentDismiss() },
|
||||||
|
create = if (drafted) ({ currentCreate?.invoke() }) else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
@Composable
|
||||||
fun CalendarTopBar(
|
fun CalendarTopBar(
|
||||||
host: ChromeHost,
|
host: ChromeHost,
|
||||||
@@ -91,6 +172,43 @@ fun CalendarTopBar(
|
|||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
showTodayButton: Boolean,
|
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
|
val chrome = host.chrome
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -136,16 +254,26 @@ fun CalendarTopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The FAB column every calendar screen shares. */
|
/**
|
||||||
|
* The FAB column every calendar screen shares. While an event is selected the
|
||||||
|
* FAB is [selection]'s toggle instead, and it steps aside mid-gesture.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
|
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean, selection: FabSelection? = null) {
|
||||||
val chrome = host.chrome ?: return
|
val chrome = host.chrome ?: return
|
||||||
CalendarFabColumn(
|
AnimatedVisibility(
|
||||||
todayVisible = chrome.awayFromToday && !todayInToolbar,
|
visible = !host.selectionBusy,
|
||||||
todayText = chrome.todayText,
|
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
|
||||||
onToday = { host.actions?.onToday?.invoke() },
|
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
|
||||||
onCreate = { host.actions?.onCreate?.invoke() },
|
) {
|
||||||
)
|
CalendarFabColumn(
|
||||||
|
todayVisible = chrome.awayFromToday && !todayInToolbar,
|
||||||
|
todayText = chrome.todayText,
|
||||||
|
onToday = { host.actions?.onToday?.invoke() },
|
||||||
|
onCreate = { host.actions?.onCreate?.invoke() },
|
||||||
|
selection = selection,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,202 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import 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 androidx.core.content.ContextCompat
|
||||||
|
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.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 }
|
||||||
|
|
||||||
|
/** [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 ->
|
||||||
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
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,125 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** A delete waiting on its confirmation dialog. */
|
||||||
|
data class DeletePrompt(val event: EventInstance, val isRecurring: Boolean)
|
||||||
|
|
||||||
|
/** How an action on a selected event ended, when it has something to say. */
|
||||||
|
enum class EventActionFailure { LoadFailed, ShareFailed, DeleteFailed, WriteDenied, Gone }
|
||||||
|
|
||||||
|
/** Duplicate, share and delete for a selected event (#141), loading it on demand. */
|
||||||
|
@HiltViewModel
|
||||||
|
class EventActionsViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val icsExporter: IcsExporter,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Every calendar by id, for what a selected event's bar may offer. */
|
||||||
|
val calendars: StateFlow<Map<Long, CalendarSource>> = repository.calendars()
|
||||||
|
.map { list -> list.associateBy { it.id } }
|
||||||
|
.catch { emit(emptyMap()) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _deletePrompt = MutableStateFlow<DeletePrompt?>(null)
|
||||||
|
val deletePrompt: StateFlow<DeletePrompt?> = _deletePrompt.asStateFlow()
|
||||||
|
|
||||||
|
private val _failure = MutableStateFlow<EventActionFailure?>(null)
|
||||||
|
val failure: StateFlow<EventActionFailure?> = _failure.asStateFlow()
|
||||||
|
|
||||||
|
/** A create form copying [event], or null when it can't be read. */
|
||||||
|
suspend fun duplicateForm(event: EventInstance): EventForm? {
|
||||||
|
val detail = load(event) ?: return null
|
||||||
|
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
|
||||||
|
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
|
||||||
|
suspend fun shareUri(event: EventInstance): Uri? {
|
||||||
|
val uri = load(event)?.let { withContext(io) { icsExporter.stageEventShare(it) } }
|
||||||
|
if (uri == null && _failure.value == null) _failure.value = EventActionFailure.ShareFailed
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask to delete [event]; the dialog it needs depends on whether it recurs. */
|
||||||
|
fun requestDelete(event: EventInstance) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val detail = load(event) ?: return@launch
|
||||||
|
_deletePrompt.value = DeletePrompt(event, isRecurring = !detail.rrule.isNullOrBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmDelete(scope: RecurringWriteScope) {
|
||||||
|
val prompt = _deletePrompt.value ?: return
|
||||||
|
_deletePrompt.value = null
|
||||||
|
viewModelScope.launch {
|
||||||
|
val event = prompt.event
|
||||||
|
_failure.value = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun load(event: EventInstance): EventDetail? = try {
|
||||||
|
val detail = repository.eventDetail(event.eventId)
|
||||||
|
// The row holds the series start; the actions work on this occurrence.
|
||||||
|
detail.copy(instance = detail.instance.copy(start = event.start, end = event.end))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
_failure.value = EventActionFailure.Gone
|
||||||
|
null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_failure.value = EventActionFailure.LoadFailed
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,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,18 +182,31 @@ 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,135 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** The bottom band the selection bar and FAB cover, margin included. */
|
||||||
|
val SelectionBarClearance = FabSize + SnackChipMargin
|
||||||
|
|
||||||
|
/** The FAB's role while an event is selected (#141): its action bar's toggle. Null otherwise. */
|
||||||
|
@Composable
|
||||||
|
fun rememberFabSelection(
|
||||||
|
host: ChromeHost,
|
||||||
|
viewModel: EventActionsViewModel,
|
||||||
|
onEdit: (EventInstance) -> Unit,
|
||||||
|
onDuplicate: (EventForm) -> Unit,
|
||||||
|
chipShown: Boolean,
|
||||||
|
onDismissChip: () -> Unit,
|
||||||
|
): FabSelection? {
|
||||||
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val requireWrite = rememberWriteGate()
|
||||||
|
val chooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||||
|
|
||||||
|
// What the FAB has been told for this selection, over what the coverage says.
|
||||||
|
var chosen by remember(host.selection?.event) { mutableStateOf<Boolean?>(null) }
|
||||||
|
// Folded while a chip is up, which then sits beside the FAB as usual.
|
||||||
|
val expanded = !chipShown && (chosen ?: !host.selectionCompact)
|
||||||
|
|
||||||
|
val currentEdit by rememberUpdatedState(onEdit)
|
||||||
|
val currentDuplicate by rememberUpdatedState(onDuplicate)
|
||||||
|
val currentDismissChip by rememberUpdatedState(onDismissChip)
|
||||||
|
val currentWrite by rememberUpdatedState(requireWrite)
|
||||||
|
|
||||||
|
val selection = host.selection ?: return null
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -195,15 +195,20 @@ class OutOfViewEdges internal constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
|
||||||
fun tap(y: Float): Boolean {
|
fun tap(y: Float): Boolean {
|
||||||
val edge = tappedEdge(
|
val edge = edgeAt(y) ?: return false
|
||||||
events = events,
|
|
||||||
y = y,
|
|
||||||
viewportTop = scrollState.value.toFloat(),
|
|
||||||
viewportPx = scrollState.viewportSize,
|
|
||||||
reachPx = reachPx,
|
|
||||||
) ?: return false
|
|
||||||
reveal(edge)
|
reveal(edge)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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.data.di.IoDispatcher
|
||||||
@@ -15,6 +16,9 @@ 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
|
||||||
@@ -54,6 +58,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 +81,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 +95,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 +131,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)
|
||||||
@@ -226,7 +236,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 +321,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 +410,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 +495,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 +511,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,91 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top bar while an event is selected (#141): says which, and when, with a
|
||||||
|
* way out — the selection otherwise only shows on the grid and in the FAB.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SelectionTopBar(selection: EventSelection) {
|
||||||
|
val event = selection.event
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
val whenText = remember(event.start, event.end, use24Hour, locale) {
|
||||||
|
formatSelectionWhen(event, use24Hour, locale)
|
||||||
|
}
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = selection.dismiss) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(
|
||||||
|
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,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.ScrollState
|
import androidx.compose.foundation.ScrollState
|
||||||
import androidx.compose.foundation.MutatePriority
|
import androidx.compose.foundation.MutatePriority
|
||||||
@@ -20,6 +21,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -27,6 +29,7 @@ import androidx.compose.runtime.withFrameNanos
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
@@ -35,14 +38,24 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
|
|||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
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.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.instantIn
|
||||||
|
import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt
|
||||||
|
import de.jeanlucmakiola.calendula.domain.repicked
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toFormTime
|
||||||
|
import de.jeanlucmakiola.calendula.domain.resolveIn
|
||||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.minutesPast
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.wallClockEndMinutes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.wallClockMinutes
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -50,13 +63,15 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.UtcOffset
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
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.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
||||||
@@ -97,11 +112,28 @@ data class TimelineDrag(
|
|||||||
* drop lands, so it is dropped once the gesture ends ([finish]).
|
* drop lands, so it is dropped once the gesture ends ([finish]).
|
||||||
*/
|
*/
|
||||||
val edgeOnly: Boolean = false,
|
val edgeOnly: Boolean = false,
|
||||||
|
/**
|
||||||
|
* The minute of its day the gutter badge names — the top edge, or for a
|
||||||
|
* bottom-edge resize the end being dragged (#141).
|
||||||
|
*/
|
||||||
|
val markerMin: Int = startMin,
|
||||||
) {
|
) {
|
||||||
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
/**
|
||||||
val slot: Pair<LocalDate, Int> get() = date to startMin
|
* What the snap haptics key off: one tick per changed slot, not per frame.
|
||||||
|
* Carries the length, which is all a bottom-edge resize changes.
|
||||||
|
*/
|
||||||
|
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The event a long press left selected (#141). */
|
||||||
|
data class TimelineSelection(
|
||||||
|
val event: EventInstance,
|
||||||
|
/** Set from a move or resize of it until the grid shows where it landed. */
|
||||||
|
val moved: Boolean = false,
|
||||||
|
/** Selected by letting go of a lifted block, which then keeps that lift as it is. */
|
||||||
|
val fromLift: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One day column's share of a dragged event, ready to draw. Both halves of an
|
* One day column's share of a dragged event, ready to draw. Both halves of an
|
||||||
* event crossing midnight are previewed, whichever half the finger picked up:
|
* event crossing midnight are previewed, whichever half the finger picked up:
|
||||||
@@ -248,6 +280,139 @@ data class TimelineDrop(
|
|||||||
val clipOffsetMin: Int = 0,
|
val clipOffsetMin: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
|
||||||
|
private const val DRAFT_ID = Long.MIN_VALUE
|
||||||
|
|
||||||
|
/** Whether this is the stand-in for a block drafted on an empty slot, not a real event. */
|
||||||
|
val EventInstance.isDraft: Boolean get() = instanceId == DRAFT_ID
|
||||||
|
|
||||||
|
/** A drafted block's stand-in event, [minutes] long from [start]. */
|
||||||
|
fun draftEvent(title: String, color: Int, start: Instant, minutes: Int): EventInstance = EventInstance(
|
||||||
|
instanceId = DRAFT_ID,
|
||||||
|
eventId = DRAFT_ID,
|
||||||
|
calendarId = DRAFT_ID,
|
||||||
|
title = title,
|
||||||
|
start = start,
|
||||||
|
end = start + minutes.minutes,
|
||||||
|
isAllDay = false,
|
||||||
|
color = color,
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Where a long press [minutes] down the column starts its draft, snapped down to the slot. */
|
||||||
|
internal fun draftStartMin(minutes: Float): Int =
|
||||||
|
(minutes / DRAG_SNAP_MINUTES).toInt().times(DRAG_SNAP_MINUTES)
|
||||||
|
.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||||
|
|
||||||
|
/** The part of [draft] on [date] as a full-width block, or null if none of it falls there. */
|
||||||
|
internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? {
|
||||||
|
// Clipped to the day the way the grid lays out events, so it's drawn the same across a DST change (#387).
|
||||||
|
// The end is exclusive, so a draft ending at midnight belongs to the day before.
|
||||||
|
val start = maxOf(draft.start, date.atStartOfDayIn(zone))
|
||||||
|
val end = minOf(draft.end, date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone))
|
||||||
|
if (end <= start) return null
|
||||||
|
val startMin = wallClockMinutes(start, date, zone)
|
||||||
|
val endMin = wallClockEndMinutes(startMin, start, end, date, zone).coerceAtMost(MINUTES_PER_DAY)
|
||||||
|
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which end of an event a resize handle moves (#141). */
|
||||||
|
enum class ResizeEdge { Top, Bottom }
|
||||||
|
|
||||||
|
/** Where a finished resize asks its event to begin and end. */
|
||||||
|
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s
|
||||||
|
* midnight. Only the held edge moves. The other keeps its own instant rather than
|
||||||
|
* a round trip through the grid, where an end in the fall-back hour's repeat is
|
||||||
|
* drawn to that hour's end, not at its own time (#387). In a repeated hour, the
|
||||||
|
* held edge takes the pass that matches the direction it was dragged from where
|
||||||
|
* it was drawn (see [repicked]). An end takes a pass the grid draws at the drop
|
||||||
|
* point, so the block settles under the finger.
|
||||||
|
*/
|
||||||
|
internal fun resizedDrop(
|
||||||
|
event: EventInstance,
|
||||||
|
edge: ResizeEdge?,
|
||||||
|
date: LocalDate,
|
||||||
|
startMin: Int,
|
||||||
|
spanMin: Int,
|
||||||
|
zone: TimeZone,
|
||||||
|
): TimelineResizeDrop {
|
||||||
|
val drawn = wallClockSpan(event, zone)
|
||||||
|
return TimelineResizeDrop(
|
||||||
|
event = event,
|
||||||
|
start = if (edge == ResizeEdge.Top) {
|
||||||
|
event.start.toFormTime(zone).repicked(
|
||||||
|
wallClockTime(date, startMin),
|
||||||
|
zone,
|
||||||
|
shownAt = wallClockTime(drawn.day, drawn.startMin),
|
||||||
|
notAfter = event.end,
|
||||||
|
).instantIn(zone)
|
||||||
|
} else {
|
||||||
|
event.start
|
||||||
|
},
|
||||||
|
end = if (edge == ResizeEdge.Bottom) {
|
||||||
|
val picked = wallClockTime(date, startMin + spanMin)
|
||||||
|
val pickedMin = (picked.date.toEpochDays() - drawn.day.toEpochDays()).toInt() * MINUTES_PER_DAY +
|
||||||
|
picked.time.toSecondOfDay() / 60
|
||||||
|
event.end.toFormTime(zone).repicked(
|
||||||
|
picked,
|
||||||
|
zone,
|
||||||
|
shownAt = wallClockTime(drawn.day, drawn.endMin),
|
||||||
|
notBefore = event.start,
|
||||||
|
drawable = { wallClockEndMinutes(drawn.startMin, event.start, it, drawn.day, zone) == pickedMin },
|
||||||
|
).instantIn(zone)
|
||||||
|
} else {
|
||||||
|
event.end
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The span after dragging [edge] to [edgeMin] in day [edgeDay], in minutes from the
|
||||||
|
* start day's midnight. Snapped, and at least one snap step long.
|
||||||
|
*/
|
||||||
|
internal fun resizedSpan(
|
||||||
|
edge: ResizeEdge,
|
||||||
|
startMin: Int,
|
||||||
|
endMin: Int,
|
||||||
|
edgeMin: Float,
|
||||||
|
edgeDay: Int? = null,
|
||||||
|
): Pair<Int, Int> {
|
||||||
|
// An edge dragged into a day past the other end changes nothing.
|
||||||
|
if (edgeDay != null) {
|
||||||
|
val endDay = (endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||||
|
val wrongWay = when (edge) {
|
||||||
|
ResizeEdge.Top -> edgeDay > endDay
|
||||||
|
ResizeEdge.Bottom -> edgeDay < startMin.floorDiv(MINUTES_PER_DAY)
|
||||||
|
}
|
||||||
|
if (wrongWay) return startMin to endMin
|
||||||
|
}
|
||||||
|
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||||
|
return when (edge) {
|
||||||
|
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
|
||||||
|
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [minutes] snapped, kept at [held] until three quarters of a step away. */
|
||||||
|
internal fun stickySnap(minutes: Float, held: Int?): Int {
|
||||||
|
if (held != null && kotlin.math.abs(minutes - held) < DRAG_SNAP_MINUTES * 0.75f) return held
|
||||||
|
return (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far past its column's side the finger has to go before a resize changes day. */
|
||||||
|
private const val COLUMN_HYSTERESIS = 0.25f
|
||||||
|
|
||||||
|
/** The column under [position], kept at [held] until [COLUMN_HYSTERESIS] into another. */
|
||||||
|
internal fun stickyColumn(position: Float, held: Int?, lastIndex: Int): Int {
|
||||||
|
val under = position.toInt().coerceIn(0, lastIndex)
|
||||||
|
if (held == null) return under
|
||||||
|
val stays = position >= held - COLUMN_HYSTERESIS && position < held + 1 + COLUMN_HYSTERESIS
|
||||||
|
return if (stays) held else under
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The timeline's live geometry, republished on every layout. Plain fields rather
|
* The timeline's live geometry, republished on every layout. Plain fields rather
|
||||||
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
||||||
@@ -320,10 +485,92 @@ class TimelineDragController {
|
|||||||
*/
|
*/
|
||||||
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
||||||
|
|
||||||
|
/** How long a block drafted on an empty slot starts out, in minutes (#153). */
|
||||||
|
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
|
||||||
|
|
||||||
|
/** The selected event, if any — see [TimelineSelection]. */
|
||||||
|
var selection: TimelineSelection? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun select(event: EventInstance, moved: Boolean = false, fromLift: Boolean = false) {
|
||||||
|
if (selection?.event?.instanceId != event.instanceId) selectionCovered = false
|
||||||
|
selection = TimelineSelection(event, moved, fromLift)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deselect() {
|
||||||
|
if (selection?.event?.isDraft == true && settling?.event?.isDraft == true) release()
|
||||||
|
selection = null
|
||||||
|
selectionCovered = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The events of the blocks on screen that show the selection; none for a while ends it. */
|
||||||
|
val selectedCandidates = mutableStateListOf<EventInstance>()
|
||||||
|
|
||||||
|
fun noteSelectedBlock(event: EventInstance, shown: Boolean) {
|
||||||
|
if (shown) selectedCandidates.add(event) else selectedCandidates.remove(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once a move of the selected event has settled, point the selection at the
|
||||||
|
* row it landed as. A new event id wins: a single moved occurrence is written
|
||||||
|
* as an exception, and another occurrence of its series may share its time.
|
||||||
|
*/
|
||||||
|
fun settleMovedSelection() {
|
||||||
|
val current = selection?.takeIf { it.moved } ?: return
|
||||||
|
if (isDragging || settling != null) return
|
||||||
|
val landed = selectedCandidates.filter { it.instanceId != current.event.instanceId }
|
||||||
|
// Only the original row left: the move was abandoned, and it still stands.
|
||||||
|
val chosen = landed.firstOrNull { it.eventId != current.event.eventId }
|
||||||
|
?: landed.firstOrNull() ?: selectedCandidates.firstOrNull() ?: return
|
||||||
|
selection = current.copy(event = chosen, moved = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the selected block's bottom sits where the action bar would cover it. */
|
||||||
|
var selectionCovered: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [block] is the selected event: by instance, by series and start, and
|
||||||
|
* just after a move by title and times, since a moved occurrence gets new ids.
|
||||||
|
*/
|
||||||
|
fun isSelected(block: TimedBlock): Boolean {
|
||||||
|
val current = selection ?: return false
|
||||||
|
val selected = current.event
|
||||||
|
val event = block.event
|
||||||
|
return event.instanceId == selected.instanceId ||
|
||||||
|
(event.eventId == selected.eventId && event.start == selected.start) ||
|
||||||
|
(
|
||||||
|
current.moved &&
|
||||||
|
event.calendarId == selected.calendarId &&
|
||||||
|
event.title.isNotBlank() &&
|
||||||
|
event.title == selected.title &&
|
||||||
|
event.start == selected.start &&
|
||||||
|
event.end == selected.end
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point the selection at [block]'s event, so the bar acts on the row now there. */
|
||||||
|
fun adoptSelection(block: TimedBlock) {
|
||||||
|
val current = selection?.takeIf { !it.moved } ?: return
|
||||||
|
if (current.event != block.event) selection = current.copy(event = block.event)
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
||||||
var isDragging: Boolean by mutableStateOf(false)
|
var isDragging: Boolean by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Whether the finger has moved since the pickup; only then may the view auto-scroll. */
|
||||||
|
var pointerMoved: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Touch slop, past which the finger counts as having moved. */
|
||||||
|
var slopPx: Float = 0f
|
||||||
|
|
||||||
|
private var pickupPointer = Offset.Zero
|
||||||
|
|
||||||
|
/** The end a settling resize asked for, which the grid has to show before it settles. */
|
||||||
|
private var settledEnd: Instant? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the grid itself now draws the settled drop at its landing slot.
|
* Whether the grid itself now draws the settled drop at its landing slot.
|
||||||
* The copy may only be handed back once this is true, or the source ghost
|
* The copy may only be handed back once this is true, or the source ghost
|
||||||
@@ -332,6 +579,10 @@ class TimelineDragController {
|
|||||||
var settledOnGrid: Boolean by mutableStateOf(false)
|
var settledOnGrid: Boolean by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Whether the settling drop is the selected event, whose block lands still lifted. */
|
||||||
|
var settlingSelected: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The abandoned-move counter, read at the drop so the mark can't be a
|
* The abandoned-move counter, read at the drop so the mark can't be a
|
||||||
* composition behind the tick it is compared against.
|
* composition behind the tick it is compared against.
|
||||||
@@ -353,6 +604,10 @@ class TimelineDragController {
|
|||||||
* finger is on; this is what turns where it lands back into an event start.
|
* finger is on; this is what turns where it lands back into an event start.
|
||||||
*/
|
*/
|
||||||
private var clipOffsetMin = 0
|
private var clipOffsetMin = 0
|
||||||
|
private var dragZone: TimeZone? = null
|
||||||
|
private var dragDuration = Duration.ZERO
|
||||||
|
/** The dragged event's pass of a repeated hour, worked out once per drag (#387). */
|
||||||
|
private var dragKept: UtcOffset? = null
|
||||||
|
|
||||||
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
|
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
|
||||||
private var titleLines = 0
|
private var titleLines = 0
|
||||||
@@ -361,44 +616,93 @@ class TimelineDragController {
|
|||||||
* The slot the block already occupied when it was picked up — not the same
|
* The slot the block already occupied when it was picked up — not the same
|
||||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||||
*/
|
*/
|
||||||
private var originSlot: Pair<LocalDate, Int>? = null
|
private var originSlot: Triple<LocalDate, Int, Int>? = null
|
||||||
|
|
||||||
|
/** The edge a resize is dragging, or null for a plain drag (#141). */
|
||||||
|
private var resizeEdge: ResizeEdge? = null
|
||||||
|
|
||||||
|
/** The day the resized event starts on; its edges are minutes from that midnight. */
|
||||||
|
private var resizeDay: LocalDate? = null
|
||||||
|
private var resizeStartMin = 0
|
||||||
|
private var resizeEndMin = 0
|
||||||
|
|
||||||
|
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
|
||||||
|
private var resizeGrabMin = 0f
|
||||||
|
|
||||||
|
/** The column the resized edge is in, held until the finger is well into another. */
|
||||||
|
private var resizeColumn: Int? = null
|
||||||
|
|
||||||
|
/** The slot the resized edge sits on, held against a finger trembling between two. */
|
||||||
|
private var resizeSlot: Int? = null
|
||||||
|
|
||||||
|
/** The day, counted from the event's start day, the finger is resizing in. */
|
||||||
|
private var resizeEdgeDay = 0
|
||||||
|
|
||||||
fun begin(
|
fun begin(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
clipOffsetMin: Int,
|
clipOffsetMin: Int,
|
||||||
|
zone: TimeZone,
|
||||||
titleLines: Int,
|
titleLines: Int,
|
||||||
pointerInRoot: Offset,
|
pointerInRoot: Offset,
|
||||||
blockInRoot: Offset,
|
blockInRoot: Offset,
|
||||||
) {
|
) {
|
||||||
|
// The selected event keeps its selection through a drag; any other ends it.
|
||||||
|
if (!isSelected(block)) deselect()
|
||||||
source = block
|
source = block
|
||||||
this.clipOffsetMin = clipOffsetMin
|
this.clipOffsetMin = clipOffsetMin
|
||||||
|
dragZone = zone
|
||||||
|
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
|
||||||
|
dragKept = zone.repeatedHourOffsetAt(block.event.start)
|
||||||
this.titleLines = titleLines
|
this.titleLines = titleLines
|
||||||
settling = null
|
settling = null
|
||||||
isDragging = true
|
isDragging = true
|
||||||
liftedInstanceId = block.event.instanceId
|
liftedInstanceId = block.event.instanceId
|
||||||
grab = pointerInRoot - blockInRoot
|
grab = pointerInRoot - blockInRoot
|
||||||
pointer = pointerInRoot
|
pointer = pointerInRoot
|
||||||
|
pickupPointer = pointerInRoot
|
||||||
|
pointerMoved = false
|
||||||
originSlot = null
|
originSlot = null
|
||||||
recompute()
|
recompute()
|
||||||
originSlot = drag?.slot
|
originSlot = drag?.slot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draft a block on an empty slot and lift it at once (#153). It is selected
|
||||||
|
* from the start, so it keeps its handles and bar once the finger lets go.
|
||||||
|
*/
|
||||||
|
fun beginDraft(block: TimedBlock, zone: TimeZone, pointerInRoot: Offset, blockInRoot: Offset) {
|
||||||
|
deselect()
|
||||||
|
select(block.event)
|
||||||
|
begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
|
||||||
|
}
|
||||||
|
|
||||||
fun move(pointerInRoot: Offset) {
|
fun move(pointerInRoot: Offset) {
|
||||||
pointer = pointerInRoot
|
pointer = pointerInRoot
|
||||||
|
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
|
||||||
recompute()
|
recompute()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancel() {
|
fun cancel() {
|
||||||
|
resizeEdge = null
|
||||||
|
resizeDay = null
|
||||||
|
resizeColumn = null
|
||||||
|
resizeSlot = null
|
||||||
source = null
|
source = null
|
||||||
clipOffsetMin = 0
|
clipOffsetMin = 0
|
||||||
|
dragZone = null
|
||||||
|
dragDuration = Duration.ZERO
|
||||||
|
dragKept = null
|
||||||
titleLines = 0
|
titleLines = 0
|
||||||
isDragging = false
|
isDragging = false
|
||||||
|
pointerMoved = false
|
||||||
liftedInstanceId = null
|
liftedInstanceId = null
|
||||||
originSlot = null
|
originSlot = null
|
||||||
drag = null
|
drag = null
|
||||||
settling = null
|
settling = null
|
||||||
settledOnGrid = false
|
settledOnGrid = false
|
||||||
settledOrigin = null
|
settledOrigin = null
|
||||||
|
settledEnd = null
|
||||||
|
settlingSelected = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -429,8 +733,11 @@ class TimelineDragController {
|
|||||||
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
|
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
|
||||||
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
|
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
|
||||||
.firstOrNull() ?: return
|
.firstOrNull() ?: return
|
||||||
|
val end = settledEnd
|
||||||
settledOnGrid = blocks.any { block ->
|
settledOnGrid = blocks.any { block ->
|
||||||
block.startMin == slice.startMin &&
|
block.startMin == slice.startMin &&
|
||||||
|
// A resize may keep its start, so its end has to have landed too.
|
||||||
|
(end == null || block.event.end == end) &&
|
||||||
(
|
(
|
||||||
block.event.eventId == landed.event.eventId ||
|
block.event.eventId == landed.event.eventId ||
|
||||||
(
|
(
|
||||||
@@ -452,6 +759,9 @@ class TimelineDragController {
|
|||||||
val clipOffset = clipOffsetMin
|
val clipOffset = clipOffsetMin
|
||||||
cancel()
|
cancel()
|
||||||
if (landed == null || landed.slot == origin) return null
|
if (landed == null || landed.slot == origin) return null
|
||||||
|
// A draft dropped on a day off screen would vanish, so it stays where it was (#153).
|
||||||
|
if (landed.edgeOnly && landed.event.isDraft) return null
|
||||||
|
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||||
// An edge piece is a hint for the finger, not a preview of the landing
|
// An edge piece is a hint for the finger, not a preview of the landing
|
||||||
// slot: the event belongs to a day this timeline has no column for, so
|
// slot: the event belongs to a day this timeline has no column for, so
|
||||||
// [noteGrid] can never seat it and it would sit at the top of today for
|
// [noteGrid] can never seat it and it would sit at the top of today for
|
||||||
@@ -463,6 +773,141 @@ class TimelineDragController {
|
|||||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grab one edge of [block] to resize it (#141). The edge follows the finger
|
||||||
|
* across days as a drag does; the other stays where it is.
|
||||||
|
*/
|
||||||
|
fun beginResize(
|
||||||
|
block: TimedBlock,
|
||||||
|
edge: ResizeEdge,
|
||||||
|
zone: TimeZone,
|
||||||
|
titleLines: Int,
|
||||||
|
pointerInRoot: Offset,
|
||||||
|
) {
|
||||||
|
val span = wallClockSpan(block.event, zone)
|
||||||
|
source = block
|
||||||
|
resizeEdge = edge
|
||||||
|
resizeDay = span.day
|
||||||
|
resizeStartMin = span.startMin
|
||||||
|
resizeEndMin = span.endMin
|
||||||
|
clipOffsetMin = 0
|
||||||
|
this.titleLines = titleLines
|
||||||
|
settling = null
|
||||||
|
isDragging = true
|
||||||
|
pointerMoved = true
|
||||||
|
liftedInstanceId = block.event.instanceId
|
||||||
|
pointer = pointerInRoot
|
||||||
|
// Measured from the edge's own column: the handle overhangs into the next.
|
||||||
|
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||||
|
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
|
||||||
|
resizeColumn = edgeIndex.takeIf { it >= 0 }
|
||||||
|
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
|
||||||
|
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
|
||||||
|
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
|
||||||
|
originSlot = null
|
||||||
|
recompute()
|
||||||
|
originSlot = drag?.slot
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a resize, handing back the event's new span — null when it never
|
||||||
|
* resolved or came back to the length it started at. Settles as [finish] does.
|
||||||
|
*/
|
||||||
|
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
|
||||||
|
val landed = drag
|
||||||
|
val origin = originSlot
|
||||||
|
val edge = resizeEdge
|
||||||
|
cancel()
|
||||||
|
if (landed == null || landed.slot == origin) return null
|
||||||
|
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
|
||||||
|
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||||
|
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
|
||||||
|
liftedInstanceId = landed.event.instanceId
|
||||||
|
settledOrigin = landed.event.eventId to landed.event.start
|
||||||
|
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||||
|
settledEnd = drop.end
|
||||||
|
return drop
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the finger is, in minutes from the resized event's start day —
|
||||||
|
* past 1440 in a later column, negative in an earlier one.
|
||||||
|
*/
|
||||||
|
private fun pointerResizeMinutes(): Float? {
|
||||||
|
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
|
||||||
|
val day = resizeDay ?: return null
|
||||||
|
val hourPx = geometry.hourPx
|
||||||
|
val columnPx = geometry.columnWidthPx
|
||||||
|
val days = geometry.days
|
||||||
|
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null
|
||||||
|
val origin = grid.positionInRoot()
|
||||||
|
val column = stickyColumn(
|
||||||
|
position = (pointer.x - origin.x) / columnPx,
|
||||||
|
held = resizeColumn,
|
||||||
|
lastIndex = days.lastIndex,
|
||||||
|
)
|
||||||
|
resizeColumn = column
|
||||||
|
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||||
|
val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt()
|
||||||
|
resizeEdgeDay = dayOffset
|
||||||
|
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recomputeResize(edge: ResizeEdge) {
|
||||||
|
val block = source ?: return
|
||||||
|
val day = resizeDay ?: return
|
||||||
|
val raw = pointerResizeMinutes() ?: return
|
||||||
|
val slot = stickySnap(raw - resizeGrabMin, resizeSlot)
|
||||||
|
resizeSlot = slot
|
||||||
|
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
|
||||||
|
val days = geometry.days
|
||||||
|
val hourPx = geometry.hourPx
|
||||||
|
val columnPx = geometry.columnWidthPx
|
||||||
|
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
|
||||||
|
// Which column the start day sits in, possibly off either end of the view.
|
||||||
|
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
|
||||||
|
val visible = -startIndex..days.lastIndex - startIndex
|
||||||
|
val slices = dragSlices(start, end - start, within = visible)
|
||||||
|
drag = TimelineDrag(
|
||||||
|
event = block.event,
|
||||||
|
date = day,
|
||||||
|
startMin = start,
|
||||||
|
eventStartMin = start,
|
||||||
|
eventSpanMin = end - start,
|
||||||
|
titleLines = titleLines,
|
||||||
|
pieces = slices.map { slice ->
|
||||||
|
val dayIndex = startIndex + slice.dayOffset
|
||||||
|
val col = if (geometry.isRtl) days.lastIndex - dayIndex else dayIndex
|
||||||
|
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
|
||||||
|
TimelineDragPiece(
|
||||||
|
topLeftInRoot = Offset(x = origin.x + col * columnPx, y = origin.y + bounds.top),
|
||||||
|
sizePx = IntSize(
|
||||||
|
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||||
|
bounds.height.roundToInt(),
|
||||||
|
),
|
||||||
|
continuesBefore = slice.continuesBefore,
|
||||||
|
continuesAfter = slice.continuesAfter,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
edgeOnly = slices.isEmpty(),
|
||||||
|
markerMin = if (edge == ResizeEdge.Top) {
|
||||||
|
start.mod(MINUTES_PER_DAY)
|
||||||
|
} else {
|
||||||
|
end - (end - 1).floorDiv(MINUTES_PER_DAY) * MINUTES_PER_DAY
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Note whether the action bar would cover the selected block's bottom handle (#141). */
|
||||||
|
fun noteSelectedBottom(block: LayoutCoordinates, extraPx: Float, clearancePx: Float) {
|
||||||
|
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return
|
||||||
|
if (!block.isAttached) return
|
||||||
|
val port = viewport.boundsInRoot()
|
||||||
|
val bottom = block.positionInRoot().y + block.size.height + extraPx
|
||||||
|
val covered = bottom > port.bottom - clearancePx
|
||||||
|
if (covered != selectionCovered) selectionCovered = covered
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop ghosting the source — the grid draws the drop itself by now — while
|
* Stop ghosting the source — the grid draws the drop itself by now — while
|
||||||
* the copy is still dissolving. Un-ghosting at [release] instead would leave
|
* the copy is still dissolving. Un-ghosting at [release] instead would leave
|
||||||
@@ -477,6 +922,8 @@ class TimelineDragController {
|
|||||||
fun release() {
|
fun release() {
|
||||||
settling = null
|
settling = null
|
||||||
settledOnGrid = false
|
settledOnGrid = false
|
||||||
|
settledEnd = null
|
||||||
|
settlingSelected = false
|
||||||
handOver()
|
handOver()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +933,10 @@ class TimelineDragController {
|
|||||||
* no move events while auto-scroll walks the content underneath it.
|
* no move events while auto-scroll walks the content underneath it.
|
||||||
*/
|
*/
|
||||||
fun recompute() {
|
fun recompute() {
|
||||||
|
resizeEdge?.let {
|
||||||
|
recomputeResize(it)
|
||||||
|
return
|
||||||
|
}
|
||||||
val block = source ?: return
|
val block = source ?: return
|
||||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||||
val hourPx = geometry.hourPx
|
val hourPx = geometry.hourPx
|
||||||
@@ -496,13 +947,22 @@ class TimelineDragController {
|
|||||||
val origin = grid.positionInRoot()
|
val origin = grid.positionInRoot()
|
||||||
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
||||||
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||||
val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt()
|
val zone = dragZone ?: return
|
||||||
.coerceAtLeast(0)
|
|
||||||
val floor = dragFloorMin(clipOffsetMin, eventSpan)
|
|
||||||
val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
|
||||||
val eventStartMin = startMin - clipOffsetMin
|
|
||||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||||
|
val target = days[dayIndex]
|
||||||
|
val keep = dragKept
|
||||||
|
// Measured where it would land: the drop keeps the real duration, which in
|
||||||
|
// wall clock gains or loses the hour of a DST change it crosses (#387).
|
||||||
|
val floor = dragFloorMin(
|
||||||
|
clipOffsetMin,
|
||||||
|
wallClockSpanMinutes(target, snapped - clipOffsetMin, dragDuration, zone, keep),
|
||||||
|
)
|
||||||
|
val held = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) - clipOffsetMin
|
||||||
|
// A start in the spring-forward gap lands an hour on; show it there.
|
||||||
|
val eventStartMin = wallClockInstant(target, held, zone, keep).minutesPast(target, zone)
|
||||||
|
val startMin = (eventStartMin + clipOffsetMin).coerceAtMost(MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||||
|
val eventSpan = wallClockSpanMinutes(target, eventStartMin, dragDuration, zone, keep)
|
||||||
val visible = -dayIndex..days.lastIndex - dayIndex
|
val visible = -dayIndex..days.lastIndex - dayIndex
|
||||||
val onGrid = dragSlices(eventStartMin, eventSpan, within = visible)
|
val onGrid = dragSlices(eventStartMin, eventSpan, within = visible)
|
||||||
val slices = onGrid.ifEmpty {
|
val slices = onGrid.ifEmpty {
|
||||||
@@ -577,6 +1037,7 @@ fun rememberTimelineDragController(): TimelineDragController {
|
|||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||||
|
controller.slopPx = LocalViewConfiguration.current.touchSlop
|
||||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
@@ -589,10 +1050,7 @@ fun rememberTimelineDragController(): TimelineDragController {
|
|||||||
* so the offset has to come back off at the drop (#253).
|
* so the offset has to come back off at the drop (#253).
|
||||||
*/
|
*/
|
||||||
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
|
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
|
||||||
val start = event.start.toLocalDateTime(zone)
|
return (startMin - event.start.minutesPast(day, zone)).coerceAtLeast(0)
|
||||||
val days = (day.toEpochDays() - start.date.toEpochDays()).toInt()
|
|
||||||
val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60
|
|
||||||
return minutes.coerceAtLeast(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -613,15 +1071,58 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
|
|||||||
* of its own; `toInstant` resolves it forward by the missing hour, and the block
|
* of its own; `toInstant` resolves it forward by the missing hour, and the block
|
||||||
* visibly settles there.
|
* visibly settles there.
|
||||||
*/
|
*/
|
||||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant {
|
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||||
// Wall clock throughout, and past this day at either end: a block's top edge
|
wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
|
||||||
// can be dragged above its midnight and a tail's below the next one, so the
|
|
||||||
// start is resolved as a day plus a minute of it rather than as an offset
|
/**
|
||||||
// from an instant — which would shift by an hour across a DST boundary.
|
* The wall-clock minutes an event lasting [duration] covers when it starts
|
||||||
val fromMidnight = startMin - clipOffsetMin
|
* [startMin] past [date]'s midnight in [zone] — its real length, give or take a
|
||||||
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
* DST change in between (#387).
|
||||||
val minute = fromMidnight.mod(MINUTES_PER_DAY)
|
*/
|
||||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
|
internal fun wallClockSpanMinutes(
|
||||||
|
date: LocalDate,
|
||||||
|
startMin: Int,
|
||||||
|
duration: Duration,
|
||||||
|
zone: TimeZone,
|
||||||
|
kept: UtcOffset? = null,
|
||||||
|
): Int {
|
||||||
|
val start = wallClockInstant(date, startMin, zone, kept)
|
||||||
|
return wallClockEndMinutes(startMin, start, start + duration, date, zone) - startMin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [minutes] past [date]'s midnight, either way past it, as wall clock in [zone]
|
||||||
|
* (DST-safe). A time the fall-back hour repeats lands on its earlier pass, unless
|
||||||
|
* [kept] is its later one: an event there stays on it (#387). Pass
|
||||||
|
* [repeatedHourOffsetAt] of the event's time, which is null outside the hour —
|
||||||
|
* such an instant says nothing about which pass the grid slot means, so a drop
|
||||||
|
* never depends on the season the event came from.
|
||||||
|
*/
|
||||||
|
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone, kept: UtcOffset? = null): Instant =
|
||||||
|
wallClockTime(date, minutes).resolveIn(zone, kept)
|
||||||
|
|
||||||
|
/** The wall-clock time [minutes] past [date]'s midnight, on a later day past 1440. */
|
||||||
|
fun wallClockTime(date: LocalDate, minutes: Int): LocalDateTime {
|
||||||
|
val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
||||||
|
val minute = minutes.mod(MINUTES_PER_DAY)
|
||||||
|
return LocalDateTime(day, LocalTime(minute / 60, minute % 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An event's span as wall-clock minutes from its start day's midnight. */
|
||||||
|
data class WallClockSpan(val day: LocalDate, val startMin: Int, val endMin: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [event]'s [WallClockSpan], its end past 1440 when it ends on a later day
|
||||||
|
* (DST-safe). One ending in the fall-back hour's repeat ends where that hour does.
|
||||||
|
*/
|
||||||
|
fun wallClockSpan(event: EventInstance, zone: TimeZone): WallClockSpan {
|
||||||
|
val start = event.start.toLocalDateTime(zone)
|
||||||
|
val startMin = start.time.toSecondOfDay() / 60
|
||||||
|
return WallClockSpan(
|
||||||
|
start.date,
|
||||||
|
startMin,
|
||||||
|
wallClockEndMinutes(startMin, event.start, event.end, start.date, zone),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -660,8 +1161,10 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
// Both live here rather than beside the controller: they read [drag], which
|
// Both live here rather than beside the controller: they read [drag], which
|
||||||
// changes every frame, and this composable is the one that is meant to.
|
// changes every frame, and this composable is the one that is meant to.
|
||||||
DragSnapHaptics(controller.drag?.slot)
|
DragSnapHaptics(controller.drag?.slot)
|
||||||
LaunchedEffect(controller.isDragging) {
|
// Only once the finger has moved: a long press held near an edge must not
|
||||||
if (controller.isDragging) controller.autoScroll()
|
// scroll the drop target out from under a finger that only means to select.
|
||||||
|
LaunchedEffect(controller.isDragging && controller.pointerMoved) {
|
||||||
|
if (controller.isDragging && controller.pointerMoved) controller.autoScroll()
|
||||||
}
|
}
|
||||||
// Hold the landed copy until the write is done — including the time a
|
// Hold the landed copy until the write is done — including the time a
|
||||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||||
@@ -693,7 +1196,8 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||||
val landed = controller.drag == null
|
val landed = controller.drag == null
|
||||||
val lift by animateFloatAsState(
|
val lift by animateFloatAsState(
|
||||||
targetValue = if (landed || reduceMotion) 0f else 1f,
|
// A selected event's block is lifted too: sinking first made it shrink, then grow back.
|
||||||
|
targetValue = if (reduceMotion || (landed && !controller.settlingSelected)) 0f else 1f,
|
||||||
label = "drag-lift",
|
label = "drag-lift",
|
||||||
)
|
)
|
||||||
val copyAlpha by animateFloatAsState(
|
val copyAlpha by animateFloatAsState(
|
||||||
@@ -870,7 +1374,7 @@ private fun DragCopy(
|
|||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
// As the block it lifted off sets its own time (#219).
|
// As the block it lifted off sets its own time (#219).
|
||||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime()),
|
||||||
maxLines = timeMaxLines,
|
maxLines = timeMaxLines,
|
||||||
overflow = overflow.overflow,
|
overflow = overflow.overflow,
|
||||||
softWrap = overflow.softWrap,
|
softWrap = overflow.softWrap,
|
||||||
@@ -880,3 +1384,30 @@ private fun DragCopy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How far a selected block is lifted, 0 to 1 (#141); [snap] takes it over at once. */
|
||||||
|
@Composable
|
||||||
|
fun selectionLift(selected: Boolean, snap: Boolean = false): Float {
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val spec = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
|
||||||
|
val raise = remember { Animatable(if (selected) 1f else 0f) }
|
||||||
|
LaunchedEffect(selected) {
|
||||||
|
val target = if (selected) 1f else 0f
|
||||||
|
if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec)
|
||||||
|
}
|
||||||
|
// The effect runs a frame late, and that frame would show the block unlifted.
|
||||||
|
return if (selected && snap) 1f else raise.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A selected block raised off the grid, the way a dragged copy is: scaled from
|
||||||
|
* its top edge so the text doesn't creep, with a shadow in the block's shape.
|
||||||
|
*/
|
||||||
|
fun Modifier.selectionLift(raise: Float, shape: Shape): Modifier = graphicsLayer {
|
||||||
|
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||||
|
scaleX = 1f + 0.02f * raise
|
||||||
|
scaleY = 1f + 0.02f * raise
|
||||||
|
shadowElevation = 6.dp.toPx() * raise
|
||||||
|
this.shape = shape
|
||||||
|
clip = false
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.detail
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -41,7 +37,6 @@ import androidx.compose.material.icons.filled.Public
|
|||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
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.IconButton
|
||||||
@@ -51,7 +46,6 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -67,7 +61,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
@@ -90,13 +83,15 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||||
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
||||||
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
|
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
|
||||||
@@ -104,10 +99,10 @@ import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
|||||||
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.floret.components.OptionCard
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -162,43 +157,15 @@ fun EventDetailScreen(
|
|||||||
val onShareClick = {
|
val onShareClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val uri = viewModel.shareUri()
|
val uri = viewModel.shareUri()
|
||||||
val sent = uri != null && runCatching {
|
val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
|
||||||
val send = Intent(Intent.ACTION_SEND).apply {
|
|
||||||
type = "text/calendar"
|
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
context.startActivity(Intent.createChooser(send, shareChooserTitle))
|
|
||||||
}.isSuccess
|
|
||||||
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||||
}
|
}
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
// upgrade in place. Granting continues straight into the tapped action —
|
// upgrade in place and continues straight into the tapped action.
|
||||||
// edit, delete, or duplicate — held here until the result comes back.
|
val requireWrite = rememberWriteGate()
|
||||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
|
||||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
|
||||||
) { granted ->
|
|
||||||
if (granted) pendingWrite?.invoke()
|
|
||||||
pendingWrite = null
|
|
||||||
}
|
|
||||||
val hasWritePermission = {
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
Manifest.permission.WRITE_CALENDAR,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
|
||||||
if (hasWritePermission()) {
|
|
||||||
action()
|
|
||||||
} else {
|
|
||||||
pendingWrite = action
|
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||||
val onEditClick = { requireWrite(onEdit) }
|
val onEditClick = { requireWrite(onEdit) }
|
||||||
// Duplicate reads the loaded detail into a create form and hands it up; the
|
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||||
@@ -322,70 +289,6 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete confirmation. Recurring events choose between cancelling just the
|
|
||||||
* tapped occurrence (default), truncating the series from it onwards, and
|
|
||||||
* removing the whole series.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun DeleteEventDialog(
|
|
||||||
isRecurring: Boolean,
|
|
||||||
onConfirm: (RecurringWriteScope) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
if (isRecurring) R.string.event_delete_recurring_title
|
|
||||||
else R.string.event_delete_title,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
if (isRecurring) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_occurrence),
|
|
||||||
onClick = { scope = RecurringWriteScope.ThisEvent },
|
|
||||||
selected = scope == RecurringWriteScope.ThisEvent,
|
|
||||||
)
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_following),
|
|
||||||
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
|
|
||||||
selected = scope == RecurringWriteScope.ThisAndFollowing,
|
|
||||||
)
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_series),
|
|
||||||
onClick = { scope = RecurringWriteScope.AllEvents },
|
|
||||||
selected = scope == RecurringWriteScope.AllEvents,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(stringResource(R.string.event_delete_body))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.event_detail_delete),
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.dialog_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventDetailContent(
|
private fun EventDetailContent(
|
||||||
state: EventDetailUiState.Success,
|
state: EventDetailUiState.Success,
|
||||||
@@ -409,15 +312,26 @@ private fun EventDetailContent(
|
|||||||
// cancelled event strikes through its title.
|
// cancelled event strikes through its title.
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
val titleLabel = stringResource(R.string.event_detail_title)
|
val titleLabel = stringResource(R.string.event_detail_title)
|
||||||
|
val titleDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||||
|
TextDecoration.LineThrough
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
// A phone number in the title ("Call Anna 0151 …") dials too.
|
||||||
|
text = if (instance.title.isBlank()) {
|
||||||
|
AnnotatedString(stringResource(R.string.event_untitled))
|
||||||
|
} else {
|
||||||
|
linkify(
|
||||||
|
instance.title,
|
||||||
|
state.phoneLinks.title,
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
titleDecoration,
|
||||||
|
)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
textDecoration = if (detail.status == EventStatus.Cancelled) {
|
textDecoration = titleDecoration,
|
||||||
TextDecoration.LineThrough
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.then(
|
.then(
|
||||||
@@ -464,7 +378,7 @@ private fun EventDetailContent(
|
|||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
// "When" — date/all-day plus the time range.
|
// "When" — date/all-day plus the time range.
|
||||||
val (whenPrimary, whenSecondary) = formatWhen(instance, TimeZone.currentSystemDefault(), locale)
|
val (whenPrimary, whenSecondary) = rememberWhen(instance, TimeZone.currentSystemDefault(), locale)
|
||||||
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
|
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
|
||||||
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
|
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
|
||||||
if (whenSecondary != null) {
|
if (whenSecondary != null) {
|
||||||
@@ -504,7 +418,7 @@ private fun EventDetailContent(
|
|||||||
// in the primary instead, so fall back to it. If the zone can't be
|
// in the primary instead, so fall back to it. If the zone can't be
|
||||||
// read back at all, drop to just the abbreviation + offset.
|
// read back at all, drop to just the abbreviation + offset.
|
||||||
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
|
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
|
||||||
?.let { formatWhen(instance, it, locale) }
|
?.let { rememberWhen(instance, it, locale) }
|
||||||
?.let { (primary, secondary) -> secondary ?: primary }
|
?.let { (primary, secondary) -> secondary ?: primary }
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -534,7 +448,8 @@ private fun EventDetailContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location (conditional, tap → maps).
|
// Location (conditional). URLs open in the browser, phone numbers in the
|
||||||
|
// dialer, the rest in maps.
|
||||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val locationLabel = stringResource(R.string.event_detail_location)
|
val locationLabel = stringResource(R.string.event_detail_location)
|
||||||
@@ -544,7 +459,7 @@ private fun EventDetailContent(
|
|||||||
iconContentDescription = locationLabel,
|
iconContentDescription = locationLabel,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = location,
|
text = linkify(location, state.phoneLinks.location, MaterialTheme.colorScheme.primary),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -554,13 +469,14 @@ private fun EventDetailContent(
|
|||||||
text = location,
|
text = location,
|
||||||
copy = copyField,
|
copy = copyField,
|
||||||
onTap = { openInMaps(context, location) },
|
onTap = { openInMaps(context, location) },
|
||||||
|
onTapLabel = stringResource(R.string.event_detail_open_in_maps),
|
||||||
)
|
)
|
||||||
.padding(vertical = 2.dp),
|
.padding(vertical = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description (conditional). URLs are auto-linked.
|
// Description (conditional). URLs and phone numbers are auto-linked.
|
||||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||||
val descriptionLabel = stringResource(R.string.event_detail_description)
|
val descriptionLabel = stringResource(R.string.event_detail_description)
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
@@ -573,7 +489,7 @@ private fun EventDetailContent(
|
|||||||
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
|
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
text = linkify(description, state.phoneLinks.description, MaterialTheme.colorScheme.primary),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -846,21 +762,41 @@ private fun foreignTimeZone(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
/**
|
||||||
|
* Wrap http(s) URLs in [text] as links that open in the browser, and its
|
||||||
|
* [phoneLinks] as links that open the dialer, both tinted [linkColor]. A
|
||||||
|
* link's underline joins [textDecoration] rather than replacing it, so a
|
||||||
|
* cancelled title stays struck through across its links.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remember(text, linkColor) {
|
private fun linkify(
|
||||||
val regex = Regex("""https?://\S+""")
|
text: String,
|
||||||
val styles = TextLinkStyles(
|
phoneLinks: List<PhoneLink>,
|
||||||
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
|
linkColor: Color,
|
||||||
)
|
textDecoration: TextDecoration? = null,
|
||||||
buildAnnotatedString {
|
): AnnotatedString {
|
||||||
append(text)
|
val context = LocalContext.current
|
||||||
for (match in regex.findAll(text)) {
|
return remember(text, phoneLinks, linkColor, textDecoration, context) {
|
||||||
// Trim trailing punctuation that commonly abuts a URL in prose.
|
val decoration = textDecoration
|
||||||
val raw = match.value
|
?.let { TextDecoration.combine(listOf(it, TextDecoration.Underline)) }
|
||||||
val url = raw.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '"', '\'')
|
?: TextDecoration.Underline
|
||||||
val end = match.range.first + url.length
|
val styles = TextLinkStyles(style = SpanStyle(color = linkColor, textDecoration = decoration))
|
||||||
addLink(LinkAnnotation.Url(url, styles), match.range.first, end)
|
buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
for (link in findWebLinks(text)) {
|
||||||
|
addLink(
|
||||||
|
LinkAnnotation.Url(link.url, styles) { openWebLink(context, link.url) },
|
||||||
|
link.start,
|
||||||
|
link.end,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (link in phoneLinks) {
|
||||||
|
addLink(
|
||||||
|
LinkAnnotation.Url("tel:${link.number}", styles) { dial(context, link.number) },
|
||||||
|
link.start,
|
||||||
|
link.end,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -871,18 +807,28 @@ private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remem
|
|||||||
* single primary line spanning both ends.
|
* single primary line spanning both ends.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
|
private fun rememberWhen(instance: EventInstance, zone: TimeZone, locale: Locale): Pair<String, String?> {
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||||
|
// Remembered: a time in the repeated hour looks up its zone abbreviation (#387).
|
||||||
|
return remember(instance, zone, locale, use24Hour, allDayLabel) {
|
||||||
|
formatWhen(instance, zone, locale, use24Hour, allDayLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun formatWhen(
|
private fun formatWhen(
|
||||||
instance: EventInstance,
|
instance: EventInstance,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
use24Hour: Boolean,
|
||||||
|
allDayLabel: String,
|
||||||
): Pair<String, String?> {
|
): Pair<String, String?> {
|
||||||
val zid = ZoneId.of(zone.id)
|
val zid = ZoneId.of(zone.id)
|
||||||
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||||
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale)
|
val timeShort = timeOfDayFormatter(use24Hour, locale)
|
||||||
|
|
||||||
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
||||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
|
||||||
|
|
||||||
if (instance.isAllDay) {
|
if (instance.isAllDay) {
|
||||||
// All-day events live at UTC midnights with an exclusive end. Resolve
|
// All-day events live at UTC midnights with an exclusive end. Resolve
|
||||||
@@ -902,12 +848,13 @@ private fun formatWhen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val endLdt = instance.end.toJavaLocalDateTime(zid)
|
val endLdt = instance.end.toJavaLocalDateTime(zid)
|
||||||
|
val startTime = withRepeatedHourZone(timeShort.format(startLdt), instance.start, zone, locale)
|
||||||
|
val endTime = withRepeatedHourZone(timeShort.format(endLdt), instance.end, zone, locale)
|
||||||
return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
|
return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
|
||||||
dateFull.format(startLdt.toLocalDate()) to
|
dateFull.format(startLdt.toLocalDate()) to "$startTime – $endTime"
|
||||||
"${timeShort.format(startLdt)} – ${timeShort.format(endLdt)}"
|
|
||||||
} else {
|
} else {
|
||||||
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}"
|
val start = "${dateMedium.format(startLdt.toLocalDate())} $startTime"
|
||||||
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}"
|
val end = "${dateMedium.format(endLdt.toLocalDate())} $endTime"
|
||||||
"$start – $end" to null
|
"$start – $end" to null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -915,21 +862,27 @@ private fun formatWhen(
|
|||||||
private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
|
private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
|
||||||
java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid)
|
java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid)
|
||||||
|
|
||||||
|
/** Open [url] in a browsable app, or say so when none can take it. */
|
||||||
|
private fun openWebLink(context: Context, url: String) {
|
||||||
|
startOrSayNoApp(context, Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the dialer with [number] filled in, or say so when none can take it. */
|
||||||
|
private fun dial(context: Context, number: String) {
|
||||||
|
startOrSayNoApp(context, Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", number, null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startOrSayNoApp(context: Context, intent: Intent) {
|
||||||
|
if (runCatching { context.startActivity(intent) }.isFailure) {
|
||||||
|
Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Open a maps intent for [query]; fall back to a web maps URL if no app handles geo:. */
|
/** Open a maps intent for [query]; fall back to a web maps URL if no app handles geo:. */
|
||||||
private fun openInMaps(context: Context, query: String) {
|
private fun openInMaps(context: Context, query: String) {
|
||||||
val encoded = Uri.encode(query)
|
val encoded = Uri.encode(query)
|
||||||
val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded"))
|
val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded"))
|
||||||
try {
|
if (runCatching { context.startActivity(geo) }.isFailure) {
|
||||||
context.startActivity(geo)
|
openWebLink(context, "https://www.google.com/maps/search/?api=1&query=$encoded")
|
||||||
} catch (e: ActivityNotFoundException) {
|
|
||||||
val web = Intent(
|
|
||||||
Intent.ACTION_VIEW,
|
|
||||||
Uri.parse("https://www.google.com/maps/search/?api=1&query=$encoded"),
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
context.startActivity(web)
|
|
||||||
} catch (e2: ActivityNotFoundException) {
|
|
||||||
// No browser either — nothing sensible to do; swallow.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ sealed interface EventDetailUiState {
|
|||||||
* the sync would just resurrect it. Contact dates are removed at the source.
|
* the sync would just resurrect it. Contact dates are removed at the source.
|
||||||
*/
|
*/
|
||||||
val isManaged: Boolean = false,
|
val isManaged: Boolean = false,
|
||||||
|
/** Phone numbers to link in the title, location and description. */
|
||||||
|
val phoneLinks: EventPhoneLinks = EventPhoneLinks(),
|
||||||
) : EventDetailUiState
|
) : EventDetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -112,22 +112,11 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
if (_deleteState.value == DeleteUiState.Deleting) return
|
if (_deleteState.value == DeleteUiState.Deleting) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_deleteState.value = DeleteUiState.Deleting
|
_deleteState.value = DeleteUiState.Deleting
|
||||||
_deleteState.value = try {
|
// Already gone is what a delete wants anyway.
|
||||||
when (scope) {
|
_deleteState.value = when (repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)) {
|
||||||
RecurringWriteScope.AllEvents ->
|
DeleteOutcome.Deleted, DeleteOutcome.Gone -> DeleteUiState.Deleted
|
||||||
repository.deleteEvent(target.eventId)
|
DeleteOutcome.NeedsPermission -> DeleteUiState.NeedsPermission
|
||||||
RecurringWriteScope.ThisEvent ->
|
DeleteOutcome.Failed -> DeleteUiState.Failed
|
||||||
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
|
||||||
RecurringWriteScope.ThisAndFollowing ->
|
|
||||||
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
|
|
||||||
}
|
|
||||||
DeleteUiState.Deleted
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
DeleteUiState.NeedsPermission
|
|
||||||
} catch (e: Exception) {
|
|
||||||
DeleteUiState.Failed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,40 +133,16 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
suspend fun shareUri(): Uri? {
|
suspend fun shareUri(): Uri? {
|
||||||
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
|
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
|
||||||
return runCatching {
|
return withContext(io) { icsExporter.stageEventShare(detail) }
|
||||||
withContext(io) {
|
|
||||||
val ics = IcsWriter().writeCalendar(
|
|
||||||
events = listOf(detail.toShareIcsEvent()),
|
|
||||||
dtStamp = Clock.System.now(),
|
|
||||||
)
|
|
||||||
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
|
|
||||||
}
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a create form seeded from the open event so the user can save a
|
* A create form seeded from the open event, to save a copy as a new event
|
||||||
* copy as a new, independent event (#52). Returns null when nothing is
|
* (#52) — see [toDuplicateForm]. Null when nothing is loaded.
|
||||||
* loaded.
|
|
||||||
*
|
|
||||||
* The recurrence is dropped — a duplicate is a single event; anyone who
|
|
||||||
* wants a series can re-add one in the edit form that opens. The source
|
|
||||||
* calendar is kept only when it's writable, otherwise the id is cleared so
|
|
||||||
* the create resolves to the last-used/first-writable calendar — which lets
|
|
||||||
* a read-only event (a WebCal subscription, a birthday) be copied into an
|
|
||||||
* editable calendar. The occurrence's own times carry over unchanged.
|
|
||||||
*/
|
*/
|
||||||
fun duplicateForm(): EventForm? {
|
fun duplicateForm(): EventForm? {
|
||||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||||
val detail = loaded.detail
|
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
|
||||||
return detail.toEditForm(
|
|
||||||
beginMillis = detail.instance.start.toEpochMilliseconds(),
|
|
||||||
endMillis = detail.instance.end.toEpochMilliseconds(),
|
|
||||||
zone = TimeZone.currentSystemDefault(),
|
|
||||||
).copy(
|
|
||||||
rrule = null,
|
|
||||||
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
@@ -204,6 +169,13 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
calendarName = calendar?.displayName,
|
calendarName = calendar?.displayName,
|
||||||
canModify = calendar?.canModifyContents == true,
|
canModify = calendar?.canModifyContents == true,
|
||||||
isManaged = calendar?.isManaged == true,
|
isManaged = calendar?.isManaged == true,
|
||||||
|
// Found here, off the main thread and once per load: the platform
|
||||||
|
// matcher loads its metadata on first use.
|
||||||
|
phoneLinks = findEventPhoneLinks(
|
||||||
|
title = corrected.instance.title,
|
||||||
|
location = corrected.instance.location,
|
||||||
|
description = corrected.description,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -228,13 +200,3 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
|
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
|
||||||
private fun shareFileName(title: String): String {
|
|
||||||
val base = title.trim()
|
|
||||||
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
|
|
||||||
.replace(' ', '_')
|
|
||||||
.take(40)
|
|
||||||
.ifBlank { "event" }
|
|
||||||
return "$base.ics"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import android.text.SpannableString
|
||||||
|
import android.text.style.URLSpan
|
||||||
|
import android.text.util.Linkify
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A phone number found in free text, spanning [start] until [end] (exclusive).
|
||||||
|
* [number] is what the dialer gets: digits, a leading `+`, and any `,`/`#`
|
||||||
|
* dial-in suffix — the separators that only made it readable are gone.
|
||||||
|
*/
|
||||||
|
data class PhoneLink(val start: Int, val end: Int, val number: String)
|
||||||
|
|
||||||
|
/** The phone numbers in an event's free-text fields, each against its own text. */
|
||||||
|
data class EventPhoneLinks(
|
||||||
|
val title: List<PhoneLink> = emptyList(),
|
||||||
|
val location: List<PhoneLink> = emptyList(),
|
||||||
|
val description: List<PhoneLink> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Where the platform matcher saw a phone number: [start] until [end] (exclusive). */
|
||||||
|
internal data class PhoneMatch(val start: Int, val end: Int)
|
||||||
|
|
||||||
|
// A conference dial-in's pauses and meeting id right after the number:
|
||||||
|
// "+16465588656,,85012345678#" or Zoom's ",,850…#,,,,*123456#". It opens on a
|
||||||
|
// double comma or closes on "#", so "030 1234567,0151 2345678" stays a list.
|
||||||
|
private val dialSuffixRegex = Regex("""^(?:,,+[\d*]+#?|,[\d*]+#)(?:,+[\d*]+#?)*""")
|
||||||
|
|
||||||
|
// Whitespace as invites write it: Java's \s misses the no-break space and the
|
||||||
|
// invisible direction marks Meet and Outlook wrap around numbers.
|
||||||
|
private const val GAP = """\s\u00A0\u200E\u200F\u202A-\u202E\u2066-\u2069"""
|
||||||
|
|
||||||
|
// A number right after one of these labels is an id to type in, not one to
|
||||||
|
// call. English and German cover the Zoom/Teams/Meet/Webex invites seen so far.
|
||||||
|
// "Conference number" is left out: invites use it for the dial-in itself.
|
||||||
|
private val notAPhoneLabelRegex = Regex(
|
||||||
|
"""(?:(?:meeting|webinar|besprechungs)[ -]?(?:id|number|nummer)|(?:conference|konferenz)[ -]?id|""" +
|
||||||
|
"""\bpin|passcode|password|passwort|kenncode|access code|zugangscode)[$GAP:#.()]*$""",
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
// A day.month.year (or year-month-day, or US month/day/year) date: exactly
|
||||||
|
// three groups, so a French "01.23.45.67.89" chain doesn't read as one.
|
||||||
|
private val dateRegex = Regex("""(?<![\d./-])(\d{1,4})([./-])(\d{1,2})\2(\d{4}|\d{2})(?![\d]|[./-]\d)""")
|
||||||
|
|
||||||
|
// Thousands groups. A short lead ("1.250.000", "12 500 000") is an amount; a
|
||||||
|
// three-digit lead is one only beside a currency — "612 345 678" and
|
||||||
|
// "612.345.678" are Spanish mobiles, "125.000.000 €" a budget.
|
||||||
|
private val shortLeadAmountRegex = Regex("""[1-9]\d?(?:([. ])\d{3})(?:\1\d{3})*""")
|
||||||
|
private val amountRegex = Regex("""[1-9]\d{0,2}(?:([. ])\d{3})(?:\1\d{3})*""")
|
||||||
|
private val currencyBeforeRegex = Regex("""(?:[€$£]|\b(?:eur|euro|usd|chf|gbp))\s*$""", RegexOption.IGNORE_CASE)
|
||||||
|
private val currencyAfterRegex = Regex("""^\s*(?:[€$£]|(?:eur|euro|usd|chf|gbp)\b)""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
// A short group set off by a space after a long one, with a word straight
|
||||||
|
// after — "… 2345678 2 times" — is the prose's count, not the number's tail.
|
||||||
|
// Without the word ("030 123456 0") or after a short group ("0221 1234 56")
|
||||||
|
// it is the number's own.
|
||||||
|
private val strayTailRegex = Regex("""(?<=\d{6})\s+\d{1,2}$""")
|
||||||
|
private val wordAfterRegex = Regex("""[ \t\u00A0]+\p{L}""")
|
||||||
|
|
||||||
|
// "9.30 – 12.00", "10.00-11.30": times of day written the German way.
|
||||||
|
private val dottedTimesRegex = Regex("""(\d{1,2})\.(\d{2})(?:\s*[-–]\s*(\d{1,2})\.(\d{2}))?""")
|
||||||
|
|
||||||
|
// "ext. 567", "x567", "Durchwahl 567": the dialer reaches an extension after a pause.
|
||||||
|
private val extensionRegex = Regex("""\s*(?:ext\.?|extension|x|durchwahl|dw\.?)\s*(?=\d)""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
private val trunkPrefixRegex = Regex("""\(\s*0\s*\)""")
|
||||||
|
private val strandedCountryCodeRegex = Regex("""\+\d{1,3}\s*\(\s*0\s*\)\s*$""")
|
||||||
|
|
||||||
|
// Short local numbers exist, but in event text a 5- or 6-digit run is far more
|
||||||
|
// often a postcode, house number or booking reference.
|
||||||
|
private const val MIN_DIGITS = 7
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the phone numbers in [text]. The platform's libphonenumber-backed
|
||||||
|
* matcher does the finding — it knows the device's region and real number
|
||||||
|
* lengths; [refinePhoneMatches] then fits what it found to an event's text.
|
||||||
|
* Call off the main thread: the matcher loads its metadata on first use.
|
||||||
|
* No match at all when the platform matcher fails.
|
||||||
|
*/
|
||||||
|
internal fun findPhoneLinks(text: String): List<PhoneLink> =
|
||||||
|
refinePhoneMatches(text, platformPhoneMatches(text), findWebLinks(text))
|
||||||
|
|
||||||
|
/** [findPhoneLinks] for each of an event's free-text fields. */
|
||||||
|
internal fun findEventPhoneLinks(title: String, location: String?, description: String?) = EventPhoneLinks(
|
||||||
|
title = findPhoneLinks(title),
|
||||||
|
location = location?.let(::findPhoneLinks).orEmpty(),
|
||||||
|
description = description?.let(::findPhoneLinks).orEmpty(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The matcher reads the region off the SIM, which throws on a device without
|
||||||
|
// telephony; a field then just goes without phone links.
|
||||||
|
private fun platformPhoneMatches(text: String): List<PhoneMatch> = runCatching {
|
||||||
|
val spannable = SpannableString(text)
|
||||||
|
if (!Linkify.addLinks(spannable, Linkify.PHONE_NUMBERS)) return emptyList()
|
||||||
|
spannable.getSpans(0, spannable.length, URLSpan::class.java)
|
||||||
|
.map { PhoneMatch(spannable.getSpanStart(it), spannable.getSpanEnd(it)) }
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the platform's [matches] in [text] into dialable links. The matcher is
|
||||||
|
* lenient, so a match is dropped when it sits inside one of the [webLinks],
|
||||||
|
* follows a meeting id or PIN label, holds a date, reads as an amount or is
|
||||||
|
* too short. What's kept loses a stray count off its end, regains an
|
||||||
|
* international "+49 (0)" prefix the matcher left behind, and carries a
|
||||||
|
* conference dial-in suffix along.
|
||||||
|
*/
|
||||||
|
internal fun refinePhoneMatches(
|
||||||
|
text: String,
|
||||||
|
matches: List<PhoneMatch>,
|
||||||
|
webLinks: List<WebLink>,
|
||||||
|
): List<PhoneLink> {
|
||||||
|
val links = mutableListOf<PhoneLink>()
|
||||||
|
for (match in matches.sortedBy { it.start }) {
|
||||||
|
var start = match.start
|
||||||
|
var end = match.end
|
||||||
|
val matched = text.substring(start, end)
|
||||||
|
if (webLinks.any { start < it.end && it.start < end }) continue
|
||||||
|
if (gluedIntoAWord(text, start, end)) continue
|
||||||
|
val lineBefore = text.window(lineStart(text, start).coerceAtLeast(start - LOOK_BEHIND), start)
|
||||||
|
if (notAPhoneLabelRegex.containsMatchIn(lineBefore)) continue
|
||||||
|
if (dateRegex.findAll(matched).any { it.isDate() }) continue
|
||||||
|
if (isDottedTimes(matched)) continue
|
||||||
|
if (isAmount(text, start, end)) continue
|
||||||
|
strayTailRegex.find(matched)?.let { tail ->
|
||||||
|
if (wordAfterRegex.matchesAt(text, end)) end = start + tail.range.first
|
||||||
|
}
|
||||||
|
// "+49 (0)151 …": a matcher that stops at the "(0)" leaves the domestic
|
||||||
|
// tail, which would dial without the country code.
|
||||||
|
strandedCountryCodeRegex.find(text.window(start - LOOK_BEHIND, start))
|
||||||
|
?.let { start -= it.value.length }
|
||||||
|
val number = dialable(text.substring(start, end))
|
||||||
|
if (number.count { it.isDigit() } < MIN_DIGITS) continue
|
||||||
|
val suffix = dialSuffixRegex.find(text.window(end, end + LOOK_AHEAD))?.value.orEmpty()
|
||||||
|
// A suffix swallowed what the matcher saw as the next number.
|
||||||
|
if (links.lastOrNull()?.let { start < it.end } == true) continue
|
||||||
|
links += PhoneLink(start, end + suffix.length, number + dialable(suffix))
|
||||||
|
}
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
// How far around a match the label, prefix, currency and suffix checks look.
|
||||||
|
// Enough for any of them, and it keeps a long invite from being re-read whole
|
||||||
|
// for every number in it.
|
||||||
|
private const val LOOK_BEHIND = 40
|
||||||
|
private const val LOOK_AHEAD = 60
|
||||||
|
|
||||||
|
private fun String.window(from: Int, to: Int): String =
|
||||||
|
substring(from.coerceAtLeast(0), to.coerceAtMost(length))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Digits run straight into a letter or a URL path — "zoom.us/j/850…" without
|
||||||
|
* its https:// — which no web link covered. A phone number stands apart.
|
||||||
|
*/
|
||||||
|
private fun gluedIntoAWord(text: String, start: Int, end: Int): Boolean {
|
||||||
|
val before = text.getOrNull(start - 1)
|
||||||
|
val after = text.getOrNull(end)
|
||||||
|
return (before != null && (before.isLetter() || before in "/=?&")) ||
|
||||||
|
(after != null && (after.isLetter() || after == '/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isDottedTimes(matched: String): Boolean {
|
||||||
|
val times = dottedTimesRegex.matchEntire(matched) ?: return false
|
||||||
|
return times.groupValues.drop(1).chunked(2).all { (hour, minute) ->
|
||||||
|
hour.isEmpty() || (hour.toInt() in 0..24 && minute.toInt() in 0..59)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAmount(text: String, start: Int, end: Int): Boolean {
|
||||||
|
val matched = text.substring(start, end)
|
||||||
|
if (shortLeadAmountRegex.matches(matched)) return true
|
||||||
|
return amountRegex.matches(matched) &&
|
||||||
|
(currencyBeforeRegex.containsMatchIn(text.window(start - LOOK_BEHIND, start)) ||
|
||||||
|
currencyAfterRegex.containsMatchIn(text.window(end, end + LOOK_AHEAD)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MatchResult.isDate(): Boolean {
|
||||||
|
val (a, _, b, c) = destructured
|
||||||
|
val first = a.toInt()
|
||||||
|
val second = b.toInt()
|
||||||
|
val last = c.toInt()
|
||||||
|
return when {
|
||||||
|
a.length == 4 -> second in 1..12 && last in 1..31 && c.length <= 2
|
||||||
|
a.length > 2 -> false
|
||||||
|
// Day first (most of the world) or month first (US).
|
||||||
|
else -> (first in 1..31 && second in 1..12) || (first in 1..12 && second in 1..31)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lineStart(text: String, index: Int): Int = text.lastIndexOf('\n', index - 1) + 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip [raw] down to what a dialer takes. An international number's "(0)"
|
||||||
|
* trunk prefix — "+49 (0) 151 …" — is a domestic-only hint and would break the
|
||||||
|
* call, so it goes too; an extension becomes a pause.
|
||||||
|
*/
|
||||||
|
private fun dialable(raw: String): String {
|
||||||
|
val international = raw.trimStart('(').startsWith("+")
|
||||||
|
val withoutTrunk = if (international) raw.replace(trunkPrefixRegex, "") else raw
|
||||||
|
return withoutTrunk.replace(extensionRegex, ",")
|
||||||
|
.filter { it.isDigit() || it == '+' || it == ',' || it == '#' || it == '*' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
/** An http(s) URL found in free text, spanning [start] until [end] (exclusive). */
|
||||||
|
internal data class WebLink(val start: Int, val end: Int, val url: String)
|
||||||
|
|
||||||
|
private val webLinkRegex = Regex("""https?://[^\s<>"]+""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the http(s) URLs in [text]. Trailing prose punctuation is left out of
|
||||||
|
* each match, and the scheme is lower-cased so intent filters match it.
|
||||||
|
*/
|
||||||
|
internal fun findWebLinks(text: String): List<WebLink> = webLinkRegex.findAll(text).mapNotNull { match ->
|
||||||
|
val raw = match.value.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '\'')
|
||||||
|
val schemeEnd = raw.indexOf(':')
|
||||||
|
if (raw.length <= schemeEnd + 3) return@mapNotNull null
|
||||||
|
val url = raw.substring(0, schemeEnd).lowercase() + raw.substring(schemeEnd)
|
||||||
|
WebLink(match.range.first, match.range.first + raw.length, url)
|
||||||
|
}.toList()
|
||||||
@@ -36,6 +36,15 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
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.Close
|
||||||
@@ -116,10 +125,13 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
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.startInstant
|
||||||
|
import de.jeanlucmakiola.calendula.domain.endInstant
|
||||||
|
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isPinnedAwayFrom
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||||
import de.jeanlucmakiola.calendula.domain.timesIn
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||||
@@ -162,6 +174,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
|||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
|
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
|
||||||
@@ -174,7 +187,6 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.isoDayNumber
|
import kotlinx.datetime.isoDayNumber
|
||||||
import kotlinx.datetime.toJavaDayOfWeek
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toJavaLocalTime
|
import kotlinx.datetime.toJavaLocalTime
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.toJavaInstant
|
import kotlin.time.toJavaInstant
|
||||||
@@ -184,6 +196,7 @@ import java.time.format.FormatStyle
|
|||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen event form: create (v1.2, from the FAB's anchor date) and edit
|
* Full-screen event form: create (v1.2, from the FAB's anchor date) and edit
|
||||||
@@ -192,6 +205,10 @@ import kotlin.time.Clock
|
|||||||
* first asks how far the change reaches (this occurrence / this and
|
* first asks how far the change reaches (this occurrence / this and
|
||||||
* following / whole series) — then closes. The calendar picker offers only
|
* following / whole series) — then closes. The calendar picker offers only
|
||||||
* writable calendars; when editing, the calendar is fixed.
|
* writable calendars; when editing, the calendar is fixed.
|
||||||
|
*
|
||||||
|
* Leaving with unsaved changes asks to save, discard or keep editing (#178).
|
||||||
|
* While editing, the overflow menu offers Duplicate (the form as it stands,
|
||||||
|
* handed to [onDuplicate] as a new event), Share and Delete.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -201,13 +218,21 @@ fun EventEditScreen(
|
|||||||
onSaved: () -> Unit,
|
onSaved: () -> Unit,
|
||||||
editKey: LongArray? = null,
|
editKey: LongArray? = null,
|
||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
|
initialDraft: LongArray? = null,
|
||||||
initialForm: EventForm? = null,
|
initialForm: EventForm? = null,
|
||||||
initialFormSource: ImportSource = ImportSource.File,
|
initialFormSource: ImportSource = ImportSource.File,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
|
onDuplicate: ((EventForm) -> Unit)? = null,
|
||||||
|
onDeleted: () -> Unit = onSaved,
|
||||||
|
onLoadFailed: () -> Unit = onClose,
|
||||||
|
leaveRequested: Boolean = false,
|
||||||
|
onLeaveDeclined: () -> Unit = {},
|
||||||
visible: Boolean = true,
|
visible: Boolean = true,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
// Keyed on every input that decides the form, so a second draft or slot on
|
||||||
|
// the same day opens its own form even when the composition is kept alive.
|
||||||
|
LaunchedEffect(initialDateIso, editKey, initialForm, initialDraft, initialStartMinutes) {
|
||||||
when {
|
when {
|
||||||
// A prefilled open: a single-event .ics for review, or an external
|
// A prefilled open: a single-event .ics for review, or an external
|
||||||
// ACTION_INSERT intent. The source drives how reminders are seeded.
|
// ACTION_INSERT intent. The source drives how reminders are seeded.
|
||||||
@@ -217,9 +242,14 @@ fun EventEditScreen(
|
|||||||
beginMillis = editKey[1],
|
beginMillis = editKey[1],
|
||||||
endMillis = editKey[2],
|
endMillis = editKey[2],
|
||||||
)
|
)
|
||||||
|
// A block drafted on the timeline (#153): its own start and end.
|
||||||
|
initialDraft != null -> viewModel.openDraft(
|
||||||
|
start = Instant.fromEpochMilliseconds(initialDraft[0]),
|
||||||
|
end = Instant.fromEpochMilliseconds(initialDraft[1]),
|
||||||
|
)
|
||||||
else -> {
|
else -> {
|
||||||
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||||
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
?: Clock.System.now().toLocalDateTime(viewModel.zone.value).date
|
||||||
viewModel.openNew(date, initialStartMinutes)
|
viewModel.openNew(date, initialStartMinutes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,10 +265,23 @@ fun EventEditScreen(
|
|||||||
viewModel.reset()
|
viewModel.reset()
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
// The event vanished between the detail screen and the edit tap — fall
|
val hasUnsavedChanges by viewModel.hasUnsavedChanges.collectAsStateWithLifecycle()
|
||||||
// back to the detail screen, which shows its own failure state.
|
var showLeavePrompt by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val requestClose = {
|
||||||
|
if (hasUnsavedChanges) showLeavePrompt = true else close()
|
||||||
|
}
|
||||||
|
// Something else wants this form's place, e.g. a second share (#306).
|
||||||
|
LaunchedEffect(leaveRequested) {
|
||||||
|
if (leaveRequested) requestClose()
|
||||||
|
}
|
||||||
|
// The event vanished before the form could load it — fall back to the
|
||||||
|
// detail screen, which shows its own failure state.
|
||||||
LaunchedEffect(loadFailed) {
|
LaunchedEffect(loadFailed) {
|
||||||
if (loadFailed) close()
|
if (loadFailed) {
|
||||||
|
viewModel.reset()
|
||||||
|
onLoadFailed()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -262,14 +305,49 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val requireWrite = rememberWriteGate()
|
||||||
|
val shareFailedMessage = stringResource(R.string.event_share_failed)
|
||||||
|
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||||
|
val onShareClick: () -> Unit = {
|
||||||
|
scope.launch {
|
||||||
|
val uri = viewModel.shareUri()
|
||||||
|
val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
|
||||||
|
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The copy opens over this form; a form with nothing to lose steps aside.
|
||||||
|
val onDuplicateClick: () -> Unit = {
|
||||||
|
requireWrite {
|
||||||
|
viewModel.duplicateForm()?.let { form ->
|
||||||
|
onDuplicate?.invoke(form)
|
||||||
|
if (!hasUnsavedChanges) close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
|
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
|
||||||
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
||||||
|
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||||
|
val deleteDeniedMessage = stringResource(R.string.event_delete_write_denied)
|
||||||
LaunchedEffect(state?.saveState) {
|
LaunchedEffect(state?.saveState) {
|
||||||
when (state?.saveState) {
|
when (state?.saveState) {
|
||||||
SaveUiState.Saved -> {
|
SaveUiState.Saved -> {
|
||||||
viewModel.reset()
|
viewModel.reset()
|
||||||
onSaved()
|
onSaved()
|
||||||
}
|
}
|
||||||
|
SaveUiState.Deleted -> {
|
||||||
|
viewModel.reset()
|
||||||
|
onDeleted()
|
||||||
|
}
|
||||||
|
SaveUiState.DeleteFailed -> {
|
||||||
|
viewModel.consumeSaveResult()
|
||||||
|
snackbarHostState.showSnackbar(deleteFailedMessage, duration = SnackbarDuration.Long)
|
||||||
|
}
|
||||||
|
SaveUiState.DeleteDenied -> {
|
||||||
|
viewModel.consumeSaveResult()
|
||||||
|
snackbarHostState.showSnackbar(deleteDeniedMessage, duration = SnackbarDuration.Long)
|
||||||
|
}
|
||||||
// A failed save leaves the form looking unchanged, so the snackbar is
|
// A failed save leaves the form looking unchanged, so the snackbar is
|
||||||
// the only sign anything happened — long rather than the default
|
// the only sign anything happened — long rather than the default
|
||||||
// flash (Codeberg #234: it read as "nothing happens at all").
|
// flash (Codeberg #234: it read as "nothing happens at all").
|
||||||
@@ -287,7 +365,7 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.predictiveBack(onBack = close, visible = visible),
|
modifier = Modifier.predictiveBack(onBack = requestClose, visible = visible),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -295,7 +373,7 @@ fun EventEditScreen(
|
|||||||
// matching the detail screen's bare top bar.
|
// matching the detail screen's bare top bar.
|
||||||
title = {},
|
title = {},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = close) {
|
IconButton(onClick = requestClose) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = stringResource(R.string.event_edit_close),
|
contentDescription = stringResource(R.string.event_edit_close),
|
||||||
@@ -303,6 +381,15 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
if (state?.isEditing == true) {
|
||||||
|
EditActionsMenu(
|
||||||
|
canDelete = state?.isManaged == false,
|
||||||
|
canDuplicate = onDuplicate != null,
|
||||||
|
onDuplicate = onDuplicateClick,
|
||||||
|
onShare = onShareClick,
|
||||||
|
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||||
|
)
|
||||||
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = onSaveClick,
|
onClick = onSaveClick,
|
||||||
enabled = state != null && state?.saveState != SaveUiState.Saving,
|
enabled = state != null && state?.saveState != SaveUiState.Saving,
|
||||||
@@ -329,6 +416,35 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showLeavePrompt) {
|
||||||
|
UnsavedChangesDialog(
|
||||||
|
onSave = {
|
||||||
|
showLeavePrompt = false
|
||||||
|
onSaveClick()
|
||||||
|
},
|
||||||
|
onDiscard = {
|
||||||
|
showLeavePrompt = false
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
showLeavePrompt = false
|
||||||
|
if (leaveRequested) onLeaveDeclined()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val recurs = viewModel.editedEventRecurs()
|
||||||
|
if (showDeleteDialog && recurs != null) {
|
||||||
|
DeleteEventDialog(
|
||||||
|
isRecurring = recurs,
|
||||||
|
onConfirm = { deleteScope ->
|
||||||
|
showDeleteDialog = false
|
||||||
|
viewModel.delete(deleteScope)
|
||||||
|
},
|
||||||
|
onDismiss = { showDeleteDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (state?.saveState == SaveUiState.AwaitingScope) {
|
if (state?.saveState == SaveUiState.AwaitingScope) {
|
||||||
SaveScopeDialog(
|
SaveScopeDialog(
|
||||||
recurrenceChanged = state?.recurrenceChanged == true,
|
recurrenceChanged = state?.recurrenceChanged == true,
|
||||||
@@ -373,6 +489,90 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Duplicate, Share and Delete for the event being edited, behind an overflow button. */
|
||||||
|
@Composable
|
||||||
|
private fun EditActionsMenu(
|
||||||
|
canDelete: Boolean,
|
||||||
|
canDuplicate: Boolean,
|
||||||
|
onDuplicate: () -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { open = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = stringResource(R.string.event_edit_more_actions),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
if (canDuplicate) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.event_detail_duplicate)) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onDuplicate()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.event_detail_share)) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onShare()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// Managed special-dates events have no delete, as on the detail screen.
|
||||||
|
if (canDelete) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.event_detail_delete)) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asked when leaving the form with unsaved changes (#178). Dismissing keeps
|
||||||
|
* editing; a form with nothing changed closes without asking.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UnsavedChangesDialog(
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onDiscard: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.event_edit_unsaved_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_edit_unsaved_save),
|
||||||
|
onClick = onSave,
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_edit_unsaved_discard),
|
||||||
|
onClick = onDiscard,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.event_edit_unsaved_keep))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offer to apply the settings default reminder to an event opened from a `.ics`
|
* Offer to apply the settings default reminder to an event opened from a `.ics`
|
||||||
* file. The file's own reminders are kept unless the user accepts. A plain
|
* file. The file's own reminders are kept unless the user accepts. A plain
|
||||||
@@ -501,6 +701,7 @@ private fun EventEditContent(
|
|||||||
) {
|
) {
|
||||||
val form = state.form
|
val form = state.form
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
val deviceZone by viewModel.zone.collectAsStateWithLifecycle()
|
||||||
val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
|
val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
// The title, date and recurrence are managed by the special-dates sync, so
|
// The title, date and recurrence are managed by the special-dates sync, so
|
||||||
@@ -662,9 +863,14 @@ private fun EventEditContent(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
val formZone = remember(form.timezone, deviceZone) { form.resolvedZone(deviceZone) }
|
||||||
|
val startAt = remember(form.start, formZone) { form.startInstant(formZone) }
|
||||||
|
val endAt = remember(form.end, formZone) { form.endInstant(formZone) }
|
||||||
ScheduleRow(
|
ScheduleRow(
|
||||||
label = stringResource(R.string.event_edit_starts),
|
label = stringResource(R.string.event_edit_starts),
|
||||||
dateTime = form.start,
|
dateTime = form.start.dateTime,
|
||||||
|
instant = startAt,
|
||||||
|
zone = formZone,
|
||||||
isAllDay = form.isAllDay,
|
isAllDay = form.isAllDay,
|
||||||
locale = locale,
|
locale = locale,
|
||||||
onDateClick = { picker = PickerTarget.StartDate },
|
onDateClick = { picker = PickerTarget.StartDate },
|
||||||
@@ -673,7 +879,9 @@ private fun EventEditContent(
|
|||||||
)
|
)
|
||||||
ScheduleRow(
|
ScheduleRow(
|
||||||
label = stringResource(R.string.event_edit_ends),
|
label = stringResource(R.string.event_edit_ends),
|
||||||
dateTime = form.end,
|
dateTime = form.end.dateTime,
|
||||||
|
instant = endAt,
|
||||||
|
zone = formZone,
|
||||||
isAllDay = form.isAllDay,
|
isAllDay = form.isAllDay,
|
||||||
locale = locale,
|
locale = locale,
|
||||||
onDateClick = { picker = PickerTarget.EndDate },
|
onDateClick = { picker = PickerTarget.EndDate },
|
||||||
@@ -692,20 +900,20 @@ private fun EventEditContent(
|
|||||||
// The rows above edit a pinned event in *its own* zone, which is the
|
// The rows above edit a pinned event in *its own* zone, which is the
|
||||||
// time it was set at — but that says nothing about when it lands for
|
// time it was set at — but that says nothing about when it lands for
|
||||||
// whoever is reading it. Spell the local equivalent out rather than
|
// whoever is reading it. Spell the local equivalent out rather than
|
||||||
// leaving the user to do the offset arithmetic. Absent (null) unless
|
// leaving the user to do the offset arithmetic. Absent unless the
|
||||||
// the event is pinned somewhere other than here.
|
// event is pinned somewhere other than here.
|
||||||
// Keyed rather than recomputed: this sits in the same Column as the
|
// Keyed rather than recomputed: this sits in the same Column as the
|
||||||
// title field, so it would otherwise re-parse the zone on every
|
// title field, so it would otherwise re-parse the zone on every
|
||||||
// keystroke.
|
// keystroke.
|
||||||
val localTimes = remember(form.timezone, form.start, form.end, form.isAllDay) {
|
val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) {
|
||||||
form.timesIn(TimeZone.currentSystemDefault())
|
form.isPinnedAwayFrom(deviceZone)
|
||||||
}
|
}
|
||||||
localTimes?.let { (localStart, localEnd) ->
|
if (pinnedAway) {
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(
|
text = stringResource(
|
||||||
R.string.event_edit_timezone_local_time,
|
R.string.event_edit_timezone_local_time,
|
||||||
formatTimeRange(localStart, localEnd, locale),
|
formatTimeRange(startAt, endAt, deviceZone, locale),
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -769,7 +977,7 @@ private fun EventEditContent(
|
|||||||
timeZoneOptionOf(
|
timeZoneOptionOf(
|
||||||
id,
|
id,
|
||||||
locale,
|
locale,
|
||||||
at = form.start.toInstant(zone).toJavaInstant(),
|
at = form.startInstant(zone).toJavaInstant(),
|
||||||
regionOf = ::icuTimeZoneRegion,
|
regionOf = ::icuTimeZoneRegion,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2251,18 +2459,23 @@ private fun EditCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "2:00 PM – 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one
|
* "2:00 PM – 3:00 PM" in [zone], honouring the user's 12/24-hour setting. Collapses
|
||||||
* time when both land on the same minute (an instant event shouldn't read as a
|
* to one time for an instant event, which shouldn't read as a range against
|
||||||
* range against itself). Dates are deliberately absent — the rows above carry
|
* itself. Dates are deliberately absent — the rows above carry them.
|
||||||
* them.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String {
|
private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String {
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }
|
return remember(start, end, zone, locale, use24Hour) {
|
||||||
val from = timeFormat.format(start.time.toJavaLocalTime())
|
val timeFormat = timeOfDayFormatter(use24Hour, locale)
|
||||||
val to = timeFormat.format(end.time.toJavaLocalTime())
|
fun format(at: Instant) = withRepeatedHourZone(
|
||||||
return if (from == to) from else "$from – $to"
|
timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()),
|
||||||
|
at,
|
||||||
|
zone,
|
||||||
|
locale,
|
||||||
|
)
|
||||||
|
if (start == end) format(start) else "${format(start)} – ${format(end)}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2344,6 +2557,8 @@ private fun firstLineHeight(
|
|||||||
private fun ScheduleRow(
|
private fun ScheduleRow(
|
||||||
label: String,
|
label: String,
|
||||||
dateTime: LocalDateTime,
|
dateTime: LocalDateTime,
|
||||||
|
instant: Instant,
|
||||||
|
zone: TimeZone,
|
||||||
isAllDay: Boolean,
|
isAllDay: Boolean,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
onDateClick: () -> Unit,
|
onDateClick: () -> Unit,
|
||||||
@@ -2385,8 +2600,11 @@ private fun ScheduleRow(
|
|||||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||||
)
|
)
|
||||||
if (!isAllDay) {
|
if (!isAllDay) {
|
||||||
|
val time = remember(dateTime, instant, zone, locale, timeFormat) {
|
||||||
|
withRepeatedHourZone(timeFormat.format(dateTime.time.toJavaLocalTime()), instant, zone, locale)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = timeFormat.format(dateTime.time.toJavaLocalTime()),
|
text = time,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = valueColor,
|
color = valueColor,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ sealed interface SaveUiState {
|
|||||||
data object Gone : SaveUiState
|
data object Gone : SaveUiState
|
||||||
data object Saving : SaveUiState
|
data object Saving : SaveUiState
|
||||||
data object Saved : SaveUiState
|
data object Saved : SaveUiState
|
||||||
|
/** The event was deleted from the form (#178). */
|
||||||
|
data object Deleted : SaveUiState
|
||||||
|
data object DeleteFailed : SaveUiState
|
||||||
|
/** WRITE_CALENDAR was revoked before a delete from the form. */
|
||||||
|
data object DeleteDenied : SaveUiState
|
||||||
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||||
data object NeedsPermission : SaveUiState
|
data object NeedsPermission : SaveUiState
|
||||||
data object Failed : SaveUiState
|
data object Failed : SaveUiState
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.edit
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -7,8 +8,10 @@ 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.calendar.NoSuchEventException
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
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.data.time.ZoneSource
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||||
@@ -18,7 +21,17 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|||||||
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.endInstant
|
||||||
|
import de.jeanlucmakiola.calendula.domain.nextFullHour
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toFormTime
|
||||||
|
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||||
|
import de.jeanlucmakiola.calendula.domain.startInstant
|
||||||
|
import de.jeanlucmakiola.calendula.domain.withEnd
|
||||||
|
import de.jeanlucmakiola.calendula.domain.withStart
|
||||||
|
import de.jeanlucmakiola.calendula.domain.withTimes
|
||||||
|
import de.jeanlucmakiola.calendula.domain.withTimezone
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
@@ -26,6 +39,9 @@ import de.jeanlucmakiola.calendula.domain.populatedFields
|
|||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -132,9 +148,15 @@ class EventEditViewModel @Inject constructor(
|
|||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val prefs: CalendarPrefs,
|
private val prefs: CalendarPrefs,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
private val icsExporter: IcsExporter,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
private val zoneSource: ZoneSource,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** The zone the form's unpinned times are read in, for the screen to show them in too. */
|
||||||
|
val zone: StateFlow<TimeZone> = zoneSource.zone
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, zoneSource.current())
|
||||||
|
|
||||||
private val _form = MutableStateFlow<EventForm?>(null)
|
private val _form = MutableStateFlow<EventForm?>(null)
|
||||||
private val _saveState = MutableStateFlow<SaveUiState>(SaveUiState.Idle)
|
private val _saveState = MutableStateFlow<SaveUiState>(SaveUiState.Idle)
|
||||||
// Problems stay hidden until the first save attempt, so a half-filled
|
// Problems stay hidden until the first save attempt, so a half-filled
|
||||||
@@ -156,6 +178,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// A one-time offer, raised when a .ics import opens, to replace the file's
|
// A one-time offer, raised when a .ics import opens, to replace the file's
|
||||||
// reminders with the settings default (#49). Null while there's nothing to ask.
|
// reminders with the settings default (#49). Null while there's nothing to ask.
|
||||||
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
||||||
|
// Set by the first change the user makes, so a form only holding its own
|
||||||
|
// defaults or an import's prefill can be left without asking.
|
||||||
|
private val _edited = MutableStateFlow(false)
|
||||||
|
|
||||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
@@ -176,6 +201,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val beginMillis: Long,
|
val beginMillis: Long,
|
||||||
val endMillis: Long,
|
val endMillis: Long,
|
||||||
val zone: TimeZone,
|
val zone: TimeZone,
|
||||||
|
/** The event as loaded, on the tapped occurrence's times — what Share exports. */
|
||||||
|
val detail: EventDetail,
|
||||||
) {
|
) {
|
||||||
val original: EventForm get() = snapshot.form
|
val original: EventForm get() = snapshot.form
|
||||||
}
|
}
|
||||||
@@ -289,7 +316,11 @@ class EventEditViewModel @Inject constructor(
|
|||||||
EventEditUiState(
|
EventEditUiState(
|
||||||
form = resolved,
|
form = resolved,
|
||||||
calendars = pickerCalendars,
|
calendars = pickerCalendars,
|
||||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
problems = if (local.showProblems) {
|
||||||
|
resolved.problems(zoneSource.current())
|
||||||
|
} else {
|
||||||
|
emptySet()
|
||||||
|
},
|
||||||
saveState = local.saveState,
|
saveState = local.saveState,
|
||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hiddenFields = (offerableFields - visibleFields).sorted(),
|
hiddenFields = (offerableFields - visibleFields).sorted(),
|
||||||
@@ -311,6 +342,18 @@ class EventEditViewModel @Inject constructor(
|
|||||||
initialValue = null,
|
initialValue = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether leaving now would lose something the user did (#178): an edit that
|
||||||
|
* differs from the loaded event, or any change to a new or imported form.
|
||||||
|
*/
|
||||||
|
val hasUnsavedChanges: StateFlow<Boolean> = combine(_form, _editTarget, _edited) { form, target, edited ->
|
||||||
|
when {
|
||||||
|
form == null -> false
|
||||||
|
target != null -> form != target.original
|
||||||
|
else -> edited
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First day of the week for ordering the recurrence weekday toggles — the
|
* First day of the week for ordering the recurrence weekday toggles — the
|
||||||
* app's "week starts on" preference, matching the calendar views (Auto
|
* app's "week starts on" preference, matching the calendar views (Auto
|
||||||
@@ -329,22 +372,32 @@ class EventEditViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
||||||
if (_form.value != null) return
|
if (_form.value != null) return
|
||||||
val zone = TimeZone.currentSystemDefault()
|
val zone = zoneSource.current()
|
||||||
val now = Clock.System.now()
|
val now = Clock.System.now()
|
||||||
val start = when {
|
val startAt = when {
|
||||||
startMinutes != null ->
|
startMinutes != null ->
|
||||||
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60))
|
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60)).toInstant(zone)
|
||||||
date == now.toLocalDateTime(zone).date -> {
|
date == now.toLocalDateTime(zone).date -> nextFullHour(now)
|
||||||
// Today: the next full hour (may roll into tomorrow before midnight).
|
else -> LocalDateTime(date, LocalTime(9, 0)).toInstant(zone)
|
||||||
val hourMillis = 3_600_000L
|
|
||||||
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
|
||||||
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
|
||||||
}
|
|
||||||
else -> LocalDateTime(date, LocalTime(9, 0))
|
|
||||||
}
|
}
|
||||||
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
|
val endAt = startAt + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes
|
||||||
.toLocalDateTime(zone)
|
_form.value = EventForm(calendarId = null, start = startAt.toFormTime(zone), end = endAt.toFormTime(zone))
|
||||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
applyDefaultReminder()
|
||||||
|
applyDefaultDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise a fresh form for a block drafted on the timeline (#153), from
|
||||||
|
* [start] to [end]. These are the instants the block shows, so a time in the
|
||||||
|
* fall-back hour's repeat, or one moved out of the spring-forward gap, opens
|
||||||
|
* as drawn (#387). The user chose the length, so the default length doesn't
|
||||||
|
* apply. No-op when a form is already open, as [openNew].
|
||||||
|
*/
|
||||||
|
fun openDraft(start: Instant, end: Instant) {
|
||||||
|
if (_form.value != null) return
|
||||||
|
val zone = zoneSource.current()
|
||||||
|
_form.value = EventForm(calendarId = null, start = start.toFormTime(zone), end = end.toFormTime(zone))
|
||||||
|
_durationTouched.value = true
|
||||||
applyDefaultReminder()
|
applyDefaultReminder()
|
||||||
applyDefaultDuration()
|
applyDefaultDuration()
|
||||||
}
|
}
|
||||||
@@ -454,11 +507,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
if (_editTarget.value != null || _durationTouched.value) return@launch
|
if (_editTarget.value != null || _durationTouched.value) return@launch
|
||||||
if (_form.value?.isAllDay != false) return@launch
|
if (_form.value?.isAllDay != false) return@launch
|
||||||
val duration = resolveDefaultEventDuration(global, overrides, targetId)
|
val duration = resolveDefaultEventDuration(global, overrides, targetId)
|
||||||
val zone = TimeZone.currentSystemDefault()
|
|
||||||
update { form ->
|
update { form ->
|
||||||
form.copy(
|
val zone = form.resolvedZone(zoneSource.current())
|
||||||
end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone),
|
val start = form.startInstant(zone)
|
||||||
)
|
form.withTimes(start, start + duration.minutes, zone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,7 +542,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val prompt = _importReminderPrompt.value ?: return
|
val prompt = _importReminderPrompt.value ?: return
|
||||||
_importReminderPrompt.value = null
|
_importReminderPrompt.value = null
|
||||||
// Already frozen as touched by openImported; this just swaps the values.
|
// Already frozen as touched by openImported; this just swaps the values.
|
||||||
update { it.copy(reminders = prompt.defaultReminders) }
|
edit { it.copy(reminders = prompt.defaultReminders) }
|
||||||
_revealed.value = _revealed.value + EventFormField.Reminders
|
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,13 +571,19 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_loadFailed.value = true
|
_loadFailed.value = true
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val zone = TimeZone.currentSystemDefault()
|
val zone = zoneSource.current()
|
||||||
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||||
?: detail.instance.start.toEpochMilliseconds()
|
?: detail.instance.start.toEpochMilliseconds()
|
||||||
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||||
?: detail.instance.end.toEpochMilliseconds()
|
?: detail.instance.end.toEpochMilliseconds()
|
||||||
val snapshot = detail.toEditSnapshot(begin, end, zone)
|
val snapshot = detail.toEditSnapshot(begin, end, zone)
|
||||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
|
val occurrence = detail.copy(
|
||||||
|
instance = detail.instance.copy(
|
||||||
|
start = Instant.fromEpochMilliseconds(begin),
|
||||||
|
end = Instant.fromEpochMilliseconds(end),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, occurrence)
|
||||||
// Sections holding data must show even when not in the defaults.
|
// Sections holding data must show even when not in the defaults.
|
||||||
_revealed.value = snapshot.form.populatedFields()
|
_revealed.value = snapshot.form.populatedFields()
|
||||||
_form.value = snapshot.form
|
_form.value = snapshot.form
|
||||||
@@ -543,6 +601,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_remindersTouched.value = false
|
_remindersTouched.value = false
|
||||||
_durationTouched.value = false
|
_durationTouched.value = false
|
||||||
_importReminderPrompt.value = null
|
_importReminderPrompt.value = null
|
||||||
|
_edited.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
@@ -553,24 +612,28 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// The title field wraps (multi-line) so long titles stay visible (#33), but
|
// The title field wraps (multi-line) so long titles stay visible (#33), but
|
||||||
// a title is one logical line: drop any newline the IME's Enter key or a
|
// a title is one logical line: drop any newline the IME's Enter key or a
|
||||||
// paste would introduce, so it never reaches the provider's TITLE column.
|
// paste would introduce, so it never reaches the provider's TITLE column.
|
||||||
fun setTitle(value: String) = update {
|
fun setTitle(value: String) = edit {
|
||||||
val cleaned = value.replace("\n", "").replace("\r", "")
|
val cleaned = value.replace("\n", "").replace("\r", "")
|
||||||
it.copy(title = capLength(cleaned, it.title))
|
it.copy(title = capLength(cleaned, it.title))
|
||||||
}
|
}
|
||||||
// The location wraps too (#273) but is likewise one logical line. A pasted
|
// The location wraps too (#273) but is likewise one logical line. A pasted
|
||||||
// multi-line address joins the way the contact picker's does (#146), so it
|
// multi-line address joins the way the contact picker's does (#146), so it
|
||||||
// never runs together into "12 Main St10115 Berlin".
|
// never runs together into "12 Main St10115 Berlin".
|
||||||
fun setLocation(value: String) = update {
|
fun setLocation(value: String) = edit {
|
||||||
val cleaned = value.replace(LINE_BREAK, ", ")
|
val cleaned = value.replace(LINE_BREAK, ", ")
|
||||||
it.copy(location = capLength(cleaned, it.location))
|
it.copy(location = capLength(cleaned, it.location))
|
||||||
}
|
}
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = edit { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) {
|
fun setAllDay(value: Boolean) {
|
||||||
// Going all-day drops any pinned zone: the times become bare dates that
|
// Going all-day drops any pinned zone: the times become bare dates that
|
||||||
// the provider stores UTC-anchored, so a zone could only misrepresent
|
// the provider stores UTC-anchored, so a zone could only misrepresent
|
||||||
// them. Coming back out of all-day leaves the event on the device zone,
|
// them. Coming back out of all-day leaves the event on the device zone,
|
||||||
// which is the same thing a fresh event gets.
|
// which is the same thing a fresh event gets.
|
||||||
update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
|
// A kept pass of a repeated hour stays, so switching back out of all-day
|
||||||
|
// lands where the event was — unless dropping the pin changed the zone.
|
||||||
|
edit {
|
||||||
|
if (value) it.withTimezone(null, zoneSource.current()).copy(isAllDay = true) else it.copy(isAllDay = false)
|
||||||
|
}
|
||||||
// The default reminder differs for all-day vs timed; re-apply the
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
applyDefaultReminder()
|
applyDefaultReminder()
|
||||||
@@ -585,7 +648,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
* time — only real picks are remembered, not the device fallback.
|
* time — only real picks are remembered, not the device fallback.
|
||||||
*/
|
*/
|
||||||
fun setTimezone(zoneId: String?) {
|
fun setTimezone(zoneId: String?) {
|
||||||
update { it.copy(timezone = zoneId) }
|
edit { it.withTimezone(zoneId, zoneSource.current()) }
|
||||||
if (zoneId != null) {
|
if (zoneId != null) {
|
||||||
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
|
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
|
||||||
}
|
}
|
||||||
@@ -597,35 +660,35 @@ class EventEditViewModel @Inject constructor(
|
|||||||
* The event falls back to the new calendar's colour until re-picked.
|
* The event falls back to the new calendar's colour until re-picked.
|
||||||
*/
|
*/
|
||||||
fun setCalendar(id: Long) {
|
fun setCalendar(id: Long) {
|
||||||
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
edit { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||||
// A fresh event re-inherits the new calendar's default reminder and
|
// A fresh event re-inherits the new calendar's default reminder and
|
||||||
// length unless the user has already hand-edited them (guarded inside).
|
// length unless the user has already hand-edited them (guarded inside).
|
||||||
applyDefaultReminder(id)
|
applyDefaultReminder(id)
|
||||||
applyDefaultDuration(id)
|
applyDefaultDuration(id)
|
||||||
}
|
}
|
||||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
fun setAvailability(value: Availability) = edit { it.copy(availability = value) }
|
||||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
fun setAccessLevel(value: AccessLevel) = edit { it.copy(accessLevel = value) }
|
||||||
|
|
||||||
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
|
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
|
||||||
fun setColorKey(key: String, argb: Int) = update { it.copy(colorKey = key, color = argb) }
|
fun setColorKey(key: String, argb: Int) = edit { it.copy(colorKey = key, color = argb) }
|
||||||
|
|
||||||
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
|
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
|
||||||
fun setColorRaw(argb: Int) = update { it.copy(colorKey = null, color = argb) }
|
fun setColorRaw(argb: Int) = edit { it.copy(colorKey = null, color = argb) }
|
||||||
|
|
||||||
/** Clear the colour so the event inherits its calendar's. */
|
/** Clear the colour so the event inherits its calendar's. */
|
||||||
fun clearColor() = update { it.copy(colorKey = null, color = null) }
|
fun clearColor() = edit { it.copy(colorKey = null, color = null) }
|
||||||
|
|
||||||
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
fun setRecurrence(rrule: String?) = edit { it.copy(rrule = rrule) }
|
||||||
|
|
||||||
fun addReminder(minutes: Int) {
|
fun addReminder(minutes: Int) {
|
||||||
_remindersTouched.value = true
|
_remindersTouched.value = true
|
||||||
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
edit { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeReminder(minutes: Int) {
|
fun removeReminder(minutes: Int) {
|
||||||
_remindersTouched.value = true
|
_remindersTouched.value = true
|
||||||
update { it.copy(reminders = it.reminders - minutes) }
|
edit { it.copy(reminders = it.reminders - minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -636,7 +699,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
|
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
|
||||||
val trimmed = email.trim()
|
val trimmed = email.trim()
|
||||||
if (trimmed.isEmpty()) return
|
if (trimmed.isEmpty()) return
|
||||||
update { form ->
|
edit { form ->
|
||||||
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
|
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
|
||||||
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
|
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
|
||||||
form.copy(attendees = rest + attendee)
|
form.copy(attendees = rest + attendee)
|
||||||
@@ -644,10 +707,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun removeAttendee(attendee: EventAttendee) =
|
fun removeAttendee(attendee: EventAttendee) =
|
||||||
update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
|
edit { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
|
||||||
|
|
||||||
/** Flip a guest between required and optional (the per-row role toggle). */
|
/** Flip a guest between required and optional (the per-row role toggle). */
|
||||||
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form ->
|
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = edit { form ->
|
||||||
form.copy(
|
form.copy(
|
||||||
attendees = form.attendees.map { a ->
|
attendees = form.attendees.map { a ->
|
||||||
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
|
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
|
||||||
@@ -663,12 +726,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// default duration, so a later calendar switch keeps their span (#54).
|
// default duration, so a later calendar switch keeps their span (#54).
|
||||||
fun setEndDate(date: LocalDate) {
|
fun setEndDate(date: LocalDate) {
|
||||||
_durationTouched.value = true
|
_durationTouched.value = true
|
||||||
update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
moveEnd { LocalDateTime(date, it.time) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setEndTime(time: LocalTime) {
|
fun setEndTime(time: LocalTime) {
|
||||||
_durationTouched.value = true
|
_durationTouched.value = true
|
||||||
update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
moveEnd { LocalDateTime(it.date, time) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -681,7 +744,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val current = state.value ?: return
|
val current = state.value ?: return
|
||||||
if (current.saveState == SaveUiState.Saving) return
|
if (current.saveState == SaveUiState.Saving) return
|
||||||
val form = current.form
|
val form = current.form
|
||||||
if (form.problems().isNotEmpty()) {
|
if (form.problems(zoneSource.current()).isNotEmpty()) {
|
||||||
_showProblems.value = true
|
_showProblems.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -804,20 +867,62 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the event being edited recurs; null while not editing. */
|
||||||
|
fun editedEventRecurs(): Boolean? = _editTarget.value?.let { !it.detail.rrule.isNullOrBlank() }
|
||||||
|
|
||||||
|
/** The form as it stands, as a fresh one-off event — Duplicate in the edit form (#178). */
|
||||||
|
fun duplicateForm(): EventForm? = state.value?.form?.copy(rrule = null)
|
||||||
|
|
||||||
|
/** A staged `.ics` of the event as saved, or null when it can't be built. */
|
||||||
|
suspend fun shareUri(): Uri? {
|
||||||
|
val detail = _editTarget.value?.detail ?: return null
|
||||||
|
return withContext(io) { icsExporter.stageEventShare(detail) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete the event being edited, as far as [scope] reaches; result lands in [saveState]. */
|
||||||
|
fun delete(scope: RecurringWriteScope) {
|
||||||
|
val target = _editTarget.value ?: return
|
||||||
|
if (_saveState.value == SaveUiState.Saving) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_saveState.value = SaveUiState.Saving
|
||||||
|
// Already gone is what a delete wants anyway.
|
||||||
|
_saveState.value = when (repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)) {
|
||||||
|
DeleteOutcome.Deleted, DeleteOutcome.Gone -> SaveUiState.Deleted
|
||||||
|
DeleteOutcome.NeedsPermission -> SaveUiState.DeleteDenied
|
||||||
|
DeleteOutcome.Failed -> SaveUiState.DeleteFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Reset [saveState] after the screen handled a terminal result. */
|
/** Reset [saveState] after the screen handled a terminal result. */
|
||||||
fun consumeSaveResult() {
|
fun consumeSaveResult() {
|
||||||
_saveState.value = SaveUiState.Idle
|
_saveState.value = SaveUiState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
|
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = edit { form ->
|
||||||
val zone = TimeZone.currentSystemDefault()
|
form.withStart(transform(form.start.dateTime), form.resolvedZone(zoneSource.current()), opened(form)?.start)
|
||||||
val newStart = transform(form.start)
|
|
||||||
val duration = form.end.toInstant(zone) - form.start.toInstant(zone)
|
|
||||||
val newEnd = (newStart.toInstant(zone) + duration).toLocalDateTime(zone)
|
|
||||||
form.copy(start = newStart, end = newEnd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun moveEnd(transform: (LocalDateTime) -> LocalDateTime) = edit { form ->
|
||||||
|
form.withEnd(transform(form.end.dateTime), form.resolvedZone(zoneSource.current()), opened(form)?.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event as it was opened, while its times still mean the same instants:
|
||||||
|
* picking one of them again keeps its pass of a repeated hour (#387).
|
||||||
|
*/
|
||||||
|
private fun opened(form: EventForm): EventForm? =
|
||||||
|
_editTarget.value?.original?.takeIf { it.timezone == form.timezone && !it.isAllDay }
|
||||||
|
|
||||||
private inline fun update(block: (EventForm) -> EventForm) {
|
private inline fun update(block: (EventForm) -> EventForm) {
|
||||||
_form.value = _form.value?.let(block)
|
_form.value = _form.value?.let(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A change the user made, as opposed to a default the form applied itself. */
|
||||||
|
private inline fun edit(block: (EventForm) -> EventForm) {
|
||||||
|
val current = _form.value ?: return
|
||||||
|
val next = block(current)
|
||||||
|
if (next != current) _edited.value = true
|
||||||
|
_form.value = next
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -170,6 +171,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.isPastOn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
||||||
@@ -197,13 +199,17 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
|
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
|
||||||
|
|
||||||
|
/** Live today for the pages, whose cached states may have been built before midnight (#373). */
|
||||||
|
private val LocalMonthToday = compositionLocalOf<LocalDate?> { null }
|
||||||
|
|
||||||
|
private fun MonthUiState.Success.withToday(today: LocalDate?): MonthUiState.Success =
|
||||||
|
if (today == null || today == this.today) this else copy(today = today)
|
||||||
private val LocalOpenWeek = staticCompositionLocalOf<((LocalDate) -> Unit)?> { null }
|
private val LocalOpenWeek = staticCompositionLocalOf<((LocalDate) -> Unit)?> { null }
|
||||||
|
|
||||||
/** A brief push from today's full cell into its immediate neighbours. */
|
/** A brief push from today's full cell into its immediate neighbours. */
|
||||||
@@ -319,12 +325,7 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
val dense = viewStyle == MonthViewStyle.Dense
|
val dense = viewStyle == MonthViewStyle.Dense
|
||||||
|
|
||||||
// Today, from whichever state is driving; the clock only covers the first
|
val today by viewModel.today.collectAsStateWithLifecycle()
|
||||||
// frame, before either has loaded.
|
|
||||||
val today = when {
|
|
||||||
scrolling -> (continuousState as? ContinuousMonthUiState.Success)?.today
|
|
||||||
else -> (state as? MonthUiState.Success)?.today
|
|
||||||
} ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
|
||||||
|
|
||||||
// The two scrolling styles index their items differently — Continuous by
|
// The two scrolling styles index their items differently — Continuous by
|
||||||
// month (which the week start can't move), Dense by week (which it can) — so
|
// month (which the week start can't move), Dense by week (which it can) — so
|
||||||
@@ -520,6 +521,7 @@ fun MonthScreen(
|
|||||||
LocalDimCutoff provides dimCutoff,
|
LocalDimCutoff provides dimCutoff,
|
||||||
LocalMonthDrag provides chipDrag,
|
LocalMonthDrag provides chipDrag,
|
||||||
LocalTodayPulse provides todayPulse,
|
LocalTodayPulse provides todayPulse,
|
||||||
|
LocalMonthToday provides today,
|
||||||
LocalOpenWeek provides onOpenWeek,
|
LocalOpenWeek provides onOpenWeek,
|
||||||
) {
|
) {
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
@@ -773,6 +775,7 @@ private fun MonthPage(
|
|||||||
) {
|
) {
|
||||||
val ym = yearMonthForIndex(index)
|
val ym = yearMonthForIndex(index)
|
||||||
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
|
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
|
||||||
|
val today = LocalMonthToday.current
|
||||||
val chipDrag = LocalMonthDrag.current
|
val chipDrag = LocalMonthDrag.current
|
||||||
val morph = LocalMonthMorph.current
|
val morph = LocalMonthMorph.current
|
||||||
val viewMorph = LocalViewMorph.current
|
val viewMorph = LocalViewMorph.current
|
||||||
@@ -782,7 +785,7 @@ private fun MonthPage(
|
|||||||
LocalViewMorph provides viewMorph.takeIf { morphActive },
|
LocalViewMorph provides viewMorph.takeIf { morphActive },
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is MonthUiState.Success -> page(index, s)
|
is MonthUiState.Success -> page(index, s.withToday(today))
|
||||||
else -> loading()
|
else -> loading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1388,7 +1391,8 @@ private fun SplitMonthContent(
|
|||||||
val ym = yearMonthForIndex(index)
|
val ym = yearMonthForIndex(index)
|
||||||
val pageState by remember(ym, weekStart) { monthState(ym, weekStart) }
|
val pageState by remember(ym, weekStart) { monthState(ym, weekStart) }
|
||||||
.collectAsStateWithLifecycle()
|
.collectAsStateWithLifecycle()
|
||||||
val page = pageState as? MonthUiState.Success ?: return@HorizontalPager
|
val page = (pageState as? MonthUiState.Success)?.withToday(LocalMonthToday.current)
|
||||||
|
?: return@HorizontalPager
|
||||||
// Only the settled page carries the drag and the morph tags: its
|
// Only the settled page carries the drag and the morph tags: its
|
||||||
// neighbours repeat some of its dates and would claim them.
|
// neighbours repeat some of its dates and would claim them.
|
||||||
val active = index == pagerState.settledPage
|
val active = index == pagerState.settledPage
|
||||||
@@ -1937,7 +1941,7 @@ private fun SplitDots(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||||
.size(SPLIT_DOT_SIZE)
|
.size(SPLIT_DOT_SIZE)
|
||||||
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
.alpha(if (dimCutoff != null && event.isPastOn(date, dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventAccent(event.color, dark, soften), CircleShape),
|
.background(eventAccent(event.color, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1951,7 +1955,7 @@ private fun SplitDots(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.morphBounds(MonthMorphKey.Overflow(date))
|
.morphBounds(MonthMorphKey.Overflow(date))
|
||||||
.alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f),
|
.alpha(if (allEnded(hidden, date, dimCutoff)) EventDimAlpha else 1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1994,7 +1998,7 @@ private fun SplitMonthAgendaPane(
|
|||||||
day = day,
|
day = day,
|
||||||
zone = state.zone,
|
zone = state.zone,
|
||||||
position = positionOf(index, events.size),
|
position = positionOf(index, events.size),
|
||||||
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
|
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
|
||||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
|
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
@@ -2063,7 +2067,7 @@ internal fun SplitDayPane(
|
|||||||
day = day,
|
day = day,
|
||||||
zone = zone,
|
zone = zone,
|
||||||
position = positionOf(index, dayEvents.size),
|
position = positionOf(index, dayEvents.size),
|
||||||
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
|
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
// An event added to or removed from the day you are
|
// An event added to or removed from the day you are
|
||||||
// already looking at moves its neighbours rather
|
// already looking at moves its neighbours rather
|
||||||
@@ -2539,6 +2543,7 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
OverflowDots(
|
OverflowDots(
|
||||||
events = hiddenEvents,
|
events = hiddenEvents,
|
||||||
|
day = d,
|
||||||
total = hidden,
|
total = hidden,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
rowHeight = overflowRow,
|
rowHeight = overflowRow,
|
||||||
@@ -2863,7 +2868,9 @@ private fun MonthBar(
|
|||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
// A bar is one piece, so it dims once the last day it covers has passed.
|
||||||
|
val dimmed = dimCutoff != null &&
|
||||||
|
(event.hasEnded(dimCutoff) || days?.let { event.isPastOn(it.last(), dimCutoff) } == true)
|
||||||
val paint = eventPaint(event, dark)
|
val paint = eventPaint(event, dark)
|
||||||
// The same title/secondary ink pairing the week and day blocks use, with
|
// The same title/secondary ink pairing the week and day blocks use, with
|
||||||
// the time on the quieter half.
|
// the time on the quieter half.
|
||||||
@@ -2916,6 +2923,7 @@ private fun MonthBar(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun OverflowDots(
|
private fun OverflowDots(
|
||||||
events: List<EventInstance>,
|
events: List<EventInstance>,
|
||||||
|
day: LocalDate,
|
||||||
total: Int,
|
total: Int,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
rowHeight: Dp,
|
rowHeight: Dp,
|
||||||
@@ -2934,7 +2942,7 @@ private fun OverflowDots(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(OVERFLOW_DOT_SIZE)
|
.size(OVERFLOW_DOT_SIZE)
|
||||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
.alpha(if (allEnded(byColor.getValue(argb), day, dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventAccent(argb, dark, soften), CircleShape),
|
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2944,15 +2952,15 @@ private fun OverflowDots(
|
|||||||
text = "+$extra",
|
text = "+$extra",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f),
|
modifier = Modifier.alpha(if (allEnded(events, day, dimCutoff)) EventDimAlpha else 1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when dimming is on and every one of [events] is already over. */
|
/** True when dimming is on and every one of [events] is already over on [day]. */
|
||||||
private fun allEnded(events: List<EventInstance>, dimCutoff: Instant?): Boolean =
|
private fun allEnded(events: List<EventInstance>, day: LocalDate, dimCutoff: Instant?): Boolean =
|
||||||
dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) }
|
dimCutoff != null && events.isNotEmpty() && events.all { it.isPastOn(day, dimCutoff) }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthGridLoading() {
|
private fun MonthGridLoading() {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -45,7 +47,6 @@ import kotlinx.datetime.minus
|
|||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ private const val MONTH_CACHE_SPAN = 2
|
|||||||
class MonthViewModel @Inject constructor(
|
class MonthViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
settingsPrefs: SettingsPrefs,
|
settingsPrefs: SettingsPrefs,
|
||||||
|
todaySource: TodaySource,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -90,7 +92,18 @@ class MonthViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = currentDate()
|
||||||
|
|
||||||
|
private var lastToday = todayDate
|
||||||
|
|
||||||
|
/** Today, kept current across midnight so a grid left open doesn't mark yesterday (#373). */
|
||||||
|
val today: StateFlow<LocalDate> = todaySource.today
|
||||||
|
.onEach { date ->
|
||||||
|
val old = lastToday
|
||||||
|
lastToday = date
|
||||||
|
if (old != date) followToday(old, date)
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
|
||||||
|
|
||||||
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
||||||
val month: StateFlow<YearMonth> = _month
|
val month: StateFlow<YearMonth> = _month
|
||||||
@@ -139,8 +152,9 @@ class MonthViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
repository.instances(monthGridRange(ym, ws, zone)),
|
repository.instances(monthGridRange(ym, ws, zone)),
|
||||||
) { calendars, instances ->
|
today,
|
||||||
buildState(ym, ws, calendars, instances)
|
) { calendars, instances, today ->
|
||||||
|
buildState(ym, ws, calendars, instances, today)
|
||||||
}
|
}
|
||||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
@@ -211,8 +225,9 @@ class MonthViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
repository.instances(range),
|
repository.instances(range),
|
||||||
) { calendars, instances ->
|
today,
|
||||||
buildContinuousState(window, ws, style, calendars, instances)
|
) { calendars, instances, today ->
|
||||||
|
buildContinuousState(window, ws, style, calendars, instances, today)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A load landing is the cue to reach further out. Widening from here
|
// A load landing is the cue to reach further out. Widening from here
|
||||||
@@ -257,6 +272,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
style: MonthViewStyle,
|
style: MonthViewStyle,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
|
today: LocalDate,
|
||||||
): ContinuousMonthUiState {
|
): ContinuousMonthUiState {
|
||||||
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) }
|
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) }
|
||||||
// Bucketed once for the whole window: a row then takes the handful of
|
// Bucketed once for the whole window: a row then takes the handful of
|
||||||
@@ -283,7 +299,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
emptyMap()
|
emptyMap()
|
||||||
}
|
}
|
||||||
return ContinuousMonthUiState.Success(
|
return ContinuousMonthUiState.Success(
|
||||||
today = todayDate,
|
today = today,
|
||||||
monthsByIndex = months,
|
monthsByIndex = months,
|
||||||
weeksByIndex = weeks,
|
weeksByIndex = weeks,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
@@ -333,6 +349,18 @@ class MonthViewModel @Inject constructor(
|
|||||||
_selectedDate.value = null
|
_selectedDate.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date rolled over from [old] to [new]. A paged grid still on [old]'s
|
||||||
|
* month moves on to [new]'s and drops the day picked yesterday; one browsed
|
||||||
|
* elsewhere stays put. The scrolling styles only repaint the today marker.
|
||||||
|
*/
|
||||||
|
private fun followToday(old: LocalDate, new: LocalDate) {
|
||||||
|
if (viewStyle.value.isScrolling) return
|
||||||
|
if (_month.value != YearMonth(old.year, old.month)) return
|
||||||
|
_month.value = YearMonth(new.year, new.month)
|
||||||
|
_selectedDate.value = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track [_month] to the month a scrolling style is showing. Those styles
|
* Track [_month] to the month a scrolling style is showing. Those styles
|
||||||
* navigate by scroll position and never set [_month] themselves, so it would
|
* navigate by scroll position and never set [_month] themselves, so it would
|
||||||
@@ -358,12 +386,13 @@ class MonthViewModel @Inject constructor(
|
|||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
|
today: LocalDate,
|
||||||
): MonthUiState {
|
): MonthUiState {
|
||||||
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) }
|
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) }
|
||||||
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||||
return MonthUiState.Success(
|
return MonthUiState.Success(
|
||||||
month = ym,
|
month = ym,
|
||||||
today = todayDate,
|
today = today,
|
||||||
weeks = weeks,
|
weeks = weeks,
|
||||||
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
|
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
|
||||||
zone = zone,
|
zone = zone,
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ data class SettingsUiState(
|
|||||||
val todayButtonInToolbar: Boolean = false,
|
val todayButtonInToolbar: Boolean = false,
|
||||||
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
|
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
|
||||||
val dragToReschedule: Boolean = true,
|
val dragToReschedule: Boolean = true,
|
||||||
|
/** Whether tapping an event opens the edit form instead of the detail screen (#178). */
|
||||||
|
val tapOpensEditor: Boolean = false,
|
||||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
/** How far ahead the agenda widget shows events (v2.11). */
|
/** How far ahead the agenda widget shows events (v2.11). */
|
||||||
|
|||||||
@@ -148,13 +148,16 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.softenCalendarColors,
|
prefs.softenCalendarColors,
|
||||||
combine(
|
combine(
|
||||||
prefs.todayButtonInToolbar,
|
prefs.todayButtonInToolbar,
|
||||||
prefs.dragToReschedule,
|
combine(prefs.dragToReschedule, prefs.tapOpensEditor, ::Pair),
|
||||||
prefs.multiDayCount,
|
prefs.multiDayCount,
|
||||||
::Triple,
|
::Triple,
|
||||||
),
|
),
|
||||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) ->
|
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, gestures, multiDay) ->
|
||||||
DisplayToggles(
|
DisplayToggles(
|
||||||
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay,
|
hourLines, weekNumbers, showToday, soften, todayInToolbar,
|
||||||
|
dragToReschedule = gestures.first,
|
||||||
|
tapOpensEditor = gestures.second,
|
||||||
|
multiDayCount = multiDay,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||||
@@ -166,6 +169,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
softenColors = toggles.softenColors,
|
softenColors = toggles.softenColors,
|
||||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||||
dragToReschedule = toggles.dragToReschedule,
|
dragToReschedule = toggles.dragToReschedule,
|
||||||
|
tapOpensEditor = toggles.tapOpensEditor,
|
||||||
multiDayCount = toggles.multiDayCount,
|
multiDayCount = toggles.multiDayCount,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -202,6 +206,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
softenColors = views.softenColors,
|
softenColors = views.softenColors,
|
||||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||||
dragToReschedule = views.dragToReschedule,
|
dragToReschedule = views.dragToReschedule,
|
||||||
|
tapOpensEditor = views.tapOpensEditor,
|
||||||
multiDayCount = views.multiDayCount,
|
multiDayCount = views.multiDayCount,
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
@@ -305,6 +310,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
val todayButtonInToolbar: Boolean,
|
val todayButtonInToolbar: Boolean,
|
||||||
val dragToReschedule: Boolean,
|
val dragToReschedule: Boolean,
|
||||||
|
val tapOpensEditor: Boolean,
|
||||||
val multiDayCount: Int,
|
val multiDayCount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -315,6 +321,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
val todayButtonInToolbar: Boolean,
|
val todayButtonInToolbar: Boolean,
|
||||||
val dragToReschedule: Boolean,
|
val dragToReschedule: Boolean,
|
||||||
|
val tapOpensEditor: Boolean,
|
||||||
val multiDayCount: Int,
|
val multiDayCount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -562,6 +569,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
|
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTapOpensEditor(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setTapOpensEditor(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch the launcher label (#44). The card highlights at once, then settles
|
* Switch the launcher label (#44). The card highlights at once, then settles
|
||||||
* on what the component state reports; the writes are binder round-trips and
|
* on what the component state reports; the writes are binder round-trips and
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ internal fun ViewsScreen(
|
|||||||
var showDefaultView by remember { mutableStateOf(false) }
|
var showDefaultView by remember { mutableStateOf(false) }
|
||||||
var showWeekStart by remember { mutableStateOf(false) }
|
var showWeekStart by remember { mutableStateOf(false) }
|
||||||
var showTimeFormat by remember { mutableStateOf(false) }
|
var showTimeFormat by remember { mutableStateOf(false) }
|
||||||
|
var showTapOpens by remember { mutableStateOf(false) }
|
||||||
var showPastEvents by remember { mutableStateOf(false) }
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||||
var showTimelineScale by remember { mutableStateOf(false) }
|
var showTimelineScale by remember { mutableStateOf(false) }
|
||||||
@@ -125,7 +126,7 @@ internal fun ViewsScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_dim_completed),
|
title = stringResource(R.string.settings_dim_completed),
|
||||||
summary = stringResource(R.string.settings_dim_completed_summary),
|
summary = stringResource(R.string.settings_dim_completed_summary),
|
||||||
position = Position.Middle,
|
position = Position.Bottom,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.dimCompletedEvents,
|
checked = state.dimCompletedEvents,
|
||||||
@@ -134,6 +135,17 @@ internal fun ViewsScreen(
|
|||||||
},
|
},
|
||||||
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// How events respond to a tap and a long press (#178, #141).
|
||||||
|
SectionHeader(stringResource(R.string.settings_events_header))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_tap_opens),
|
||||||
|
summary = tapOpensLabel(state.tapOpensEditor),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showTapOpens = true },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_drag_to_reschedule),
|
title = stringResource(R.string.settings_drag_to_reschedule),
|
||||||
summary = stringResource(R.string.settings_drag_to_reschedule_summary),
|
summary = stringResource(R.string.settings_drag_to_reschedule_summary),
|
||||||
@@ -387,6 +399,18 @@ internal fun ViewsScreen(
|
|||||||
onDismiss = { showTimeFormat = false },
|
onDismiss = { showTimeFormat = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showTapOpens) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_tap_opens),
|
||||||
|
header = { PickerDescription(stringResource(R.string.settings_tap_opens_hint)) },
|
||||||
|
predictiveBack = true,
|
||||||
|
options = listOf(false, true),
|
||||||
|
selected = state.tapOpensEditor,
|
||||||
|
label = { tapOpensLabel(it) },
|
||||||
|
onSelect = viewModel::setTapOpensEditor,
|
||||||
|
onDismiss = { showTapOpens = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showPastEvents) {
|
if (showPastEvents) {
|
||||||
PastEventsPicker(
|
PastEventsPicker(
|
||||||
selected = state.pastEventDisplay,
|
selected = state.pastEventDisplay,
|
||||||
@@ -522,6 +546,11 @@ internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
|||||||
.getDisplayName(JavaTextStyle.FULL, currentLocale())
|
.getDisplayName(JavaTextStyle.FULL, currentLocale())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun tapOpensLabel(editor: Boolean): String = stringResource(
|
||||||
|
if (editor) R.string.settings_tap_opens_editor else R.string.settings_tap_opens_details,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
||||||
when (pref) {
|
when (pref) {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
@@ -59,11 +60,37 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.draftBlock
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.draftEvent
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.draftStartMin
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.isDraft
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.wallClockInstant
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickUp
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ResizeEdge
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ResizeHandle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ResizeHandleTarget
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PublishSelection
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventSelectAllowed
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.selectionLift
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.BiasAlignment
|
import androidx.compose.ui.BiasAlignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
||||||
import de.jeanlucmakiola.calendula.ui.common.StayPut
|
import de.jeanlucmakiola.calendula.ui.common.StayPut
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
@@ -73,12 +100,14 @@ import androidx.compose.runtime.SideEffect
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.laneSlot
|
import de.jeanlucmakiola.calendula.ui.common.laneSlot
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LaneColumn
|
import de.jeanlucmakiola.calendula.ui.common.LaneColumn
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.time.Instant
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.layout.Layout
|
import androidx.compose.ui.layout.Layout
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.ui.layout.layout
|
import androidx.compose.ui.layout.layout
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
@@ -98,7 +127,6 @@ 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.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
||||||
@@ -135,6 +163,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.isPastOn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
@@ -221,6 +250,7 @@ fun TimelineScreen(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
onCreateDraft: (Instant, Instant) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
weekViewModel: WeekViewModel = hiltViewModel(),
|
weekViewModel: WeekViewModel = hiltViewModel(),
|
||||||
multiDayViewModel: MultiDayViewModel = hiltViewModel(),
|
multiDayViewModel: MultiDayViewModel = hiltViewModel(),
|
||||||
@@ -235,6 +265,7 @@ fun TimelineScreen(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
|
onCreateDraft = onCreateDraft,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -281,6 +312,12 @@ internal val CalendarView.timelineKind: TimelineKind
|
|||||||
else -> TimelineKind.Week
|
else -> TimelineKind.Week
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone the shown timeline is laid out in, so its blocks, drags and drops all
|
||||||
|
* read the same wall clock (#387).
|
||||||
|
*/
|
||||||
|
internal val LocalTimelineZone = staticCompositionLocalOf { TimeZone.currentSystemDefault() }
|
||||||
|
|
||||||
/** How long a switch waits for the view it goes to before resizing into it without its events. */
|
/** How long a switch waits for the view it goes to before resizing into it without its events. */
|
||||||
private const val MORPH_DATA_WAIT_MS = 150L
|
private const val MORPH_DATA_WAIT_MS = 150L
|
||||||
|
|
||||||
@@ -292,6 +329,7 @@ private fun PagedTimelineScreen(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
onCreateDraft: (Instant, Instant) -> Unit,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
) {
|
) {
|
||||||
// The view on screen, which trails [view] while the columns resize into it.
|
// The view on screen, which trails [view] while the columns resize into it.
|
||||||
@@ -324,6 +362,7 @@ private fun PagedTimelineScreen(
|
|||||||
val grid = position.grid
|
val grid = position.grid
|
||||||
val anchorPage = position.page
|
val anchorPage = position.page
|
||||||
val dimCompleted by key(viewModel) { viewModel.dimCompletedEvents.collectAsStateWithLifecycle() }
|
val dimCompleted by key(viewModel) { viewModel.dimCompletedEvents.collectAsStateWithLifecycle() }
|
||||||
|
val draftMinutes by key(viewModel) { viewModel.defaultEventDuration.collectAsStateWithLifecycle() }
|
||||||
// The instant before which an event counts as completed, or null when dimming
|
// The instant before which an event counts as completed, or null when dimming
|
||||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||||
// screen while the setting is off (it stays null regardless of the tick).
|
// screen while the setting is off (it stays null regardless of the tick).
|
||||||
@@ -417,7 +456,8 @@ private fun PagedTimelineScreen(
|
|||||||
|
|
||||||
// Off the ticking clock rather than the loaded page, so it turns over at
|
// Off the ticking clock rather than the loaded page, so it turns over at
|
||||||
// midnight; derived, so the per-minute tick doesn't recompose the screen.
|
// midnight; derived, so the per-minute tick doesn't recompose the screen.
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
// The timeline's own zone, so "today" agrees with the blocks and drops (#387).
|
||||||
|
val zone by viewModel.zone.collectAsStateWithLifecycle()
|
||||||
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
|
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
|
||||||
val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd)
|
val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd)
|
||||||
|
|
||||||
@@ -458,9 +498,13 @@ private fun PagedTimelineScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
ScreenBody(modifier = modifier) { innerPadding ->
|
ScreenBody(modifier = modifier) { innerPadding ->
|
||||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
CompositionLocalProvider(
|
||||||
|
LocalDimCutoff provides dimCutoff,
|
||||||
|
LocalTimelineZone provides zone,
|
||||||
|
) {
|
||||||
WeekContent(
|
WeekContent(
|
||||||
state = state,
|
state = state,
|
||||||
|
today = today,
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
grid = grid,
|
grid = grid,
|
||||||
kind = kind,
|
kind = kind,
|
||||||
@@ -471,6 +515,8 @@ private fun PagedTimelineScreen(
|
|||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
|
onCreateDraft = onCreateDraft,
|
||||||
|
draftMinutes = draftMinutes,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
@@ -482,6 +528,7 @@ private fun PagedTimelineScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WeekContent(
|
private fun WeekContent(
|
||||||
state: WeekUiState,
|
state: WeekUiState,
|
||||||
|
today: LocalDate,
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
grid: PageGrid,
|
grid: PageGrid,
|
||||||
kind: TimelineKind,
|
kind: TimelineKind,
|
||||||
@@ -492,6 +539,8 @@ private fun WeekContent(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onCreateDraft: (Instant, Instant) -> Unit,
|
||||||
|
draftMinutes: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
@@ -506,19 +555,95 @@ private fun WeekContent(
|
|||||||
// Above the pager: a page change mid-drag would strand the floating block
|
// Above the pager: a page change mid-drag would strand the floating block
|
||||||
// inside the outgoing page.
|
// inside the outgoing page.
|
||||||
val dragController = rememberTimelineDragController()
|
val dragController = rememberTimelineDragController()
|
||||||
|
SideEffect { dragController.draftMinutes = draftMinutes }
|
||||||
val move = LocalEventMove.current
|
val move = LocalEventMove.current
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = LocalTimelineZone.current
|
||||||
val onDrop: (TimelineDrop) -> Unit = { drop ->
|
val onDrop: (TimelineDrop) -> Unit = drop@{ drop ->
|
||||||
|
val start = drop.startInstant(zone)
|
||||||
|
val moved = drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start))
|
||||||
|
// A draft has nothing to write, so it stays selected where it landed (#153).
|
||||||
|
if (drop.event.isDraft) {
|
||||||
|
dragController.select(moved)
|
||||||
|
return@drop
|
||||||
|
}
|
||||||
|
// A selected event stays selected where it landed (#141).
|
||||||
|
if (dragController.selection?.event?.instanceId == drop.event.instanceId) {
|
||||||
|
dragController.select(moved, moved = true)
|
||||||
|
}
|
||||||
move?.move(
|
move?.move(
|
||||||
MoveRequest(
|
MoveRequest(
|
||||||
eventId = drop.event.eventId,
|
eventId = drop.event.eventId,
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
target = MoveTarget.Start(start),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A resize keeps its event selected, at its new length (#141).
|
||||||
|
val onResize: (TimelineResizeDrop) -> Unit = resize@{ drop ->
|
||||||
|
val resized = drop.event.copy(start = drop.start, end = drop.end)
|
||||||
|
if (drop.event.isDraft) {
|
||||||
|
dragController.select(resized)
|
||||||
|
return@resize
|
||||||
|
}
|
||||||
|
dragController.select(resized, moved = true)
|
||||||
|
move?.move(
|
||||||
|
MoveRequest(
|
||||||
|
eventId = drop.event.eventId,
|
||||||
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||||
|
target = MoveTarget.Span(drop.start, drop.end),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A drafted block (#153) opens the new-event form at its own instants: the
|
||||||
|
// end it is drawn to differs on a DST change (#387).
|
||||||
|
val createDraft: (EventInstance) -> Unit = { draft ->
|
||||||
|
dragController.deselect()
|
||||||
|
onCreateDraft(draft.start, draft.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection (#141) ends on back, a page or view change, or any tap.
|
||||||
|
val selection = dragController.selection
|
||||||
|
PublishSelection(
|
||||||
|
event = selection?.event,
|
||||||
|
busy = dragController.isDragging,
|
||||||
|
compact = dragController.selectionCovered,
|
||||||
|
onDismiss = dragController::deselect,
|
||||||
|
onCreate = selection?.event?.takeIf { it.isDraft }?.let { draft -> { createDraft(draft) } },
|
||||||
|
)
|
||||||
|
BackHandler(enabled = selection != null) { dragController.deselect() }
|
||||||
|
LaunchedEffect(pagerState, dragController) {
|
||||||
|
snapshotFlow { pagerState.currentPage }.drop(1).collect {
|
||||||
|
// Deselecting would delete a draft mid-drag, so it stays until it lands.
|
||||||
|
val draftInFlight = dragController.isDragging && dragController.selection?.event?.isDraft == true
|
||||||
|
if (!draftInFlight) dragController.deselect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(morph != null) { if (morph != null) dragController.deselect() }
|
||||||
|
LaunchedEffect(selection, dragController.settling, dragController.isDragging, dragController.selectedCandidates.size) {
|
||||||
|
dragController.settleMovedSelection()
|
||||||
|
}
|
||||||
|
val orphaned = selection != null && dragController.selectedCandidates.isEmpty() &&
|
||||||
|
!dragController.isDragging && dragController.settling == null
|
||||||
|
LaunchedEffect(orphaned) {
|
||||||
|
if (!orphaned) return@LaunchedEffect
|
||||||
|
// A beat for a re-read to bring the event back under a new row.
|
||||||
|
delay(SELECTION_ORPHAN_GRACE_MILLIS)
|
||||||
|
dragController.deselect()
|
||||||
|
}
|
||||||
|
val requireWrite = rememberWriteGate()
|
||||||
|
val openEvent: (EventInstance) -> Unit = { event ->
|
||||||
|
if (event.isDraft) {
|
||||||
|
requireWrite { createDraft(event) }
|
||||||
|
} else {
|
||||||
|
dragController.deselect()
|
||||||
|
onEventClick(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) {
|
CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) {
|
||||||
// Loading and failure concern the provider as a whole, so they stand in
|
// Loading and failure concern the provider as a whole, so they stand in
|
||||||
@@ -536,27 +661,29 @@ private fun WeekContent(
|
|||||||
is WeekUiState.Success -> if (morph != null) {
|
is WeekUiState.Success -> if (morph != null) {
|
||||||
TimelineMorphFrame(
|
TimelineMorphFrame(
|
||||||
morph = morph,
|
morph = morph,
|
||||||
today = s.today,
|
today = today,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = openEvent,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
} else WeekPager(
|
} else WeekPager(
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
grid = grid,
|
grid = grid,
|
||||||
kind = kind,
|
kind = kind,
|
||||||
span = span,
|
span = span,
|
||||||
today = s.today,
|
today = today,
|
||||||
initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
|
initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = openEvent,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,6 +710,7 @@ private fun WeekPager(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||||
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
|
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
|
||||||
@@ -618,7 +746,10 @@ private fun WeekPager(
|
|||||||
}
|
}
|
||||||
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
|
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
|
||||||
WeekPage(
|
WeekPage(
|
||||||
state = loaded ?: empty,
|
// A cached page may predate midnight; today comes off the clock (#373).
|
||||||
|
state = remember(loaded, empty, today) {
|
||||||
|
loaded?.takeIf { it.today != today }?.copy(today = today) ?: loaded ?: empty
|
||||||
|
},
|
||||||
headerBias = kind.headerBias,
|
headerBias = kind.headerBias,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
@@ -628,6 +759,7 @@ private fun WeekPager(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -662,6 +794,7 @@ private fun TimelineMorphFrame(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
val fromState by morph.fromState.collectAsStateWithLifecycle()
|
val fromState by morph.fromState.collectAsStateWithLifecycle()
|
||||||
val toState by morph.toState.collectAsStateWithLifecycle()
|
val toState by morph.toState.collectAsStateWithLifecycle()
|
||||||
@@ -717,6 +850,7 @@ private fun TimelineMorphFrame(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -810,6 +944,7 @@ internal fun WeekSuccess(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
WeekFrame(
|
WeekFrame(
|
||||||
weekStart = state.weekStart,
|
weekStart = state.weekStart,
|
||||||
@@ -829,6 +964,7 @@ internal fun WeekSuccess(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -851,6 +987,7 @@ private fun WeekPage(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
val geometry = remember(state.weekStart, state.days.size) {
|
val geometry = remember(state.weekStart, state.days.size) {
|
||||||
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
|
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
|
||||||
@@ -870,6 +1007,7 @@ private fun WeekPage(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,6 +1031,7 @@ private fun TimelinePageContent(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -923,6 +1062,7 @@ private fun TimelinePageContent(
|
|||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1071,6 +1211,7 @@ private fun AllDayStrip(
|
|||||||
key(bar.event.eventId, bar.event.start) {
|
key(bar.event.eventId, bar.event.start) {
|
||||||
AllDayBar(
|
AllDayBar(
|
||||||
event = bar.event,
|
event = bar.event,
|
||||||
|
lastDay = bar.toRange.endInclusive,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
onClick = { onEventClick(bar.event) },
|
onClick = { onEventClick(bar.event) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -1106,13 +1247,15 @@ private fun AllDayStrip(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AllDayBar(
|
private fun AllDayBar(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
|
lastDay: LocalDate,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
// A bar is one piece, so it dims once the last day it covers here has passed.
|
||||||
|
val dimmed = dimCutoff != null && event.isPastOn(lastDay, dimCutoff)
|
||||||
val paint = eventPaint(event, dark)
|
val paint = eventPaint(event, dark)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
@@ -1149,6 +1292,7 @@ private fun Timeline(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
val fullViewportHeight = remember(geometry) { intArrayOf(-1) }
|
val fullViewportHeight = remember(geometry) { intArrayOf(-1) }
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -1246,6 +1390,7 @@ private fun Timeline(
|
|||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
@@ -1268,6 +1413,7 @@ private fun DayColumnCard(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -1275,10 +1421,16 @@ private fun DayColumnCard(
|
|||||||
val hourPx = with(density) { hourHeight.toPx() }
|
val hourPx = with(density) { hourHeight.toPx() }
|
||||||
val morphing = LocalViewMorph.current != null
|
val morphing = LocalViewMorph.current != null
|
||||||
val showHourGrid = LocalShowHourGrid.current
|
val showHourGrid = LocalShowHourGrid.current
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = LocalTimelineZone.current
|
||||||
|
// A block drafted on an empty slot (#153), drawn over the part of it on this day.
|
||||||
|
val draft by remember(dragController) {
|
||||||
|
derivedStateOf { dragController.selection?.event?.takeIf { it.isDraft } }
|
||||||
|
}
|
||||||
|
val draftPiece = remember(draft, date, zone) { draft?.let { draftBlock(it, date, zone) } }
|
||||||
|
val shown = remember(blocks, draftPiece) { if (draftPiece == null) blocks else blocks + draftPiece }
|
||||||
// Tells a settled drop when this column has caught up with it.
|
// Tells a settled drop when this column has caught up with it.
|
||||||
LaunchedEffect(blocks, dragController.settling) {
|
LaunchedEffect(shown, dragController.settling) {
|
||||||
dragController.noteGrid(date, blocks)
|
dragController.noteGrid(date, shown)
|
||||||
}
|
}
|
||||||
// The tap detector outlives the composition that installed it — a pinch or a
|
// The tap detector outlives the composition that installed it — a pinch or a
|
||||||
// Settings change moves the hour height without restarting it — so it reads
|
// Settings change moves the hour height without restarting it — so it reads
|
||||||
@@ -1288,6 +1440,7 @@ private fun DayColumnCard(
|
|||||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||||
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
|
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
|
||||||
val currentEdges = rememberUpdatedState(edges)
|
val currentEdges = rememberUpdatedState(edges)
|
||||||
|
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, onDrop)
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular columns — the soft corners come from the outer
|
// Plain rectangular columns — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -1316,20 +1469,27 @@ private fun DayColumnCard(
|
|||||||
// blocks are consumed by their own handler first. Snaps to hour.
|
// blocks are consumed by their own handler first. Snaps to hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
detectTapGestures { offset ->
|
detectTapGestures { offset ->
|
||||||
|
if (dragController.selection != null) {
|
||||||
|
dragController.deselect()
|
||||||
|
return@detectTapGestures
|
||||||
|
}
|
||||||
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
|
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
|
||||||
currentOnCreateAt.value(
|
currentOnCreateAt.value(
|
||||||
date,
|
date,
|
||||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
// Inside the tap detector, which consumes every down, so only a
|
||||||
|
// block or handle on top can claim the press first.
|
||||||
|
.then(draftSurface),
|
||||||
) {
|
) {
|
||||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||||
// Keyed by event, so a block that changes time or lane is the *same*
|
// Keyed by event, so a block that changes time or lane is the *same*
|
||||||
// composable afterwards and tweens there. The ordinal disambiguates
|
// composable afterwards and tweens there. The ordinal disambiguates
|
||||||
// a column holding two occurrences of one series.
|
// a column holding two occurrences of one series.
|
||||||
val ordinals = mutableMapOf<Long, Int>()
|
val ordinals = mutableMapOf<Long, Int>()
|
||||||
blocks.forEach { block ->
|
shown.forEach { block ->
|
||||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||||
key(block.event.eventId, ordinal) {
|
key(block.event.eventId, ordinal) {
|
||||||
val top = hourHeight * (block.startMin / 60f)
|
val top = hourHeight * (block.startMin / 60f)
|
||||||
@@ -1340,6 +1500,7 @@ private fun DayColumnCard(
|
|||||||
widthFraction = 1f / block.laneCount,
|
widthFraction = 1f / block.laneCount,
|
||||||
y = top,
|
y = top,
|
||||||
height = height,
|
height = height,
|
||||||
|
landing = dragController.ghosts(block),
|
||||||
)
|
)
|
||||||
// Where this block is cut at midnight, which decides both
|
// Where this block is cut at midnight, which decides both
|
||||||
// its shape and the half-gap it gives up to the hour cells.
|
// its shape and the half-gap it gives up to the hour cells.
|
||||||
@@ -1375,6 +1536,7 @@ private fun DayColumnCard(
|
|||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.laneSlot(place)
|
.laneSlot(place)
|
||||||
.offset(y = place.y + topInset)
|
.offset(y = place.y + topInset)
|
||||||
@@ -1419,13 +1581,16 @@ private fun EventBlock(
|
|||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
val zone = LocalTimelineZone.current
|
||||||
|
val labelEndMin = remember(block, date, zone) { block.labelEndMin(date, zone) }
|
||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(labelEndMin, use24Hour, locale)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val metrics = rememberBlockTextMetrics(height)
|
val metrics = rememberBlockTextMetrics(height)
|
||||||
// Only full-width (non-overlapping) blocks that are tall enough show the
|
// Only full-width (non-overlapping) blocks that are tall enough show the
|
||||||
@@ -1439,7 +1604,7 @@ private fun EventBlock(
|
|||||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||||
val shownTime = rememberBlockTimeRange(
|
val shownTime = rememberBlockTimeRange(
|
||||||
minToHm(block.startMin, use24Hour, locale),
|
minToHm(block.startMin, use24Hour, locale),
|
||||||
minToHm(block.endMin, use24Hour, locale),
|
minToHm(labelEndMin, use24Hour, locale),
|
||||||
textWidth,
|
textWidth,
|
||||||
)
|
)
|
||||||
// A short block drops the title rather than serving a horizontally sliced
|
// A short block drops the title rather than serving a horizontally sliced
|
||||||
@@ -1469,72 +1634,237 @@ private fun EventBlock(
|
|||||||
if (showTime) metrics.timeLine else 0.dp
|
if (showTime) metrics.timeLine else 0.dp
|
||||||
val timeMaxLines = if (showTime) blockTimeLines(shownTime, textWidth, spare) else 1
|
val timeMaxLines = if (showTime) blockTimeLines(shownTime, textWidth, spare) else 1
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff)
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
val moveAction = eventMoveAction(block.event)
|
val moveAction = eventMoveAction(block.event)
|
||||||
val draggable = eventDragAllowed(block.event)
|
val draft = block.event.isDraft
|
||||||
|
val draggable = draft || eventDragAllowed(block.event)
|
||||||
|
val selectable = eventSelectAllowed()
|
||||||
|
val selected = dragController.isSelected(block)
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
// The drop takes this offset back off, so a tail clipped at midnight lands
|
// The drop takes this offset back off, so a tail clipped at midnight lands
|
||||||
// where the event's own start belongs (#253).
|
// where the event's own start belongs (#253).
|
||||||
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
||||||
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
|
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
|
||||||
val topInsetPx = with(density) { topInset.toPx() }
|
val topInsetPx = with(density) { topInset.toPx() }
|
||||||
|
// Long press (#141): move to drag, let go to select; selected blocks drag at once.
|
||||||
val dragModifier = rememberEventDragSource(
|
val dragModifier = rememberEventDragSource(
|
||||||
enabled = draggable,
|
enabled = selectable,
|
||||||
key = block.event.instanceId,
|
key = block.event.instanceId,
|
||||||
|
immediate = selected && draggable,
|
||||||
onPickUp = { pointer, blockRoot, _ ->
|
onPickUp = { pointer, blockRoot, _ ->
|
||||||
dragController.begin(
|
if (draggable) {
|
||||||
block = block,
|
dragController.begin(
|
||||||
clipOffsetMin = clipOffset,
|
block = block,
|
||||||
titleLines = if (showTitle) titleMaxLines else 0,
|
clipOffsetMin = clipOffset,
|
||||||
pointerInRoot = pointer,
|
zone = zone,
|
||||||
// Measured off the block's placement rather than off where the
|
titleLines = if (showTitle) titleMaxLines else 0,
|
||||||
// hour grid seats it: the copy is drawn from that placement and
|
pointerInRoot = pointer,
|
||||||
// re-seated the same half-gap down (#267).
|
// Measured off the block's placement rather than off where the
|
||||||
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
|
// hour grid seats it: the copy is drawn from that placement and
|
||||||
)
|
// re-seated the same half-gap down (#267).
|
||||||
|
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
|
||||||
|
)
|
||||||
|
PickUp.Drag
|
||||||
|
} else {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
dragController.select(block.event)
|
||||||
|
PickUp.Hold
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onMove = dragController::move,
|
onMove = dragController::move,
|
||||||
onDrop = { dragController.finish()?.let(onDrop) },
|
onDrop = { dragController.finish()?.let(onDrop) },
|
||||||
onCancel = dragController::cancel,
|
onCancel = dragController::cancel,
|
||||||
|
onRelease = { dragController.select(block.event, fromLift = draggable) },
|
||||||
)
|
)
|
||||||
val lifted = draggable && dragController.ghosts(block)
|
val lifted = draggable && dragController.ghosts(block)
|
||||||
val ghost = ghostAlpha(lifted)
|
// Released in place, the block takes over the copy's lift without a dip.
|
||||||
|
val takesOverLift = selected && dragController.selection?.fromLift == true
|
||||||
|
val ghostFade = ghostAlpha(lifted)
|
||||||
|
val ghost = when {
|
||||||
|
takesOverLift && !lifted -> 1f
|
||||||
|
// A draft leaves no ghost behind, and the copy it lands under is identical.
|
||||||
|
draft -> if (lifted) 0f else 1f
|
||||||
|
else -> ghostFade
|
||||||
|
}
|
||||||
|
val raise = selectionLift(selected, snap = takesOverLift)
|
||||||
|
// Tells the action bar to fold when it would cover the bottom handle.
|
||||||
|
val revealExtraPx = with(density) { (ResizeHandleTarget / 2).toPx() }
|
||||||
|
val barClearancePx = with(density) { SelectionBarClearance.toPx() }
|
||||||
|
val tracksBar = selected && !cuts.bottom
|
||||||
|
// Not while its own write is pending: it still shows the old times.
|
||||||
|
LaunchedEffect(selected, block.event, lifted) {
|
||||||
|
if (selected && !lifted) dragController.adoptSelection(block)
|
||||||
|
}
|
||||||
|
// Kept composed while hidden: removing them would cancel their own resize.
|
||||||
|
DisposableEffect(selected, block.event) {
|
||||||
|
if (selected) dragController.noteSelectedBlock(block.event, true)
|
||||||
|
onDispose { if (selected) dragController.noteSelectedBlock(block.event, false) }
|
||||||
|
}
|
||||||
|
val showHandles = selected && draggable
|
||||||
|
val handlesShown = !lifted && !dragController.isDragging
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed && !selected) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
|
.zIndex(if (selected) 1f else 0f)
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.eventSurface(paint, shape, cuts)
|
.then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier)
|
||||||
.clickable(onClick = onClick)
|
.then(
|
||||||
// After clickable, so it is the inner node and wins the main pass;
|
if (tracksBar) {
|
||||||
// the tap still works, since a drag consumes the up.
|
Modifier.onGloballyPositioned {
|
||||||
.then(dragModifier)
|
dragController.noteSelectedBottom(it, revealExtraPx, barClearancePx)
|
||||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
}
|
||||||
.semantics {
|
} else {
|
||||||
contentDescription = "$title, $timeLabel"
|
Modifier
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
},
|
||||||
},
|
),
|
||||||
) {
|
) {
|
||||||
Column {
|
Box(
|
||||||
if (showTitle) {
|
modifier = Modifier
|
||||||
BlockTitle(
|
.fillMaxSize()
|
||||||
title = title,
|
.eventSurface(paint, shape, cuts)
|
||||||
maxLines = titleMaxLines,
|
.clickable(onClick = onClick)
|
||||||
color = paint.titleInk,
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
textDecoration = paint.decoration,
|
// the tap still works, since a drag consumes the up.
|
||||||
fontWeight = paint.titleWeight,
|
.then(dragModifier)
|
||||||
|
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = "$title, $timeLabel"
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
if (showTitle) {
|
||||||
|
BlockTitle(
|
||||||
|
title = title,
|
||||||
|
maxLines = titleMaxLines,
|
||||||
|
color = paint.titleInk,
|
||||||
|
textDecoration = paint.decoration,
|
||||||
|
fontWeight = paint.titleWeight,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTime) {
|
||||||
|
BlockTimeLabel(
|
||||||
|
label = shownTime,
|
||||||
|
color = paint.secondaryInk,
|
||||||
|
maxLines = timeMaxLines,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showHandles) {
|
||||||
|
val accent = eventAccent(block.event.color, dark, LocalSoftenColors.current)
|
||||||
|
val handleLines = if (showTitle) titleMaxLines else 0
|
||||||
|
val begin = { edge: ResizeEdge ->
|
||||||
|
{ pointer: Offset ->
|
||||||
|
dragController.beginResize(block, edge, zone, handleLines, pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val end = { dragController.finishResize(zone)?.let(onResize) }
|
||||||
|
// No bounce: an overshoot reads as the block's ends jumping.
|
||||||
|
val handleIn by animateFloatAsState(
|
||||||
|
targetValue = if (handlesShown) 1f else 0f,
|
||||||
|
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||||
|
label = "handles",
|
||||||
|
)
|
||||||
|
val handleAlpha = Modifier.graphicsLayer {
|
||||||
|
alpha = handleIn.coerceIn(0f, 1f)
|
||||||
|
scaleX = 0.5f + 0.5f * handleIn
|
||||||
|
scaleY = 0.5f + 0.5f * handleIn
|
||||||
|
}
|
||||||
|
if (!cuts.top) {
|
||||||
|
ResizeHandle(
|
||||||
|
color = accent,
|
||||||
|
enabled = handlesShown,
|
||||||
|
onStart = begin(ResizeEdge.Top),
|
||||||
|
onMove = dragController::move,
|
||||||
|
onEnd = { end() },
|
||||||
|
onCancel = dragController::cancel,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.offset(x = HANDLE_INSET - ResizeHandleTarget / 2, y = -ResizeHandleTarget / 2)
|
||||||
|
.then(handleAlpha),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showTime) {
|
if (!cuts.bottom) {
|
||||||
BlockTimeLabel(
|
ResizeHandle(
|
||||||
label = shownTime,
|
color = accent,
|
||||||
color = paint.secondaryInk,
|
enabled = handlesShown,
|
||||||
maxLines = timeMaxLines,
|
onStart = begin(ResizeEdge.Bottom),
|
||||||
|
onMove = dragController::move,
|
||||||
|
onEnd = { end() },
|
||||||
|
onCancel = dragController::cancel,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = ResizeHandleTarget / 2 - HANDLE_INSET, y = ResizeHandleTarget / 2)
|
||||||
|
.then(handleAlpha),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A long press on an empty stretch of [date]'s column (#153): drafts a block of
|
||||||
|
* the default length from the slot under the finger and lifts it, as a long
|
||||||
|
* press on an event does. Presses on a block or handle are left to them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberDraftSurface(
|
||||||
|
date: LocalDate,
|
||||||
|
edges: OutOfViewEdges,
|
||||||
|
hourPx: Float,
|
||||||
|
dragController: TimelineDragController,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
): Modifier {
|
||||||
|
val currentDrop by rememberUpdatedState(onDrop)
|
||||||
|
val title = stringResource(R.string.event_edit_new_title)
|
||||||
|
val color = MaterialTheme.colorScheme.primary.toArgb()
|
||||||
|
val zone = LocalTimelineZone.current
|
||||||
|
val currentEdges by rememberUpdatedState(edges)
|
||||||
|
// The draft this gesture lifted, selected again on release after the cancel.
|
||||||
|
val lifted = remember { arrayOfNulls<EventInstance>(1) }
|
||||||
|
val currentHourPx by rememberUpdatedState(hourPx)
|
||||||
|
return rememberDragSurface(
|
||||||
|
enabled = eventSelectAllowed(),
|
||||||
|
key = date,
|
||||||
|
skipClaimedDown = true,
|
||||||
|
// A press on a block or handle was consumed by it and never gets here.
|
||||||
|
onPickUp = { local, pointer, node, _ ->
|
||||||
|
val hour = currentHourPx
|
||||||
|
if (hour <= 0f || currentEdges.covers(local.y)) return@rememberDragSurface false
|
||||||
|
val startMin = draftStartMin(local.y / hour * 60f)
|
||||||
|
val event = draftEvent(
|
||||||
|
title = title,
|
||||||
|
color = color,
|
||||||
|
start = wallClockInstant(date, startMin, zone),
|
||||||
|
minutes = dragController.draftMinutes,
|
||||||
|
)
|
||||||
|
val block = draftBlock(event, date, zone) ?: return@rememberDragSurface false
|
||||||
|
lifted[0] = event
|
||||||
|
dragController.beginDraft(
|
||||||
|
block = block,
|
||||||
|
zone = zone,
|
||||||
|
pointerInRoot = pointer,
|
||||||
|
blockInRoot = node + Offset(0f, startMin / 60f * hour),
|
||||||
|
)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onMove = dragController::move,
|
||||||
|
onDrop = { dragController.finish()?.let(currentDrop) },
|
||||||
|
// An abandoned pickup leaves no draft behind; a release selects it again below.
|
||||||
|
onCancel = {
|
||||||
|
dragController.cancel()
|
||||||
|
if (dragController.selection?.event?.isDraft == true) dragController.deselect()
|
||||||
|
},
|
||||||
|
onRelease = { lifted[0]?.let { dragController.select(it, fromLift = true) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val SELECTION_ORPHAN_GRACE_MILLIS = 500L
|
||||||
|
|
||||||
|
/** How far in from the block's side a resize handle's centre sits. */
|
||||||
|
private val HANDLE_INSET = 16.dp
|
||||||
|
|
||||||
private val SKELETON_HEADER_HEIGHT = 36.dp
|
private val SKELETON_HEADER_HEIGHT = 36.dp
|
||||||
private val SKELETON_HEADER_PADDING = 8.dp
|
private val SKELETON_HEADER_PADDING = 8.dp
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.displayPriority
|
import de.jeanlucmakiola.calendula.domain.displayPriority
|
||||||
@@ -27,11 +30,15 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.FixedOffsetTimeZone
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toKotlinUtcOffset
|
||||||
|
import kotlinx.datetime.toJavaZoneId
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
import kotlinx.datetime.daysUntil
|
import kotlinx.datetime.daysUntil
|
||||||
@@ -40,8 +47,9 @@ import kotlinx.datetime.plus
|
|||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
import kotlin.time.toKotlinDuration
|
||||||
|
import kotlin.time.toKotlinInstant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
@@ -73,12 +81,19 @@ fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition
|
|||||||
abstract class PagedTimelineViewModel(
|
abstract class PagedTimelineViewModel(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
settingsPrefs: SettingsPrefs,
|
settingsPrefs: SettingsPrefs,
|
||||||
|
todaySource: TodaySource,
|
||||||
|
zoneSource: ZoneSource,
|
||||||
private val io: CoroutineDispatcher,
|
private val io: CoroutineDispatcher,
|
||||||
gridRule: Flow<(LocalDate) -> PageGrid>,
|
gridRule: Flow<(LocalDate) -> PageGrid>,
|
||||||
initialRule: (LocalDate) -> PageGrid,
|
initialRule: (LocalDate) -> PageGrid,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
/**
|
||||||
|
* The zone every page is laid out in; drags and drops resolve in it too, and
|
||||||
|
* pages lay out again when the device moves zones (#387).
|
||||||
|
*/
|
||||||
|
val zone: StateFlow<TimeZone> = zoneSource.zone
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, zoneSource.current())
|
||||||
|
|
||||||
/** Whether to fade events that have already finished (display concern only). */
|
/** Whether to fade events that have already finished (display concern only). */
|
||||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||||
@@ -88,8 +103,16 @@ abstract class PagedTimelineViewModel(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** How long a block drafted on an empty slot starts out (#153). */
|
||||||
|
val defaultEventDuration: StateFlow<Int> = settingsPrefs.defaultEventDurationMinutes
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = SettingsPrefs.DEFAULT_EVENT_DURATION,
|
||||||
|
)
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = currentDate()
|
||||||
|
|
||||||
// A day on the visible page; the page itself comes from the grid, so a
|
// A day on the visible page; the page itself comes from the grid, so a
|
||||||
// settings change re-frames the same days instead of jumping.
|
// settings change re-frames the same days instead of jumping.
|
||||||
@@ -103,6 +126,17 @@ abstract class PagedTimelineViewModel(
|
|||||||
val rule: StateFlow<(LocalDate) -> PageGrid> =
|
val rule: StateFlow<(LocalDate) -> PageGrid> =
|
||||||
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
|
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
|
||||||
|
|
||||||
|
private var lastToday = todayDate
|
||||||
|
|
||||||
|
/** Today, kept current across midnight so a page left open doesn't mark yesterday (#373). */
|
||||||
|
private val today: StateFlow<LocalDate> = todaySource.today
|
||||||
|
.onEach { date ->
|
||||||
|
val old = lastToday
|
||||||
|
lastToday = date
|
||||||
|
if (old != date) followToday(old, date)
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
|
||||||
|
|
||||||
private val position: StateFlow<PagerPosition> =
|
private val position: StateFlow<PagerPosition> =
|
||||||
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
|
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
@@ -138,11 +172,14 @@ abstract class PagedTimelineViewModel(
|
|||||||
abs(cached.start.daysUntil(requested.start)) <= SPAN_CACHE_DAYS
|
abs(cached.start.daysUntil(requested.start)) <= SPAN_CACHE_DAYS
|
||||||
},
|
},
|
||||||
) { span ->
|
) { span ->
|
||||||
combine(
|
zone.flatMapLatest { zone ->
|
||||||
repository.calendars(),
|
combine(
|
||||||
repository.instances(spanRange(span.start, span.count, zone)),
|
repository.calendars(),
|
||||||
) { calendars, instances ->
|
repository.instances(spanRange(span.start, span.count, zone)),
|
||||||
buildState(span, calendars, instances)
|
today,
|
||||||
|
) { calendars, instances, today ->
|
||||||
|
buildState(span, calendars, instances, today, zone)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
@@ -163,12 +200,21 @@ abstract class PagedTimelineViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() = jumpTo(todayDate)
|
fun goToToday() = jumpTo(todayDate)
|
||||||
|
|
||||||
|
/** The date rolled over; a page still showing [old] follows on to [new], one browsed elsewhere stays. */
|
||||||
|
private fun followToday(old: LocalDate, new: LocalDate) {
|
||||||
|
val anchor = _anchor.value
|
||||||
|
val grid = rule.value(anchor)
|
||||||
|
if (grid.pageFor(anchor) == grid.pageFor(old)) _anchor.value = new
|
||||||
|
}
|
||||||
fun goToDate(date: LocalDate) = jumpTo(date)
|
fun goToDate(date: LocalDate) = jumpTo(date)
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
span: DaySpan,
|
span: DaySpan,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
): WeekUiState {
|
): WeekUiState {
|
||||||
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
|
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
|
||||||
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
|
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
|
||||||
@@ -176,7 +222,7 @@ abstract class PagedTimelineViewModel(
|
|||||||
val timed = instances.filterNot { it.isAllDay }
|
val timed = instances.filterNot { it.isAllDay }
|
||||||
return WeekUiState.Success(
|
return WeekUiState.Success(
|
||||||
weekStart = span.start,
|
weekStart = span.start,
|
||||||
today = todayDate,
|
today = today,
|
||||||
days = days,
|
days = days,
|
||||||
allDaySpans = layoutAllDay(allDay, days, zone),
|
allDaySpans = layoutAllDay(allDay, days, zone),
|
||||||
timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) },
|
timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) },
|
||||||
@@ -189,10 +235,14 @@ abstract class PagedTimelineViewModel(
|
|||||||
class WeekViewModel @Inject constructor(
|
class WeekViewModel @Inject constructor(
|
||||||
repository: CalendarRepository,
|
repository: CalendarRepository,
|
||||||
settingsPrefs: SettingsPrefs,
|
settingsPrefs: SettingsPrefs,
|
||||||
|
todaySource: TodaySource,
|
||||||
|
zoneSource: ZoneSource,
|
||||||
@IoDispatcher io: CoroutineDispatcher,
|
@IoDispatcher io: CoroutineDispatcher,
|
||||||
) : PagedTimelineViewModel(
|
) : PagedTimelineViewModel(
|
||||||
repository = repository,
|
repository = repository,
|
||||||
settingsPrefs = settingsPrefs,
|
settingsPrefs = settingsPrefs,
|
||||||
|
todaySource = todaySource,
|
||||||
|
zoneSource = zoneSource,
|
||||||
io = io,
|
io = io,
|
||||||
gridRule = settingsPrefs.weekStart.map { pref ->
|
gridRule = settingsPrefs.weekStart.map { pref ->
|
||||||
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
|
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
|
||||||
@@ -211,10 +261,14 @@ class WeekViewModel @Inject constructor(
|
|||||||
class MultiDayViewModel @Inject constructor(
|
class MultiDayViewModel @Inject constructor(
|
||||||
repository: CalendarRepository,
|
repository: CalendarRepository,
|
||||||
settingsPrefs: SettingsPrefs,
|
settingsPrefs: SettingsPrefs,
|
||||||
|
todaySource: TodaySource,
|
||||||
|
zoneSource: ZoneSource,
|
||||||
@IoDispatcher io: CoroutineDispatcher,
|
@IoDispatcher io: CoroutineDispatcher,
|
||||||
) : PagedTimelineViewModel(
|
) : PagedTimelineViewModel(
|
||||||
repository = repository,
|
repository = repository,
|
||||||
settingsPrefs = settingsPrefs,
|
settingsPrefs = settingsPrefs,
|
||||||
|
todaySource = todaySource,
|
||||||
|
zoneSource = zoneSource,
|
||||||
io = io,
|
io = io,
|
||||||
gridRule = settingsPrefs.multiDayCount.map { count ->
|
gridRule = settingsPrefs.multiDayCount.map { count ->
|
||||||
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
|
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
|
||||||
@@ -227,10 +281,14 @@ class MultiDayViewModel @Inject constructor(
|
|||||||
class DayViewModel @Inject constructor(
|
class DayViewModel @Inject constructor(
|
||||||
repository: CalendarRepository,
|
repository: CalendarRepository,
|
||||||
settingsPrefs: SettingsPrefs,
|
settingsPrefs: SettingsPrefs,
|
||||||
|
todaySource: TodaySource,
|
||||||
|
zoneSource: ZoneSource,
|
||||||
@IoDispatcher io: CoroutineDispatcher,
|
@IoDispatcher io: CoroutineDispatcher,
|
||||||
) : PagedTimelineViewModel(
|
) : PagedTimelineViewModel(
|
||||||
repository = repository,
|
repository = repository,
|
||||||
settingsPrefs = settingsPrefs,
|
settingsPrefs = settingsPrefs,
|
||||||
|
todaySource = todaySource,
|
||||||
|
zoneSource = zoneSource,
|
||||||
io = io,
|
io = io,
|
||||||
gridRule = flowOf { _: LocalDate -> DayGrid },
|
gridRule = flowOf { _: LocalDate -> DayGrid },
|
||||||
initialRule = { DayGrid },
|
initialRule = { DayGrid },
|
||||||
@@ -302,6 +360,64 @@ internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRang
|
|||||||
return from..to
|
return from..to
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This instant's wall-clock time as minutes past [day]'s midnight in [zone] —
|
||||||
|
* negative before that day, past 1440 after it. Elapsed time since midnight would
|
||||||
|
* put everything after a DST change an hour off on that day's grid (#387).
|
||||||
|
*/
|
||||||
|
internal fun Instant.minutesPast(day: LocalDate, zone: TimeZone): Int {
|
||||||
|
val local = toLocalDateTime(zone)
|
||||||
|
val days = (local.date.toEpochDays() - day.toEpochDays()).toInt()
|
||||||
|
return days * MINUTES_PER_DAY + local.time.toSecondOfDay() / 60
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [instant]'s [minutesPast] clamped to [day]. */
|
||||||
|
internal fun wallClockMinutes(instant: Instant, day: LocalDate, zone: TimeZone): Int =
|
||||||
|
instant.minutesPast(day, zone).coerceIn(0, MINUTES_PER_DAY)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a run from [start] to [end] that begins at [startMin] ends on [day]'s
|
||||||
|
* wall clock. A run that began before the fall-back hour's repeat and ends
|
||||||
|
* inside it is drawn to where that hour ends (03:00 in Berlin), since its own
|
||||||
|
* wall-clock end reads earlier than its real length (#387).
|
||||||
|
*/
|
||||||
|
internal fun wallClockEndMinutes(
|
||||||
|
startMin: Int,
|
||||||
|
start: Instant,
|
||||||
|
end: Instant,
|
||||||
|
day: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
): Int {
|
||||||
|
val wall = end.minutesPast(day, zone)
|
||||||
|
if (end <= start) return maxOf(wall, startMin)
|
||||||
|
val drawn = repeatedHourEndMinutes(start, end, day, zone) ?: wall
|
||||||
|
return if (drawn > startMin) drawn else startMin + (end - start).inWholeMinutes.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the fall-back hour's repeat [end] falls in ends, as minutes past [day]'s
|
||||||
|
* midnight: the wall clock the zone fell back from (03:00 in Berlin). Null unless
|
||||||
|
* [end] is in that repeat and [start] before it.
|
||||||
|
*/
|
||||||
|
private fun repeatedHourEndMinutes(start: Instant, end: Instant, day: LocalDate, zone: TimeZone): Int? {
|
||||||
|
// Just past [end], so an end on the transition itself still finds it.
|
||||||
|
val after = java.time.Instant.ofEpochMilli(end.toEpochMilliseconds() + 1)
|
||||||
|
val transition = zone.toJavaZoneId().rules.previousTransition(after)
|
||||||
|
?.takeIf { it.isOverlap } ?: return null
|
||||||
|
val fellBack = transition.instant.toKotlinInstant()
|
||||||
|
if (start >= fellBack || end >= fellBack + transition.duration.abs().toKotlinDuration()) return null
|
||||||
|
// The moment it fell back, read on the clock it fell back from.
|
||||||
|
return fellBack.minutesPast(day, FixedOffsetTimeZone(transition.offsetBefore.toKotlinUtcOffset()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The end a block's time label names on [day]: its event's own wall-clock end,
|
||||||
|
* which for one ending in the fall-back hour's repeat is earlier than the
|
||||||
|
* [TimedBlock.endMin] it is drawn to (#387).
|
||||||
|
*/
|
||||||
|
internal fun TimedBlock.labelEndMin(day: LocalDate, zone: TimeZone): Int =
|
||||||
|
minOf(endMin, wallClockMinutes(event.end, day, zone))
|
||||||
|
|
||||||
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
|
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
|
||||||
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
@@ -355,14 +471,14 @@ internal fun layoutDay(
|
|||||||
if (ev.start == ev.end) {
|
if (ev.start == ev.end) {
|
||||||
// Instant event: keep only if the point falls inside this day.
|
// Instant event: keep only if the point falls inside this day.
|
||||||
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
|
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
|
||||||
val m = (ev.start - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
|
val m = wallClockMinutes(ev.start, day, zone)
|
||||||
Raw(ev, m, m)
|
Raw(ev, m, m)
|
||||||
} else {
|
} else {
|
||||||
val s = maxOf(ev.start, dayStart)
|
val s = maxOf(ev.start, dayStart)
|
||||||
val e = minOf(ev.end, dayEnd)
|
val e = minOf(ev.end, dayEnd)
|
||||||
if (e <= s) return@mapNotNull null
|
if (e <= s) return@mapNotNull null
|
||||||
val startMin = (s - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
|
val startMin = wallClockMinutes(s, day, zone)
|
||||||
val endMin = (e - dayStart).inWholeMinutes.toInt().coerceIn(startMin, MINUTES_PER_DAY)
|
val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY)
|
||||||
Raw(ev, startMin, endMin)
|
Raw(ev, startMin, endMin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ internal fun WeekViewPreview(
|
|||||||
onOpenDay = {},
|
onOpenDay = {},
|
||||||
onCreateAt = { _, _ -> },
|
onCreateAt = { _, _ -> },
|
||||||
onDrop = {},
|
onDrop = {},
|
||||||
|
onResize = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
@@ -28,11 +29,12 @@ import kotlin.time.Clock
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far either side of today the month widget pre-loads. The displayed month
|
* Months either side of the displayed one the month widget loads, so a few arrow
|
||||||
* is chosen reactively in the composition, so one wide read covers ~13 months of
|
* taps page without a re-query. Kept small: every load expands recurrences across
|
||||||
* prev/next navigation without re-querying on every arrow tap.
|
* the whole window, and on slow devices a wide one held the widget on "loading"
|
||||||
|
* (#363).
|
||||||
*/
|
*/
|
||||||
private const val MONTH_WIDGET_RANGE_DAYS = 400
|
private const val MONTH_WIDGET_PAD_MONTHS = 3
|
||||||
|
|
||||||
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
@@ -76,6 +78,8 @@ sealed interface AgendaWidgetData {
|
|||||||
val savedShowToday: Boolean,
|
val savedShowToday: Boolean,
|
||||||
/** Saved widget size step (#103) — the fallback before Glance state is set. */
|
/** Saved widget size step (#103) — the fallback before Glance state is set. */
|
||||||
val savedWidgetSize: WidgetSize,
|
val savedWidgetSize: WidgetSize,
|
||||||
|
/** Last day [days] covers; a wider range picked later needs a reload. */
|
||||||
|
val loadedThrough: LocalDate,
|
||||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||||
val now: Instant,
|
val now: Instant,
|
||||||
) : AgendaWidgetData
|
) : AgendaWidgetData
|
||||||
@@ -94,14 +98,15 @@ sealed interface MonthWidgetSource {
|
|||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
val instances: List<EventInstance>,
|
val instances: List<EventInstance>,
|
||||||
|
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
|
||||||
|
val months: IntRange,
|
||||||
) : MonthWidgetSource
|
) : MonthWidgetSource
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process-lived cache of the wide month window. Month navigation re-runs
|
* Process-lived cache of the month window. Month navigation can re-run
|
||||||
* `provideGlance` (via `updateAll`), and re-querying ~13 months of instances on
|
* `provideGlance`, and re-querying on every arrow tap is what made paging feel
|
||||||
* every arrow tap is what made paging feel sluggish — so we load once and reuse
|
* sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by
|
||||||
* the same snapshot for every nearby month. Invalidated by
|
|
||||||
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
||||||
* receiver), and automatically when the day rolls over (the `today` guard).
|
* receiver), and automatically when the day rolls over (the `today` guard).
|
||||||
*/
|
*/
|
||||||
@@ -118,8 +123,11 @@ internal fun invalidateMonthWidgetCache() {
|
|||||||
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
||||||
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
||||||
* [first]-emitted snapshot already has hidden calendars filtered out.
|
* [first]-emitted snapshot already has hidden calendars filtered out.
|
||||||
|
*
|
||||||
|
* Loads [range] (the widget's own, or the saved pref when null) rather than the
|
||||||
|
* widest selectable window (#363).
|
||||||
*/
|
*/
|
||||||
internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
internal suspend fun Context.loadAgendaWidgetData(range: AgendaRange? = null): AgendaWidgetData {
|
||||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
@@ -129,11 +137,11 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||||
val showToday = prefs.agendaShowToday.first()
|
val showToday = prefs.agendaShowToday.first()
|
||||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
// Load the widest selectable window once; the displayed range is sliced in
|
// The displayed range is still sliced in the composition from Glance state;
|
||||||
// the composition from Glance state, so changing the range is a plain
|
// a wider one picked later reloads there (see loadedThrough).
|
||||||
// recomposition and never depends on the widget session restarting.
|
val dayCount = (range ?: savedRange).dayCount(anchor, weekStart)
|
||||||
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
val window = agendaRange(anchor, dayCount - 1, zone)
|
||||||
val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY)
|
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||||
val instances = ep.calendarRepository().instances(window).first()
|
val instances = ep.calendarRepository().instances(window).first()
|
||||||
val is24Hour = prefs.timeFormat.first()
|
val is24Hour = prefs.timeFormat.first()
|
||||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||||
@@ -148,22 +156,39 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
savedPastDisplay = savedPastDisplay,
|
savedPastDisplay = savedPastDisplay,
|
||||||
savedShowToday = showToday,
|
savedShowToday = showToday,
|
||||||
savedWidgetSize = prefs.widgetSize.first(),
|
savedWidgetSize = prefs.widgetSize.first(),
|
||||||
|
loadedThrough = windowEnd,
|
||||||
now = Clock.System.now(),
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal
|
||||||
internal suspend fun Context.loadMonthWidgetSource(): MonthWidgetSource {
|
|
||||||
|
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
|
||||||
|
|
||||||
|
/** Read backing the month widget's grid for [centerIndex] and the months around it. */
|
||||||
|
internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): MonthWidgetSource {
|
||||||
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
|
val center = centerIndex ?: monthIndexOf(anchor)
|
||||||
// Reuse the cached window unless the day changed (then it's stale for "today").
|
// Reuse the cached window unless the day changed (then it's stale for "today").
|
||||||
MonthWidgetCache.data?.let { if (it.today == anchor) return it }
|
MonthWidgetCache.data?.let { if (it.today == anchor && center in it.months) return it }
|
||||||
val ep = widgetEntryPoint()
|
val ep = widgetEntryPoint()
|
||||||
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
val from = anchor.minus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS)
|
||||||
val to = anchor.plus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
// Padded by the grid's spill into the neighbouring months.
|
||||||
|
val from = firstDayOfMonthIndex(months.first)
|
||||||
|
.minus(1, DateTimeUnit.WEEK)
|
||||||
|
.atStartOfDayIn(zone)
|
||||||
|
val to = firstDayOfMonthIndex(months.last + 1)
|
||||||
|
.plus(2, DateTimeUnit.WEEK)
|
||||||
|
.atTime(23, 59, 59)
|
||||||
|
.toInstant(zone)
|
||||||
val instances = ep.calendarRepository().instances(from..to).first()
|
val instances = ep.calendarRepository().instances(from..to).first()
|
||||||
return MonthWidgetSource.Ready(today = anchor, weekStart = weekStart, instances = instances)
|
return MonthWidgetSource.Ready(
|
||||||
.also { MonthWidgetCache.data = it }
|
today = anchor,
|
||||||
|
weekStart = weekStart,
|
||||||
|
instances = instances,
|
||||||
|
months = months,
|
||||||
|
).also { MonthWidgetCache.data = it }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
@@ -28,6 +34,7 @@ import androidx.glance.appwidget.lazy.items
|
|||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
import androidx.glance.layout.Box
|
import androidx.glance.layout.Box
|
||||||
@@ -125,7 +132,8 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
override val sizeMode = SizeMode.Single
|
override val sizeMode = SizeMode.Single
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val data = context.loadAgendaWidgetData()
|
val stored = getAppWidgetState<Preferences>(context, id)[AGENDA_RANGE_KEY]
|
||||||
|
val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) })
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
provideContent {
|
provideContent {
|
||||||
@@ -190,71 +198,95 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||||
is AgendaWidgetData.Ready -> {
|
is AgendaWidgetData.Ready -> {
|
||||||
// Range read reactively from per-instance Glance state (falls back
|
// Range read reactively from per-instance Glance state (falls back
|
||||||
// to the saved pref for a freshly placed widget), then the wide
|
// to the saved pref for a freshly placed widget), then the loaded
|
||||||
// pre-loaded window is sliced to it — pure recomposition.
|
// window is sliced to it — pure recomposition.
|
||||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||||
val pastDisplay =
|
val context = androidx.glance.LocalContext.current
|
||||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
var loaded by remember(data) { mutableStateOf<AgendaWidgetData.Ready>(data) }
|
||||||
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
// A range wider than what was loaded reloads in a live session,
|
||||||
val rangeEnd = data.today.plus(
|
// where provideGlance doesn't run again (#363).
|
||||||
range.dayCount(data.today, data.weekStart) - 1,
|
LaunchedEffect(range) {
|
||||||
DateTimeUnit.DAY,
|
val end = loaded.today.plus(
|
||||||
)
|
range.dayCount(loaded.today, loaded.weekStart) - 1,
|
||||||
// Slice to the chosen range, then drop finished events (and any day
|
DateTimeUnit.DAY,
|
||||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
)
|
||||||
// Finally re-anchor today (as an empty placeholder day) when the
|
if (end > loaded.loadedThrough) {
|
||||||
// "always show today" pref is on and today has no events left (#35).
|
(context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready)
|
||||||
val visibleDays = anchorTodayIfMissing(
|
?.let { loaded = it }
|
||||||
days = data.days
|
}
|
||||||
.filter { it.date <= rangeEnd }
|
}
|
||||||
.let { days ->
|
AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics)
|
||||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
}
|
||||||
days.mapNotNull { day ->
|
}
|
||||||
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
}
|
||||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
}
|
||||||
}
|
|
||||||
} else {
|
@Composable
|
||||||
days
|
private fun AgendaRows(
|
||||||
}
|
data: AgendaWidgetData.Ready,
|
||||||
},
|
range: AgendaRange,
|
||||||
today = data.today,
|
dark: Boolean,
|
||||||
enabled = showToday,
|
metrics: AgendaMetrics,
|
||||||
)
|
) {
|
||||||
if (visibleDays.isEmpty()) {
|
val pastDisplay =
|
||||||
WidgetMessage(R.string.agenda_empty_title, metrics)
|
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||||
|
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
||||||
|
val rangeEnd = data.today.plus(
|
||||||
|
range.dayCount(data.today, data.weekStart) - 1,
|
||||||
|
DateTimeUnit.DAY,
|
||||||
|
)
|
||||||
|
// Slice to the chosen range, then drop finished events (and any day
|
||||||
|
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||||
|
// Finally re-anchor today (as an empty placeholder day) when the
|
||||||
|
// "always show today" pref is on and today has no events left (#35).
|
||||||
|
val visibleDays = anchorTodayIfMissing(
|
||||||
|
days = data.days
|
||||||
|
.filter { it.date <= rangeEnd }
|
||||||
|
.let { days ->
|
||||||
|
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
|
days.mapNotNull { day ->
|
||||||
|
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
||||||
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
days
|
||||||
visibleDays.forEach { day ->
|
}
|
||||||
add(AgendaRow.Header(day.date, data.today))
|
},
|
||||||
if (day.events.isEmpty()) {
|
today = data.today,
|
||||||
add(AgendaRow.Placeholder(day.date))
|
enabled = showToday,
|
||||||
} else {
|
)
|
||||||
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
if (visibleDays.isEmpty()) {
|
||||||
}
|
WidgetMessage(R.string.agenda_empty_title, metrics)
|
||||||
}
|
} else {
|
||||||
}
|
val rows = buildList {
|
||||||
// Bound the payload, then drop a day header the cut left
|
visibleDays.forEach { day ->
|
||||||
// stranded with nothing under it.
|
add(AgendaRow.Header(day.date, data.today))
|
||||||
.take(MAX_AGENDA_ROWS)
|
if (day.events.isEmpty()) {
|
||||||
.dropLastWhile { it is AgendaRow.Header }
|
add(AgendaRow.Placeholder(day.date))
|
||||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
} else {
|
||||||
items(rows.size) { index ->
|
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
||||||
when (val row = rows[index]) {
|
}
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
}
|
||||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
}
|
||||||
is AgendaRow.Event -> EventRow(
|
// Bound the payload, then drop a day header the cut left
|
||||||
event = row.event,
|
// stranded with nothing under it.
|
||||||
day = row.date,
|
.take(MAX_AGENDA_ROWS)
|
||||||
dark = dark,
|
.dropLastWhile { it is AgendaRow.Header }
|
||||||
soften = data.soften,
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
is24Hour = data.is24Hour,
|
items(rows.size) { index ->
|
||||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
when (val row = rows[index]) {
|
||||||
row.event.hasEnded(data.now),
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
||||||
metrics = metrics,
|
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||||
)
|
is AgendaRow.Event -> EventRow(
|
||||||
}
|
event = row.event,
|
||||||
}
|
day = row.date,
|
||||||
}
|
dark = dark,
|
||||||
|
soften = data.soften,
|
||||||
|
is24Hour = data.is24Hour,
|
||||||
|
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||||
|
row.event.hasEnded(data.now),
|
||||||
|
metrics = metrics,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
@@ -26,6 +32,7 @@ import androidx.glance.appwidget.action.actionRunCallback
|
|||||||
import androidx.glance.appwidget.action.actionStartActivity
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
import androidx.glance.appwidget.cornerRadius
|
import androidx.glance.appwidget.cornerRadius
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
@@ -59,6 +66,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventInk
|
|||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||||
@@ -81,10 +89,7 @@ private val LANE_HEIGHT = 14.dp
|
|||||||
private val DAY_NUMBER_HEIGHT = 18.dp
|
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||||
private val GRID_HPADDING = 8.dp
|
private val GRID_HPADDING = 8.dp
|
||||||
|
|
||||||
private fun currentMonthIndex(zone: TimeZone): Int {
|
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
|
||||||
val t = today(zone)
|
|
||||||
return t.year * 12 + t.month.ordinal
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun yearMonthOf(index: Int): YearMonth =
|
private fun yearMonthOf(index: Int): YearMonth =
|
||||||
YearMonth(index / 12, Month(index % 12 + 1))
|
YearMonth(index / 12, Month(index % 12 + 1))
|
||||||
@@ -123,7 +128,8 @@ class MonthWidget : GlanceAppWidget() {
|
|||||||
override val sizeMode = SizeMode.Exact
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val source = context.loadMonthWidgetSource()
|
val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY]
|
||||||
|
val source = context.loadMonthWidgetSource(shown)
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
// Read fresh (not through the cached source) so toggling the softener
|
// Read fresh (not through the cached source) so toggling the softener
|
||||||
@@ -198,18 +204,28 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
|
|||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
||||||
val ym = yearMonthOf(index)
|
val ym = yearMonthOf(index)
|
||||||
|
// Paging past the loaded months reloads around the new one in a
|
||||||
|
// live session, where provideGlance doesn't run again.
|
||||||
|
val context = LocalContext.current
|
||||||
|
var loaded by remember(source) { mutableStateOf<MonthWidgetSource.Ready>(source) }
|
||||||
|
LaunchedEffect(index) {
|
||||||
|
if (index !in loaded.months) {
|
||||||
|
(context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready)
|
||||||
|
?.let { loaded = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
// Column width from the live widget size, minus our H padding.
|
// Column width from the live widget size, minus our H padding.
|
||||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone)
|
||||||
|
|
||||||
MonthHeader(label = monthLabel(ym, source.today.year))
|
MonthHeader(label = monthLabel(ym, loaded.today.year))
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
WeekdayHeader(weekStart = loaded.weekStart, colW = colW)
|
||||||
weeks.forEach { week ->
|
weeks.forEach { week ->
|
||||||
WeekRow(
|
WeekRow(
|
||||||
week = week,
|
week = week,
|
||||||
currentMonth = ym.month,
|
currentMonth = ym.month,
|
||||||
today = source.today,
|
today = loaded.today,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
soften = soften,
|
soften = soften,
|
||||||
colW = colW,
|
colW = colW,
|
||||||
|
|||||||
@@ -97,6 +97,14 @@
|
|||||||
<string name="event_move_occurrence_only">Moving the whole series would change which days it falls on, so only this event can move.</string>
|
<string name="event_move_occurrence_only">Moving the whole series would change which days it falls on, so only this event can move.</string>
|
||||||
<!-- %1$s is the new date and time, e.g. "Fri, 7 Aug, 09:00". -->
|
<!-- %1$s is the new date and time, e.g. "Fri, 7 Aug, 09:00". -->
|
||||||
<string name="event_move_done">Moved to %1$s</string>
|
<string name="event_move_done">Moved to %1$s</string>
|
||||||
|
<string name="event_resize_done">Changed to %1$s</string>
|
||||||
|
<string name="event_resize_recurring_title">Change recurring event</string>
|
||||||
|
<string name="event_selection_actions">Event actions</string>
|
||||||
|
<string name="event_selection_hide">Hide event actions</string>
|
||||||
|
<string name="event_selection_close">Deselect event</string>
|
||||||
|
<string name="event_draft_create">Create event</string>
|
||||||
|
<string name="event_draft_discard">Discard new event</string>
|
||||||
|
<string name="event_action_load_failed">Couldn\'t open the event</string>
|
||||||
<string name="event_move_undo">Undo</string>
|
<string name="event_move_undo">Undo</string>
|
||||||
<string name="event_move_undone">Move undone</string>
|
<string name="event_move_undone">Move undone</string>
|
||||||
<string name="event_move_failed">Couldn\'t move the event</string>
|
<string name="event_move_failed">Couldn\'t move the event</string>
|
||||||
@@ -110,6 +118,11 @@
|
|||||||
<!-- Event form (v1.2 create) -->
|
<!-- Event form (v1.2 create) -->
|
||||||
<string name="event_edit_new_title">New event</string>
|
<string name="event_edit_new_title">New event</string>
|
||||||
<string name="event_edit_close">Close</string>
|
<string name="event_edit_close">Close</string>
|
||||||
|
<string name="event_edit_more_actions">More actions</string>
|
||||||
|
<string name="event_edit_unsaved_title">Unsaved changes</string>
|
||||||
|
<string name="event_edit_unsaved_save">Save changes</string>
|
||||||
|
<string name="event_edit_unsaved_discard">Discard changes</string>
|
||||||
|
<string name="event_edit_unsaved_keep">Keep editing</string>
|
||||||
<string name="event_edit_save">Save</string>
|
<string name="event_edit_save">Save</string>
|
||||||
<string name="event_edit_title_hint">Add title</string>
|
<string name="event_edit_title_hint">Add title</string>
|
||||||
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
|
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
|
||||||
@@ -211,6 +224,8 @@
|
|||||||
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
||||||
<string name="event_detail_title">Title</string>
|
<string name="event_detail_title">Title</string>
|
||||||
<string name="event_detail_location">Location</string>
|
<string name="event_detail_location">Location</string>
|
||||||
|
<string name="event_detail_open_in_maps">Open in maps</string>
|
||||||
|
<string name="link_open_failed">No app can open this link</string>
|
||||||
<string name="event_detail_description">Description</string>
|
<string name="event_detail_description">Description</string>
|
||||||
<string name="event_detail_attendees">Attendees</string>
|
<string name="event_detail_attendees">Attendees</string>
|
||||||
<string name="event_detail_recurrence">Recurrence</string>
|
<string name="event_detail_recurrence">Recurrence</string>
|
||||||
@@ -508,6 +523,11 @@
|
|||||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||||
<string name="settings_drag_to_reschedule">Drag to reschedule</string>
|
<string name="settings_drag_to_reschedule">Drag to reschedule</string>
|
||||||
<string name="settings_drag_to_reschedule_summary">Move an event by dragging it to another day or time</string>
|
<string name="settings_drag_to_reschedule_summary">Move an event by dragging it to another day or time</string>
|
||||||
|
<string name="settings_events_header">Events</string>
|
||||||
|
<string name="settings_tap_opens">Tapping an event opens</string>
|
||||||
|
<string name="settings_tap_opens_details">Details</string>
|
||||||
|
<string name="settings_tap_opens_editor">Edit form</string>
|
||||||
|
<string name="settings_tap_opens_hint">Events you can\'t edit and reminder notifications always open the details.</string>
|
||||||
<string name="settings_past_events">Past events</string>
|
<string name="settings_past_events">Past events</string>
|
||||||
<string name="settings_past_events_show">Show</string>
|
<string name="settings_past_events_show">Show</string>
|
||||||
<string name="settings_past_events_dim">Dim</string>
|
<string name="settings_past_events_dim">Dim</string>
|
||||||
@@ -824,6 +844,9 @@
|
|||||||
<string name="shortcut_new_event_short">New event</string>
|
<string name="shortcut_new_event_short">New event</string>
|
||||||
<string name="shortcut_new_event_long">Create a new event</string>
|
<string name="shortcut_new_event_long">Create a new event</string>
|
||||||
|
|
||||||
|
<!-- Text-selection menu entry -->
|
||||||
|
<string name="process_text_create_event">Create event</string>
|
||||||
|
|
||||||
<!-- Quick Settings tile -->
|
<!-- Quick Settings tile -->
|
||||||
<string name="qs_tile_new_event_label">New event</string>
|
<string name="qs_tile_new_event_label">New event</string>
|
||||||
<string name="settings_qs_tile">Add Quick Settings tile</string>
|
<string name="settings_qs_tile">Add Quick Settings tile</string>
|
||||||
|
|||||||
+58
-18
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|||||||
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.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -95,6 +96,45 @@ class CalendarRepositoryImplTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances re-emit after a write the provider never notified about`(@TempDir tempDir: Path) = runTest {
|
||||||
|
var current = listOf(makeEvent(10L))
|
||||||
|
val fake = FakeCalendarDataSource().apply { instancesResult = { _, _ -> current } }
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
assertThat(awaitItem().map { it.eventId }).containsExactly(10L)
|
||||||
|
|
||||||
|
current = emptyList()
|
||||||
|
repo.deleteEvent(10L)
|
||||||
|
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances re-emit after a write that failed`(@TempDir tempDir: Path) = runTest {
|
||||||
|
var current = listOf(makeEvent(10L))
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
instancesResult = { _, _ -> current }
|
||||||
|
writeError = WriteFailedException("boom")
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
awaitItem()
|
||||||
|
|
||||||
|
current = emptyList()
|
||||||
|
assertThrows<WriteFailedException> { repo.deleteOccurrence(10L, 1_000L) }
|
||||||
|
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest {
|
fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest {
|
||||||
var observedBegin: Long? = null
|
var observedBegin: Long? = null
|
||||||
@@ -471,8 +511,8 @@ class CalendarRepositoryImplTest {
|
|||||||
val form = EventForm(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Stand-up",
|
title = "Stand-up",
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
|
||||||
)
|
)
|
||||||
|
|
||||||
val id = repo.createEvent(form)
|
val id = repo.createEvent(form)
|
||||||
@@ -492,8 +532,8 @@ class CalendarRepositoryImplTest {
|
|||||||
val form = EventForm(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
repo.createEvent(form)
|
repo.createEvent(form)
|
||||||
@@ -509,8 +549,8 @@ class CalendarRepositoryImplTest {
|
|||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
val form = EventForm(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -528,8 +568,8 @@ class CalendarRepositoryImplTest {
|
|||||||
val original = EventForm(
|
val original = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Stand-up",
|
title = "Stand-up",
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
|
||||||
)
|
)
|
||||||
val updated = original.copy(title = "Daily")
|
val updated = original.copy(title = "Daily")
|
||||||
|
|
||||||
@@ -545,8 +585,8 @@ class CalendarRepositoryImplTest {
|
|||||||
val original = EventForm(
|
val original = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Stand-up",
|
title = "Stand-up",
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
|
||||||
)
|
)
|
||||||
val updated = original.copy(calendarId = 3L)
|
val updated = original.copy(calendarId = 3L)
|
||||||
|
|
||||||
@@ -566,8 +606,8 @@ class CalendarRepositoryImplTest {
|
|||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
val form = EventForm(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -586,8 +626,8 @@ class CalendarRepositoryImplTest {
|
|||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
val form = EventForm(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -639,8 +679,8 @@ class CalendarRepositoryImplTest {
|
|||||||
val form = EventForm(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Moved",
|
title = "Moved",
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
val id = repo.updateOccurrence(
|
val id = repo.updateOccurrence(
|
||||||
@@ -662,8 +702,8 @@ class CalendarRepositoryImplTest {
|
|||||||
val original = EventForm(
|
val original = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Weekly",
|
title = "Weekly",
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
|
||||||
rrule = "FREQ=WEEKLY",
|
rrule = "FREQ=WEEKLY",
|
||||||
)
|
)
|
||||||
val updated = original.copy(title = "Weekly, renamed")
|
val updated = original.copy(title = "Weekly, renamed")
|
||||||
|
|||||||
+100
-34
@@ -5,12 +5,15 @@ import com.google.common.truth.Truth.assertThat
|
|||||||
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.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||||
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.UtcOffset
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class EventWriteMapperTest {
|
class EventWriteMapperTest {
|
||||||
|
|
||||||
@@ -24,8 +27,8 @@ class EventWriteMapperTest {
|
|||||||
): EventForm = EventForm(
|
): EventForm = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
isAllDay = isAllDay,
|
isAllDay = isAllDay,
|
||||||
start = start,
|
start = FormTime(start),
|
||||||
end = end,
|
end = FormTime(end),
|
||||||
timezone = timezone,
|
timezone = timezone,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -154,8 +157,8 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
|
||||||
|
|
||||||
val moved = original.copy(
|
val moved = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30))),
|
||||||
)
|
)
|
||||||
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
|
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
|
||||||
.isEqualTo("America/New_York")
|
.isEqualTo("America/New_York")
|
||||||
@@ -201,8 +204,8 @@ class EventWriteMapperTest {
|
|||||||
fun `time edit on a one-off event writes absolute times and clears recurrence columns`() {
|
fun `time edit on a one-off event writes absolute times and clears recurrence columns`() {
|
||||||
val original = form()
|
val original = form()
|
||||||
val updated = original.copy(
|
val updated = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0))),
|
||||||
)
|
)
|
||||||
val values = update(original, updated)
|
val values = update(original, updated)
|
||||||
// 2026-06-11 11:00 CEST == 09:00Z.
|
// 2026-06-11 11:00 CEST == 09:00Z.
|
||||||
@@ -221,8 +224,8 @@ class EventWriteMapperTest {
|
|||||||
val original = form().copy(rrule = "FREQ=WEEKLY")
|
val original = form().copy(rrule = "FREQ=WEEKLY")
|
||||||
val updated = original.copy(
|
val updated = original.copy(
|
||||||
// Pushed one hour later than the displayed occurrence.
|
// Pushed one hour later than the displayed occurrence.
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30))),
|
||||||
)
|
)
|
||||||
val values = update(original, updated)
|
val values = update(original, updated)
|
||||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart + 3_600_000L)
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart + 3_600_000L)
|
||||||
@@ -263,8 +266,8 @@ class EventWriteMapperTest {
|
|||||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||||
).copy(rrule = "FREQ=WEEKLY")
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
val moved = original.copy(
|
val moved = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
||||||
@@ -279,8 +282,8 @@ class EventWriteMapperTest {
|
|||||||
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)),
|
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)),
|
||||||
).copy(rrule = "FREQ=WEEKLY")
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
val moved = original.copy(
|
val moved = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30))),
|
||||||
end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30))),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
||||||
@@ -297,8 +300,8 @@ class EventWriteMapperTest {
|
|||||||
end = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(10, 0)),
|
end = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(10, 0)),
|
||||||
).copy(rrule = "FREQ=WEEKLY;BYDAY=MO")
|
).copy(rrule = "FREQ=WEEKLY;BYDAY=MO")
|
||||||
val moved = original.copy(
|
val moved = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0))),
|
||||||
rrule = realignRecurrence("FREQ=WEEKLY;BYDAY=MO", LocalDate(2026, 6, 8), LocalDate(2026, 6, 10)),
|
rrule = realignRecurrence("FREQ=WEEKLY;BYDAY=MO", LocalDate(2026, 6, 8), LocalDate(2026, 6, 10)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -319,8 +322,8 @@ class EventWriteMapperTest {
|
|||||||
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 30)),
|
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 30)),
|
||||||
).copy(rrule = "FREQ=WEEKLY")
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
val moved = original.copy(
|
val moved = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30))),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(update(original, moved, series)[CalendarContract.Events.DURATION])
|
assertThat(update(original, moved, series)[CalendarContract.Events.DURATION])
|
||||||
@@ -331,8 +334,8 @@ class EventWriteMapperTest {
|
|||||||
fun `shifting a one-off event and back again restores the original values`() {
|
fun `shifting a one-off event and back again restores the original values`() {
|
||||||
val original = form()
|
val original = form()
|
||||||
val moved = original.copy(
|
val moved = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
val forward = update(original, moved)
|
val forward = update(original, moved)
|
||||||
@@ -357,8 +360,8 @@ class EventWriteMapperTest {
|
|||||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||||
).copy(rrule = "FREQ=WEEKLY")
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
val moved = original.copy(
|
val moved = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30))),
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30))),
|
||||||
)
|
)
|
||||||
|
|
||||||
val movedAnchor = update(original, moved, series)[CalendarContract.Events.DTSTART] as Long
|
val movedAnchor = update(original, moved, series)[CalendarContract.Events.DTSTART] as Long
|
||||||
@@ -678,8 +681,8 @@ class EventWriteMapperTest {
|
|||||||
|
|
||||||
/** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */
|
/** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */
|
||||||
private fun EventForm.atHour(hour: Int): EventForm = copy(
|
private fun EventForm.atHour(hour: Int): EventForm = copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -728,8 +731,8 @@ class EventWriteMapperTest {
|
|||||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
).copy(rrule = "FREQ=WEEKLY")
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
val moved = original.copy(
|
val moved = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
|
assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
|
||||||
@@ -748,8 +751,8 @@ class EventWriteMapperTest {
|
|||||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
).copy(rrule = "FREQ=WEEKLY")
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
val moved = original.copy(
|
val moved = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
val values = update(original, moved, series, "20260722T000000Z")
|
val values = update(original, moved, series, "20260722T000000Z")
|
||||||
@@ -768,8 +771,8 @@ class EventWriteMapperTest {
|
|||||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
).copy(rrule = "FREQ=WEEKLY")
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
val moved = original.copy(
|
val moved = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
val values = update(original, moved, series, "20260722T000000")
|
val values = update(original, moved, series, "20260722T000000")
|
||||||
@@ -787,8 +790,8 @@ class EventWriteMapperTest {
|
|||||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
).copy(rrule = "FREQ=WEEKLY")
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
val moved = original.copy(
|
val moved = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
val values = update(original, moved, series, "20260722T090000Z")
|
val values = update(original, moved, series, "20260722T090000Z")
|
||||||
@@ -815,8 +818,8 @@ class EventWriteMapperTest {
|
|||||||
).copy(rrule = "FREQ=WEEKLY")
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
val timed = original.copy(
|
val timed = original.copy(
|
||||||
isAllDay = false,
|
isAllDay = false,
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0))),
|
||||||
)
|
)
|
||||||
// The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z.
|
// The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z.
|
||||||
assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE])
|
assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE])
|
||||||
@@ -898,8 +901,8 @@ class EventWriteMapperTest {
|
|||||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||||
val original = julySeries()
|
val original = julySeries()
|
||||||
val moved = original.copy(
|
val moved = original.copy(
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30))),
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30))),
|
||||||
)
|
)
|
||||||
val exdate = "20260722T070000Z"
|
val exdate = "20260722T070000Z"
|
||||||
|
|
||||||
@@ -1387,4 +1390,67 @@ class EventWriteMapperTest {
|
|||||||
assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
|
assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
|
||||||
assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
|
assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a time in the repeated fall-back hour is written at its own offset`() {
|
||||||
|
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 0))
|
||||||
|
val times = form(start = repeated, end = repeated)
|
||||||
|
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 1)))
|
||||||
|
.toWriteTimes(berlin)
|
||||||
|
assertThat(times.dtStartMillis).isEqualTo(Instant.parse("2026-10-25T00:00:00Z").toEpochMilliseconds())
|
||||||
|
assertThat(times.dtEndMillis).isEqualTo(Instant.parse("2026-10-25T01:00:00Z").toEpochMilliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a move between the two repeated fall-back hours writes the new times`() {
|
||||||
|
// 02:30 CEST to 02:30 CET: the same wall clock, an hour later.
|
||||||
|
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
|
||||||
|
val original = form(start = repeated, end = repeated)
|
||||||
|
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 2)))
|
||||||
|
val updated = original.copy(
|
||||||
|
start = original.start.copy(offset = UtcOffset(hours = 1)),
|
||||||
|
end = original.end.copy(offset = UtcOffset(hours = 1)),
|
||||||
|
)
|
||||||
|
val values = update(original, updated)
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART])
|
||||||
|
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
|
||||||
|
assertThat(values[CalendarContract.Events.DTEND])
|
||||||
|
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an untouched form in the repeated fall-back hour writes no times`() {
|
||||||
|
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
|
||||||
|
val original = form(start = repeated, end = repeated)
|
||||||
|
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 1)))
|
||||||
|
assertThat(update(original, original.copy(title = "Renamed")))
|
||||||
|
.doesNotContainKey(CalendarContract.Events.DTSTART)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a series anchored on the second repeated hour keeps it through a time edit`() {
|
||||||
|
// The series starts 02:30 CET on the fall-back day; an occurrence a week
|
||||||
|
// later gets ten more minutes.
|
||||||
|
val anchor = Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds()
|
||||||
|
val original = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 11, 1), LocalTime(2, 30)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 11, 1), LocalTime(3, 30)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
val updated = original.copy(end = FormTime(LocalDateTime(LocalDate(2026, 11, 1), LocalTime(3, 40))))
|
||||||
|
val values = update(original, updated, series = anchor)
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(anchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a series anchor moved between the repeated hours follows the form`() {
|
||||||
|
// Editing the anchor occurrence itself from 02:30 CEST onto 02:30 CET.
|
||||||
|
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
|
||||||
|
val anchor = Instant.parse("2026-10-25T00:30:00Z").toEpochMilliseconds()
|
||||||
|
val original = form(start = repeated, end = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(4, 0)))
|
||||||
|
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), rrule = "FREQ=WEEKLY")
|
||||||
|
val updated = original.copy(start = FormTime(repeated, UtcOffset(hours = 1)))
|
||||||
|
val values = update(original, updated, series = anchor)
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART])
|
||||||
|
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.time
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
|
||||||
|
/** A device zone a test sets, by default the machine's own. */
|
||||||
|
class FakeZoneSource(zone: TimeZone = TimeZone.currentSystemDefault()) : ZoneSource {
|
||||||
|
override val zone: MutableStateFlow<TimeZone> = MutableStateFlow(zone)
|
||||||
|
|
||||||
|
override fun current(): TimeZone = zone.value
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ 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.UtcOffset
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -15,29 +17,29 @@ class EventFormTest {
|
|||||||
isAllDay: Boolean = false,
|
isAllDay: Boolean = false,
|
||||||
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||||
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||||
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = start, end = end)
|
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = FormTime(start), end = FormTime(end))
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `valid timed form has no problems`() {
|
fun `valid timed form has no problems`() {
|
||||||
assertThat(form().problems()).isEmpty()
|
assertThat(form().problems(berlin)).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `missing calendar is a problem`() {
|
fun `missing calendar is a problem`() {
|
||||||
assertThat(form(calendarId = null).problems())
|
assertThat(form(calendarId = null).problems(berlin))
|
||||||
.containsExactly(EventFormProblem.NoCalendar)
|
.containsExactly(EventFormProblem.NoCalendar)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `timed end before start is a problem`() {
|
fun `timed end before start is a problem`() {
|
||||||
val bad = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)))
|
val bad = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)))
|
||||||
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
|
assertThat(bad.problems(berlin)).containsExactly(EventFormProblem.EndBeforeStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `zero-length timed event is allowed`() {
|
fun `zero-length timed event is allowed`() {
|
||||||
val instant = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
val instant = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||||
assertThat(instant.problems()).isEmpty()
|
assertThat(instant.problems(berlin)).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -47,7 +49,7 @@ class EventFormTest {
|
|||||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
)
|
)
|
||||||
assertThat(allDay.problems()).isEmpty()
|
assertThat(allDay.problems(berlin)).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -57,7 +59,7 @@ class EventFormTest {
|
|||||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(0, 0)),
|
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(0, 0)),
|
||||||
)
|
)
|
||||||
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
|
assertThat(bad.problems(berlin)).containsExactly(EventFormProblem.EndBeforeStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -66,7 +68,7 @@ class EventFormTest {
|
|||||||
calendarId = null,
|
calendarId = null,
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
|
||||||
)
|
)
|
||||||
assertThat(bad.problems()).containsExactly(
|
assertThat(bad.problems(berlin)).containsExactly(
|
||||||
EventFormProblem.NoCalendar,
|
EventFormProblem.NoCalendar,
|
||||||
EventFormProblem.EndBeforeStart,
|
EventFormProblem.EndBeforeStart,
|
||||||
)
|
)
|
||||||
@@ -76,28 +78,28 @@ class EventFormTest {
|
|||||||
fun `recurrence until before the first day is a problem`() {
|
fun `recurrence until before the first day is a problem`() {
|
||||||
// Days before the start, so it parses to an earlier date in any zone.
|
// Days before the start, so it parses to an earlier date in any zone.
|
||||||
val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z")
|
val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z")
|
||||||
assertThat(bad.problems())
|
assertThat(bad.problems(berlin))
|
||||||
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
|
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `recurrence until on or after the first day is fine`() {
|
fun `recurrence until on or after the first day is fine`() {
|
||||||
// Date-only UNTIL parses zone-independently.
|
// Date-only UNTIL parses zone-independently.
|
||||||
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems()).isEmpty()
|
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems(berlin)).isEmpty()
|
||||||
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems()).isEmpty()
|
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems(berlin)).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `complex rrules are not validated against the start`() {
|
fun `complex rrules are not validated against the start`() {
|
||||||
// The picker can't have produced this ("second Monday" ordinal BYDAY);
|
// The picker can't have produced this ("second Monday" ordinal BYDAY);
|
||||||
// it is preserved verbatim and never flagged.
|
// it is preserved verbatim and never flagged.
|
||||||
assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems()).isEmpty()
|
assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems(berlin)).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `weekly byday rules are validated against the start`() {
|
fun `weekly byday rules are validated against the start`() {
|
||||||
val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101")
|
val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101")
|
||||||
assertThat(bad.problems())
|
assertThat(bad.problems(berlin))
|
||||||
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
|
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +152,8 @@ class EventFormTest {
|
|||||||
endMillis = 1_781_164_800_000L + 90L * 60L * 1000L,
|
endMillis = 1_781_164_800_000L + 90L * 60L * 1000L,
|
||||||
zone = berlin,
|
zone = berlin,
|
||||||
)
|
)
|
||||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||||
assertThat(prefilled.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
|
assertThat(prefilled.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
|
||||||
assertThat(prefilled.isAllDay).isFalse()
|
assertThat(prefilled.isAllDay).isFalse()
|
||||||
assertThat(prefilled.calendarId).isEqualTo(7L)
|
assertThat(prefilled.calendarId).isEqualTo(7L)
|
||||||
assertThat(prefilled.title).isEqualTo("Stand-up")
|
assertThat(prefilled.title).isEqualTo("Stand-up")
|
||||||
@@ -182,7 +184,7 @@ class EventFormTest {
|
|||||||
assertThat(prefilled.timezone).isEqualTo("America/New_York")
|
assertThat(prefilled.timezone).isEqualTo("America/New_York")
|
||||||
// 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the
|
// 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the
|
||||||
// event's own wall-clock, which is what a later save re-anchors to.
|
// event's own wall-clock, which is what a later save re-anchors to.
|
||||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
|
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
|
||||||
assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone)
|
assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,68 +208,27 @@ class EventFormTest {
|
|||||||
)
|
)
|
||||||
// A malformed sync row must not be honoured, nor fail the open.
|
// A malformed sync row must not be honoured, nor fail the open.
|
||||||
assertThat(prefilled.timezone).isNull()
|
assertThat(prefilled.timezone).isNull()
|
||||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `timesIn converts a pinned event into the target zone`() {
|
fun `isPinnedAwayFrom is false when there is nothing to disambiguate`() {
|
||||||
val form = EventForm(
|
|
||||||
calendarId = 1L,
|
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
|
|
||||||
timezone = "America/New_York",
|
|
||||||
)
|
|
||||||
val (start, end) = form.timesIn(berlin)!!
|
|
||||||
// 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2).
|
|
||||||
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0)))
|
|
||||||
assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `timesIn crosses the date line when the offset pushes past midnight`() {
|
|
||||||
val form = EventForm(
|
|
||||||
calendarId = 1L,
|
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)),
|
|
||||||
timezone = "America/New_York",
|
|
||||||
)
|
|
||||||
val (start, _) = form.timesIn(berlin)!!
|
|
||||||
// 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move
|
|
||||||
// with it, not just the clock.
|
|
||||||
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `timesIn tracks each zone's own DST rather than a fixed offset`() {
|
|
||||||
// In January both are on standard time: 08:00 EST is 14:00 CET — the
|
|
||||||
// same six hours as July, but only because both shifted. Late March,
|
|
||||||
// when the US has sprung forward and Europe hasn't, the gap is five.
|
|
||||||
val march = EventForm(
|
|
||||||
calendarId = 1L,
|
|
||||||
start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)),
|
|
||||||
timezone = "America/New_York",
|
|
||||||
)
|
|
||||||
assertThat(march.timesIn(berlin)!!.first)
|
|
||||||
.isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `timesIn returns null when there is nothing to disambiguate`() {
|
|
||||||
val base = EventForm(
|
val base = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
|
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0))),
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
|
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0))),
|
||||||
)
|
)
|
||||||
// Unpinned: the form's times already are the local times.
|
// Unpinned: the form's times already are the local times.
|
||||||
assertThat(base.timesIn(berlin)).isNull()
|
assertThat(base.isPinnedAwayFrom(berlin)).isFalse()
|
||||||
// Pinned to the target itself: same thing.
|
// Pinned to the target itself: same thing.
|
||||||
assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull()
|
assertThat(base.copy(timezone = "Europe/Berlin").isPinnedAwayFrom(berlin)).isFalse()
|
||||||
// All-day: date-anchored, so there's no zone conversion to show.
|
// All-day: date-anchored, so there's no zone conversion to show.
|
||||||
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin))
|
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").isPinnedAwayFrom(berlin))
|
||||||
.isNull()
|
.isFalse()
|
||||||
// Unparseable: can't convert, mustn't throw.
|
// Unparseable: can't convert, mustn't throw.
|
||||||
assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull()
|
assertThat(base.copy(timezone = "Mars/Olympus_Mons").isPinnedAwayFrom(berlin)).isFalse()
|
||||||
|
// Pinned elsewhere: worth spelling out.
|
||||||
|
assertThat(base.copy(timezone = "America/New_York").isPinnedAwayFrom(berlin)).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -425,4 +386,190 @@ class EventFormTest {
|
|||||||
assertThat(prefilled.color).isNull()
|
assertThat(prefilled.color).isNull()
|
||||||
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Color)
|
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Color)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm keeps which repeated fall-back hour each end is in`() {
|
||||||
|
val start = Instant.parse("2026-10-25T00:00:00Z")
|
||||||
|
val end = Instant.parse("2026-10-25T01:00:00Z")
|
||||||
|
val prefilled = detail().toEditForm(start.toEpochMilliseconds(), end.toEpochMilliseconds(), berlin)
|
||||||
|
assertThat(prefilled.start.dateTime).isEqualTo(prefilled.end.dateTime)
|
||||||
|
assertThat(prefilled.startInstant(berlin)).isEqualTo(start)
|
||||||
|
assertThat(prefilled.endInstant(berlin)).isEqualTo(end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a span ending in the repeated fall-back hour is valid`() {
|
||||||
|
// 02:45 CEST to 02:15 CET: half an hour, though the clock reads backwards.
|
||||||
|
val form = form().withTimes(
|
||||||
|
Instant.parse("2026-10-25T00:45:00Z"),
|
||||||
|
Instant.parse("2026-10-25T01:15:00Z"),
|
||||||
|
berlin,
|
||||||
|
)
|
||||||
|
assertThat(form.end.dateTime).isLessThan(form.start.dateTime)
|
||||||
|
assertThat(form.problems(berlin)).isEmpty()
|
||||||
|
// Read as the same pass at both ends, it really does end first.
|
||||||
|
assertThat(form.copy(start = FormTime(form.start.dateTime), end = FormTime(form.end.dateTime)).problems(berlin))
|
||||||
|
.containsExactly(EventFormProblem.EndBeforeStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an end picked in the repeated hour moves the way the hand did`() {
|
||||||
|
// 02:45 CEST to 02:15 CET.
|
||||||
|
val form = form().withTimes(
|
||||||
|
Instant.parse("2026-10-25T00:45:00Z"),
|
||||||
|
Instant.parse("2026-10-25T01:15:00Z"),
|
||||||
|
berlin,
|
||||||
|
)
|
||||||
|
// Picking the end it already has keeps its pass.
|
||||||
|
assertThat(form.withEnd(form.end.dateTime, berlin)).isEqualTo(form)
|
||||||
|
// Later on the clock: later in time, still on the second pass.
|
||||||
|
assertThat(form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 50)), berlin).endInstant(berlin))
|
||||||
|
.isEqualTo(Instant.parse("2026-10-25T01:50:00Z"))
|
||||||
|
// Earlier on the clock: earlier in time, not an hour earlier.
|
||||||
|
assertThat(form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 5)), berlin).endInstant(berlin))
|
||||||
|
.isEqualTo(Instant.parse("2026-10-25T01:05:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an end picked in the repeated hour stays after a second-pass start`() {
|
||||||
|
// 02:10 CET to 04:00, its end set to 02:40: only the second pass is after the start.
|
||||||
|
val form = form().withTimes(
|
||||||
|
Instant.parse("2026-10-25T01:10:00Z"),
|
||||||
|
Instant.parse("2026-10-25T03:00:00Z"),
|
||||||
|
berlin,
|
||||||
|
)
|
||||||
|
val picked = form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 40)), berlin)
|
||||||
|
assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:40:00Z"))
|
||||||
|
assertThat(picked.problems(berlin)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a repeated-hour end changed and changed back is the end it was`() {
|
||||||
|
// 01:00 CEST to 02:30 CEST, its end set to 03:00 and back.
|
||||||
|
val original = form().withTimes(
|
||||||
|
Instant.parse("2026-10-24T23:00:00Z"),
|
||||||
|
Instant.parse("2026-10-25T00:30:00Z"),
|
||||||
|
berlin,
|
||||||
|
)
|
||||||
|
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
|
||||||
|
val away = original.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(3, 0)), berlin)
|
||||||
|
assertThat(away.withEnd(repeated, berlin, original = original.end)).isEqualTo(original)
|
||||||
|
// Without the opened end to go by, coming back from 03:00 reads as the later pass.
|
||||||
|
assertThat(away.withEnd(repeated, berlin).endInstant(berlin))
|
||||||
|
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a start picked in the repeated hour keeps the length`() {
|
||||||
|
// 02:30 CET to 03:30, its start set back to 02:10.
|
||||||
|
val form = form().withTimes(
|
||||||
|
Instant.parse("2026-10-25T01:30:00Z"),
|
||||||
|
Instant.parse("2026-10-25T02:30:00Z"),
|
||||||
|
berlin,
|
||||||
|
)
|
||||||
|
val picked = form.withStart(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 10)), berlin)
|
||||||
|
assertThat(picked.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:10:00Z"))
|
||||||
|
assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T02:10:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pinned event's times land in the device zone`() {
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0))),
|
||||||
|
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0))),
|
||||||
|
timezone = "America/New_York",
|
||||||
|
)
|
||||||
|
val zone = form.resolvedZone(berlin)
|
||||||
|
// 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2).
|
||||||
|
assertThat(form.startInstant(zone).toLocalDateTime(berlin))
|
||||||
|
.isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0)))
|
||||||
|
assertThat(form.endInstant(zone).toLocalDateTime(berlin))
|
||||||
|
.isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pinned event's local time crosses the date line with the offset`() {
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0))),
|
||||||
|
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0))),
|
||||||
|
timezone = "America/New_York",
|
||||||
|
)
|
||||||
|
// 20:00 New York is 02:00 the NEXT day in Berlin.
|
||||||
|
assertThat(form.startInstant(form.resolvedZone(berlin)).toLocalDateTime(berlin))
|
||||||
|
.isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pinned event's local time tracks each zone's own DST`() {
|
||||||
|
// Late March the US has sprung forward and Europe hasn't: five hours, not six.
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
start = FormTime(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0))),
|
||||||
|
end = FormTime(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0))),
|
||||||
|
timezone = "America/New_York",
|
||||||
|
)
|
||||||
|
assertThat(form.startInstant(form.resolvedZone(berlin)).toLocalDateTime(berlin))
|
||||||
|
.isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `offsets are kept only for a repeated hour`() {
|
||||||
|
val summer = form().withTimes(
|
||||||
|
Instant.parse("2026-06-11T08:00:00Z"),
|
||||||
|
Instant.parse("2026-06-11T09:00:00Z"),
|
||||||
|
berlin,
|
||||||
|
)
|
||||||
|
assertThat(summer.start.offset).isNull()
|
||||||
|
assertThat(summer.end.offset).isNull()
|
||||||
|
val repeated = form().withTimes(
|
||||||
|
Instant.parse("2026-10-25T00:45:00Z"),
|
||||||
|
Instant.parse("2026-10-25T01:15:00Z"),
|
||||||
|
berlin,
|
||||||
|
)
|
||||||
|
assertThat(repeated.start.offset).isEqualTo(UtcOffset(hours = 2))
|
||||||
|
assertThat(repeated.end.offset).isEqualTo(UtcOffset(hours = 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an end changed and changed back is the form it was`() {
|
||||||
|
val original = detail().toEditForm(
|
||||||
|
Instant.parse("2026-06-11T08:00:00Z").toEpochMilliseconds(),
|
||||||
|
Instant.parse("2026-06-11T09:00:00Z").toEpochMilliseconds(),
|
||||||
|
berlin,
|
||||||
|
)
|
||||||
|
val back = original
|
||||||
|
.withEnd(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)), berlin)
|
||||||
|
.withEnd(original.end.dateTime, berlin)
|
||||||
|
assertThat(back).isEqualTo(original)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a new zone drops the kept pass, the same zone keeps it`() {
|
||||||
|
val repeated = form().withTimes(
|
||||||
|
Instant.parse("2026-10-25T01:30:00Z"),
|
||||||
|
Instant.parse("2026-10-25T02:00:00Z"),
|
||||||
|
berlin,
|
||||||
|
)
|
||||||
|
assertThat(repeated.withTimezone("Europe/Berlin", berlin)).isEqualTo(repeated.copy(timezone = "Europe/Berlin"))
|
||||||
|
val moved = repeated.withTimezone("Africa/Lagos", berlin)
|
||||||
|
assertThat(moved.start.offset).isNull()
|
||||||
|
assertThat(moved.end.offset).isNull()
|
||||||
|
// Back on Berlin, a picked 02:30 takes the earlier pass, as any picked time does.
|
||||||
|
assertThat(moved.withTimezone(null, berlin).startInstant(berlin))
|
||||||
|
.isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an end picked in the spring-forward gap moves on as a start does`() {
|
||||||
|
val form = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 30)),
|
||||||
|
)
|
||||||
|
val picked = form.withEnd(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)), berlin)
|
||||||
|
assertThat(picked.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
|
||||||
|
val started = form.withStart(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)), berlin)
|
||||||
|
assertThat(started.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ 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.UtcOffset
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class EventShiftTest {
|
class EventShiftTest {
|
||||||
|
|
||||||
@@ -22,8 +24,8 @@ class EventShiftTest {
|
|||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Standup",
|
title = "Standup",
|
||||||
isAllDay = isAllDay,
|
isAllDay = isAllDay,
|
||||||
start = start,
|
start = FormTime(start),
|
||||||
end = end,
|
end = FormTime(end),
|
||||||
timezone = timezone,
|
timezone = timezone,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,8 +36,8 @@ class EventShiftTest {
|
|||||||
|
|
||||||
val moved = original.shiftedTo(target, berlin)
|
val moved = original.shiftedTo(target, berlin)
|
||||||
|
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
|
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
|
||||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
|
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -52,8 +54,8 @@ class EventShiftTest {
|
|||||||
val moved = original.shiftedTo(target, berlin)
|
val moved = original.shiftedTo(target, berlin)
|
||||||
|
|
||||||
assertThat(moved.timezone).isEqualTo("America/New_York")
|
assertThat(moved.timezone).isEqualTo("America/New_York")
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
|
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
|
||||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
|
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -69,10 +71,10 @@ class EventShiftTest {
|
|||||||
|
|
||||||
// Two real hours from 01:00 CET lands at 04:00 CEST: the hour in
|
// Two real hours from 01:00 CET lands at 04:00 CEST: the hour in
|
||||||
// between does not exist.
|
// between does not exist.
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
|
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
|
||||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
|
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
|
||||||
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
|
assertThat(moved.end.dateTime.toInstant(berlin) - moved.start.dateTime.toInstant(berlin))
|
||||||
.isEqualTo(original.end.toInstant(berlin) - original.start.toInstant(berlin))
|
.isEqualTo(original.end.dateTime.toInstant(berlin) - original.start.dateTime.toInstant(berlin))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -83,13 +85,15 @@ class EventShiftTest {
|
|||||||
|
|
||||||
val moved = original.shiftedTo(target, berlin)
|
val moved = original.shiftedTo(target, berlin)
|
||||||
|
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
|
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a zero-distance drop leaves the form untouched`() {
|
fun `a zero-distance drop leaves the form untouched`() {
|
||||||
val original = form()
|
// As read from the provider, offsets and all.
|
||||||
assertThat(original.shiftedTo(original.start.toInstant(berlin), berlin)).isEqualTo(original)
|
val start = form().start.dateTime.toInstant(berlin)
|
||||||
|
val original = form().withTimes(start, form().end.dateTime.toInstant(berlin), berlin)
|
||||||
|
assertThat(original.shiftedTo(start, berlin)).isEqualTo(original)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -99,7 +103,7 @@ class EventShiftTest {
|
|||||||
|
|
||||||
val moved = original.shiftedTo(target, newYork)
|
val moved = original.shiftedTo(target, newYork)
|
||||||
|
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
|
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -125,8 +129,8 @@ class EventShiftTest {
|
|||||||
|
|
||||||
val moved = multiDay.shiftedByDays(-2, berlin)
|
val moved = multiDay.shiftedByDays(-2, berlin)
|
||||||
|
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
|
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
|
||||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
|
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -140,11 +144,11 @@ class EventShiftTest {
|
|||||||
|
|
||||||
val moved = overnight.shiftedByDays(7, berlin)
|
val moved = overnight.shiftedByDays(7, berlin)
|
||||||
|
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
|
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
|
||||||
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
|
assertThat(moved.end.dateTime.toInstant(berlin) - moved.start.dateTime.toInstant(berlin))
|
||||||
.isEqualTo(overnight.end.toInstant(berlin) - overnight.start.toInstant(berlin))
|
.isEqualTo(overnight.end.dateTime.toInstant(berlin) - overnight.start.dateTime.toInstant(berlin))
|
||||||
// The wall-clock end therefore lands an hour later than a naive +7 days.
|
// The wall-clock end therefore lands an hour later than a naive +7 days.
|
||||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
|
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -152,4 +156,111 @@ class EventShiftTest {
|
|||||||
val original = form()
|
val original = form()
|
||||||
assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original)
|
assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resizing sets each edge on its own`() {
|
||||||
|
val original = form()
|
||||||
|
val end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)).toInstant(berlin)
|
||||||
|
|
||||||
|
val resized = original.resizedTo(original.start.dateTime.toInstant(berlin), end, berlin)
|
||||||
|
|
||||||
|
assertThat(resized.start).isEqualTo(original.start)
|
||||||
|
assertThat(resized.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a resize past midnight carries the end onto the next day`() {
|
||||||
|
val original = form()
|
||||||
|
val end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)).toInstant(berlin)
|
||||||
|
|
||||||
|
val resized = original.resizedTo(original.start.dateTime.toInstant(berlin), end, berlin)
|
||||||
|
|
||||||
|
assertThat(resized.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pinned event is resized in its own zone`() {
|
||||||
|
val original = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||||
|
timezone = "America/New_York",
|
||||||
|
)
|
||||||
|
// "15:30 Berlin" is 09:30 in New York.
|
||||||
|
val start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin)
|
||||||
|
|
||||||
|
val resized = original.resizedTo(start, original.end.dateTime.toInstant(newYork), berlin)
|
||||||
|
|
||||||
|
assertThat(resized.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
|
||||||
|
assertThat(resized.end).isEqualTo(original.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day event ignores a resize`() {
|
||||||
|
val allDay = form(isAllDay = true)
|
||||||
|
val start = allDay.start.dateTime.toInstant(berlin)
|
||||||
|
|
||||||
|
assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 02:00 CEST to 02:00 CET on 2026-10-25: one real hour, one wall-clock value.
|
||||||
|
private val repeatStart = Instant.parse("2026-10-25T00:00:00Z")
|
||||||
|
private val repeatEnd = Instant.parse("2026-10-25T01:00:00Z")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moving an event in the repeated fall-back hour keeps its length`() {
|
||||||
|
val original = form().withTimes(repeatStart, repeatEnd, berlin)
|
||||||
|
val moved = original.shiftedTo(Instant.parse("2026-10-25T00:30:00Z"), berlin)
|
||||||
|
assertThat(moved.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
|
||||||
|
assertThat(moved.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a move onto the second repeated hour stays there`() {
|
||||||
|
val target = Instant.parse("2026-10-25T01:30:00Z")
|
||||||
|
val moved = form().withTimes(repeatStart, repeatEnd, berlin).shiftedTo(target, berlin)
|
||||||
|
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30)))
|
||||||
|
assertThat(moved.startInstant(berlin)).isEqualTo(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveIn takes the preferred offset only where the hour repeats`() {
|
||||||
|
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
|
||||||
|
val cet = UtcOffset(hours = 1)
|
||||||
|
assertThat(repeated.resolveIn(berlin, cet)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
|
||||||
|
assertThat(repeated.resolveIn(berlin, null)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
|
||||||
|
// Not one of 14:00's offsets on a summer day: ignored.
|
||||||
|
val summer = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(14, 0))
|
||||||
|
assertThat(summer.resolveIn(berlin, cet)).isEqualTo(summer.toInstant(berlin))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a day move onto the repeated hour doesn't depend on the source's season`() {
|
||||||
|
// 02:30 a week after the fall-back day (CET) and in summer (CEST), both
|
||||||
|
// moved onto it: the earlier pass either way, as a timeline drop.
|
||||||
|
val first = Instant.parse("2026-10-25T00:30:00Z")
|
||||||
|
val november = form().withTimes(
|
||||||
|
Instant.parse("2026-11-01T01:30:00Z"),
|
||||||
|
Instant.parse("2026-11-01T02:30:00Z"),
|
||||||
|
berlin,
|
||||||
|
)
|
||||||
|
assertThat(november.shiftedByDays(-7, berlin).startInstant(berlin)).isEqualTo(first)
|
||||||
|
val summer = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 10, 18), LocalTime(2, 30)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 10, 18), LocalTime(3, 30)),
|
||||||
|
)
|
||||||
|
assertThat(summer.shiftedByDays(7, berlin).startInstant(berlin)).isEqualTo(first)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a day move onto the spring-forward gap keeps its wall clock`() {
|
||||||
|
// Saturday 02:30 to 03:30, moved onto Sunday 29 March, when 02:30 is skipped.
|
||||||
|
val moved = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(2, 30)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(3, 30)),
|
||||||
|
).shiftedByDays(1, berlin)
|
||||||
|
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)))
|
||||||
|
// It still means 03:30 CEST, an hour long.
|
||||||
|
assertThat(moved.startInstant(berlin)).isEqualTo(Instant.parse("2026-03-29T01:30:00Z"))
|
||||||
|
assertThat(moved.endInstant(berlin)).isEqualTo(Instant.parse("2026-03-29T02:30:00Z"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ class InsertEventFormTest {
|
|||||||
)
|
)
|
||||||
assertThat(form.calendarId).isNull() // resolves to last-used / first-writable
|
assertThat(form.calendarId).isNull() // resolves to last-used / first-writable
|
||||||
assertThat(form.isAllDay).isFalse()
|
assertThat(form.isAllDay).isFalse()
|
||||||
assertThat(form.start).isEqualTo(start)
|
assertThat(form.start.dateTime).isEqualTo(start)
|
||||||
assertThat(form.end).isEqualTo(end)
|
assertThat(form.end.dateTime).isEqualTo(end)
|
||||||
assertThat(form.title).isEqualTo("Standup")
|
assertThat(form.title).isEqualTo("Standup")
|
||||||
assertThat(form.description).isEqualTo("Daily")
|
assertThat(form.description).isEqualTo("Daily")
|
||||||
assertThat(form.location).isEqualTo("Room 1")
|
assertThat(form.location).isEqualTo("Room 1")
|
||||||
@@ -64,8 +64,8 @@ class InsertEventFormTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `missing times default to the next full hour and a one-hour duration`() {
|
fun `missing times default to the next full hour and a one-hour duration`() {
|
||||||
val form = build(title = "Quick note")
|
val form = build(title = "Quick note")
|
||||||
assertThat(form.start).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
|
assertThat(form.start.dateTime).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
|
||||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
|
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
|
||||||
assertThat(form.title).isEqualTo("Quick note")
|
assertThat(form.title).isEqualTo("Quick note")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +73,8 @@ class InsertEventFormTest {
|
|||||||
fun `timed event with only a start gets a one-hour end`() {
|
fun `timed event with only a start gets a one-hour end`() {
|
||||||
val start = LocalDateTime(2026, 5, 2, 9, 0)
|
val start = LocalDateTime(2026, 5, 2, 9, 0)
|
||||||
val form = build(beginMillis = millis(start))
|
val form = build(beginMillis = millis(start))
|
||||||
assertThat(form.start).isEqualTo(start)
|
assertThat(form.start.dateTime).isEqualTo(start)
|
||||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
|
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -82,7 +82,7 @@ class InsertEventFormTest {
|
|||||||
val start = LocalDateTime(2026, 5, 2, 9, 0)
|
val start = LocalDateTime(2026, 5, 2, 9, 0)
|
||||||
val badEnd = LocalDateTime(2026, 5, 2, 8, 0)
|
val badEnd = LocalDateTime(2026, 5, 2, 8, 0)
|
||||||
val form = build(beginMillis = millis(start), endMillis = millis(badEnd))
|
val form = build(beginMillis = millis(start), endMillis = millis(badEnd))
|
||||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
|
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class SharedTextTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `subject becomes the title and the text the description`() {
|
||||||
|
assertThat(sharedTextOrNull("Dinner", "Friday at Luigi's\nBring wine"))
|
||||||
|
.isEqualTo(SharedText("Dinner", "Friday at Luigi's\nBring wine"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `without a subject the first line is the title and the rest the description`() {
|
||||||
|
assertThat(sharedTextOrNull(null, "\n Dentist \n\nCheck-up, room 4\n"))
|
||||||
|
.isEqualTo(SharedText("Dentist", "Check-up, room 4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a single line is the title only`() {
|
||||||
|
assertThat(sharedTextOrNull(" ", " Call mum ")).isEqualTo(SharedText("Call mum", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `subject without text leaves the description empty`() {
|
||||||
|
assertThat(sharedTextOrNull("Meeting", null)).isEqualTo(SharedText("Meeting", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a long first line is cut and the full text kept as the description`() {
|
||||||
|
val line = "a".repeat(300)
|
||||||
|
val shared = sharedTextOrNull(null, "$line\nmore")!!
|
||||||
|
assertThat(shared.title).isEqualTo("a".repeat(255))
|
||||||
|
assertThat(shared.description).isEqualTo("$line\nmore")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a long subject is cut and kept in full above the text`() {
|
||||||
|
val subject = "s".repeat(300)
|
||||||
|
val shared = sharedTextOrNull(subject, "body")!!
|
||||||
|
assertThat(shared.title).hasLength(255)
|
||||||
|
assertThat(shared.description).isEqualTo("$subject\n\nbody")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the cut never splits a surrogate pair`() {
|
||||||
|
val shared = sharedTextOrNull(null, "a".repeat(254) + "😀" + "b")!!
|
||||||
|
assertThat(shared.title).isEqualTo("a".repeat(254))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing usable is null`() {
|
||||||
|
assertThat(sharedTextOrNull(null, null)).isNull()
|
||||||
|
assertThat(sharedTextOrNull(" ", "\n \n")).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,8 +29,8 @@ class ParsedIcsFormTest {
|
|||||||
assertThat(form.calendarId).isNull()
|
assertThat(form.calendarId).isNull()
|
||||||
assertThat(form.title).isEqualTo("Call")
|
assertThat(form.title).isEqualTo("Call")
|
||||||
// 13:00 UTC == 15:00 Berlin (summer).
|
// 13:00 UTC == 15:00 Berlin (summer).
|
||||||
assertThat(form.start).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
|
assertThat(form.start.dateTime).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
|
||||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
|
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
|
||||||
assertThat(form.reminders).containsExactly(5, 10).inOrder()
|
assertThat(form.reminders).containsExactly(5, 10).inOrder()
|
||||||
assertThat(form.rrule).isEqualTo("FREQ=WEEKLY")
|
assertThat(form.rrule).isEqualTo("FREQ=WEEKLY")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class AnchorTodayTest {
|
|||||||
|
|
||||||
private val today = LocalDate(2026, 6, 17)
|
private val today = LocalDate(2026, 6, 17)
|
||||||
private val tomorrow = LocalDate(2026, 6, 18)
|
private val tomorrow = LocalDate(2026, 6, 18)
|
||||||
|
private val yesterday = LocalDate(2026, 6, 16)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `disabled leaves the days untouched even when today is absent`() {
|
fun `disabled leaves the days untouched even when today is absent`() {
|
||||||
@@ -36,4 +37,18 @@ class AnchorTodayTest {
|
|||||||
val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
|
val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
|
||||||
assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
|
assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabled slots today in after loaded past days`() {
|
||||||
|
val days = listOf(AgendaDay(yesterday, emptyList()), AgendaDay(tomorrow, emptyList()))
|
||||||
|
val result = anchorTodayIfMissing(days, today, enabled = true)
|
||||||
|
assertThat(result.map { it.date }).containsExactly(yesterday, today, tomorrow).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabled appends today after only past days`() {
|
||||||
|
val days = listOf(AgendaDay(yesterday, emptyList()))
|
||||||
|
val result = anchorTodayIfMissing(days, today, enabled = true)
|
||||||
|
assertThat(result.map { it.date }).containsExactly(yesterday, today).inOrder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** A block drafted on an empty timeline slot (#153). */
|
||||||
|
class DraftBlockTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
private val day = LocalDate(2026, 10, 9)
|
||||||
|
|
||||||
|
private fun draft(startMin: Int, minutes: Int) =
|
||||||
|
draftEvent("New event", 0, wallClockInstant(day, startMin, zone), minutes)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a press snaps down to the slot it is in`() {
|
||||||
|
assertThat(draftStartMin(9 * 60 + 14f)).isEqualTo(9 * 60)
|
||||||
|
assertThat(draftStartMin(9 * 60 + 15f)).isEqualTo(9 * 60 + 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a press at the very bottom still leaves a slot before midnight`() {
|
||||||
|
assertThat(draftStartMin(24 * 60f)).isEqualTo(24 * 60 - DRAG_SNAP_MINUTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the draft is told apart from real events`() {
|
||||||
|
assertThat(draft(9 * 60, 60).isDraft).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a draft within its day is one block the column wide`() {
|
||||||
|
val block = draftBlock(draft(9 * 60, 60), day, zone)!!
|
||||||
|
assertThat(block.startMin).isEqualTo(9 * 60)
|
||||||
|
assertThat(block.endMin).isEqualTo(10 * 60)
|
||||||
|
assertThat(block.lane to block.laneCount).isEqualTo(0 to 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a draft past midnight is cut there and carries on the next day`() {
|
||||||
|
val event = draft(23 * 60, 120)
|
||||||
|
val first = draftBlock(event, day, zone)!!
|
||||||
|
val second = draftBlock(event, day.plusDays(1), zone)!!
|
||||||
|
assertThat(first.startMin to first.endMin).isEqualTo(23 * 60 to 24 * 60)
|
||||||
|
assertThat(second.startMin to second.endMin).isEqualTo(0 to 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a draft ending at midnight leaves the next day alone`() {
|
||||||
|
assertThat(draftBlock(draft(23 * 60, 60), day.plusDays(1), zone)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a draft misses the day before it`() {
|
||||||
|
assertThat(draftBlock(draft(9 * 60, 60), day.plusDays(-1), zone)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a draft past midnight opens the form with its end on the next day`() {
|
||||||
|
val span = wallClockSpan(draft(23 * 60, 120), zone)
|
||||||
|
assertThat(span).isEqualTo(WallClockSpan(day, 23 * 60, 25 * 60))
|
||||||
|
assertThat(wallClockTime(span.day, span.endMin)).isEqualTo(wallClockTime(day.plusDays(1), 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LocalDate.plusDays(days: Int): LocalDate = LocalDate.fromEpochDays(toEpochDays() + days)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class IsPastOnTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.of("Europe/Berlin")
|
||||||
|
private val today = LocalDate(2026, 10, 8)
|
||||||
|
private val now = Instant.parse("2026-10-08T12:00:00Z")
|
||||||
|
|
||||||
|
// All-day Oct 6–9, still running today.
|
||||||
|
private val summit = EventInstance(
|
||||||
|
instanceId = 1,
|
||||||
|
eventId = 1,
|
||||||
|
calendarId = 1,
|
||||||
|
title = "Summit",
|
||||||
|
start = Instant.parse("2026-10-06T00:00:00Z"),
|
||||||
|
end = Instant.parse("2026-10-10T00:00:00Z"),
|
||||||
|
isAllDay = true,
|
||||||
|
color = 0,
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an ongoing multi-day event is past on the days already behind`() {
|
||||||
|
assertThat(summit.isPastOn(LocalDate(2026, 10, 7), now, zone)).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an ongoing multi-day event is not past today or later`() {
|
||||||
|
assertThat(summit.isPastOn(today, now, zone)).isFalse()
|
||||||
|
assertThat(summit.isPastOn(LocalDate(2026, 10, 9), now, zone)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event that ended earlier today is past`() {
|
||||||
|
val standup = summit.copy(
|
||||||
|
isAllDay = false,
|
||||||
|
start = Instant.parse("2026-10-08T07:00:00Z"),
|
||||||
|
end = Instant.parse("2026-10-08T07:15:00Z"),
|
||||||
|
)
|
||||||
|
assertThat(standup.isPastOn(today, now, zone)).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** The zone named after a time only where the fall-back hour repeats it (#387). */
|
||||||
|
class RepeatedHourZoneTest {
|
||||||
|
|
||||||
|
private val brussels = TimeZone.of("Europe/Brussels")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `both 02 30s of the fall-back day are told apart`() {
|
||||||
|
val first = withRepeatedHourZone("2:30 AM", Instant.parse("2026-10-25T00:30:00Z"), brussels, Locale.UK)
|
||||||
|
val second = withRepeatedHourZone("2:30 AM", Instant.parse("2026-10-25T01:30:00Z"), brussels, Locale.UK)
|
||||||
|
assertThat(first).startsWith("2:30 AM ")
|
||||||
|
assertThat(second).startsWith("2:30 AM ")
|
||||||
|
assertThat(first).isNotEqualTo(second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unambiguous time stays bare`() {
|
||||||
|
assertThat(withRepeatedHourZone("2:45 PM", Instant.parse("2026-10-25T13:45:00Z"), brussels, Locale.UK))
|
||||||
|
.isEqualTo("2:45 PM")
|
||||||
|
assertThat(withRepeatedHourZone("3:30 AM", Instant.parse("2026-10-25T02:30:00Z"), brussels, Locale.UK))
|
||||||
|
.isEqualTo("3:30 AM")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
|
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
@@ -93,7 +94,7 @@ class RescheduleViewModelTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
|
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
|
||||||
return RescheduleViewModel(repo, dispatcher)
|
return RescheduleViewModel(repo, dispatcher, FakeZoneSource())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
|
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
|
||||||
@@ -115,6 +116,17 @@ class RescheduleViewModelTest {
|
|||||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
|
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The end dragged an hour later, the start left where it is. */
|
||||||
|
private fun endOneHourLater() = MoveRequest(
|
||||||
|
eventId = 42L,
|
||||||
|
beginMillis = beginMillis,
|
||||||
|
endMillis = endMillis,
|
||||||
|
target = MoveTarget.Span(
|
||||||
|
Instant.fromEpochMilliseconds(beginMillis),
|
||||||
|
Instant.fromEpochMilliseconds(endMillis + 3_600_000L),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a one-off drop writes straight through with no scope prompt`(
|
fun `a one-off drop writes straight through with no scope prompt`(
|
||||||
@TempDir tempDir: Path,
|
@TempDir tempDir: Path,
|
||||||
@@ -595,4 +607,82 @@ class RescheduleViewModelTest {
|
|||||||
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
||||||
assertThat(vm.abandoned.value).isEqualTo(0)
|
assertThat(vm.abandoned.value).isEqualTo(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a resize writes the new length and reports both ends`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
|
||||||
|
vm.move(endOneHourLater())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val (_, original, updated) = fake.updatedEvents.single()
|
||||||
|
assertThat(updated.start).isEqualTo(original.start)
|
||||||
|
assertThat(updated.end).isNotEqualTo(original.end)
|
||||||
|
val moved = vm.outcome.value as MoveOutcome.Moved
|
||||||
|
assertThat(moved.startMillis).isEqualTo(beginMillis)
|
||||||
|
assertThat(moved.endMillis).isEqualTo(endMillis + 3_600_000L)
|
||||||
|
assertThat(moved.undo).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a moved event reports no end, so its chip keeps the move wording`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
|
||||||
|
vm.move(oneHourLater())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat((vm.outcome.value as MoveOutcome.Moved).endMillis).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resizing a recurring event asks for the scope, as a resize`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
|
||||||
|
vm.move(endOneHourLater())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.scopePrompt.value)
|
||||||
|
.isEqualTo(MoveScopePrompt(occurrenceOnly = false, resize = true))
|
||||||
|
vm.moveWithScope(RecurringWriteScope.AllEvents)
|
||||||
|
advanceUntilIdle()
|
||||||
|
// The start stays put, so the rule needs no realigning.
|
||||||
|
assertThat(fake.updatedEvents.single().third.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pulling a recurring event's start back over midnight resizes only the occurrence`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val sundayEvening = LocalDateTime(LocalDate(2026, 6, 7), LocalTime(22, 0))
|
||||||
|
.toInstant(TimeZone.currentSystemDefault())
|
||||||
|
|
||||||
|
vm.move(
|
||||||
|
MoveRequest(
|
||||||
|
eventId = 42L,
|
||||||
|
beginMillis = beginMillis,
|
||||||
|
endMillis = endMillis,
|
||||||
|
target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Not a whole-day shift, so the series anchor can't follow.
|
||||||
|
assertThat(vm.scopePrompt.value)
|
||||||
|
.isEqualTo(MoveScopePrompt(occurrenceOnly = true, resize = true))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** Where a dragged resize handle leaves an event's ends (#141). */
|
||||||
|
class ResizedSpanTest {
|
||||||
|
|
||||||
|
// 09:00–10:00, in minutes from its day's midnight.
|
||||||
|
private val start = 9 * 60
|
||||||
|
private val end = 10 * 60
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bottom edge snaps and leaves the start alone`() {
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 11 * 60 + 8f))
|
||||||
|
.isEqualTo(start to 11 * 60 + 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the top edge snaps and leaves the end alone`() {
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Top, start, end, 8 * 60 - 7f))
|
||||||
|
.isEqualTo(8 * 60 to end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an edge stops one snap step short of the other end`() {
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 7 * 60f))
|
||||||
|
.isEqualTo(start to start + DRAG_SNAP_MINUTES)
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f))
|
||||||
|
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bottom edge runs on past midnight`() {
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 24 * 60 + 90f))
|
||||||
|
.isEqualTo(start to 24 * 60 + 90)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the top edge runs back into the day before`() {
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Top, start, end, -60f))
|
||||||
|
.isEqualTo(-60 to end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wall clock minutes past either midnight land on the neighbouring day`() {
|
||||||
|
val zone = TimeZone.of("Europe/Berlin")
|
||||||
|
val day = LocalDate(2026, 10, 9)
|
||||||
|
assertThat(wallClockInstant(day, 24 * 60 + 30, zone))
|
||||||
|
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 10), LocalTime(0, 30)).toInstant(zone))
|
||||||
|
assertThat(wallClockInstant(day, -30, zone))
|
||||||
|
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 8), LocalTime(23, 30)).toInstant(zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finger drifting just over a column border keeps the edge on its day`() {
|
||||||
|
assertThat(stickyColumn(position = 3.1f, held = 2, lastIndex = 6)).isEqualTo(2)
|
||||||
|
assertThat(stickyColumn(position = 1.9f, held = 2, lastIndex = 6)).isEqualTo(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finger well into the next column takes the edge there`() {
|
||||||
|
assertThat(stickyColumn(position = 3.4f, held = 2, lastIndex = 6)).isEqualTo(3)
|
||||||
|
assertThat(stickyColumn(position = 1.6f, held = 2, lastIndex = 6)).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the first column is the one under the finger`() {
|
||||||
|
assertThat(stickyColumn(position = 4.9f, held = null, lastIndex = 6)).isEqualTo(4)
|
||||||
|
assertThat(stickyColumn(position = 9f, held = null, lastIndex = 6)).isEqualTo(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finger resting between two slots keeps the one it is on`() {
|
||||||
|
// 09:08 rounds to 09:15, but from 09:00 it is not far enough to leave.
|
||||||
|
assertThat(stickySnap(9 * 60 + 8f, held = 9 * 60)).isEqualTo(9 * 60)
|
||||||
|
assertThat(stickySnap(9 * 60 + 12f, held = 9 * 60)).isEqualTo(9 * 60 + 15)
|
||||||
|
assertThat(stickySnap(9 * 60 + 8f, held = null)).isEqualTo(9 * 60 + 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an edge dragged into a day past the other end changes nothing`() {
|
||||||
|
// The start into the day after a same-day event ends.
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Top, start, end, 24 * 60 + 9 * 60f, edgeDay = 1))
|
||||||
|
.isEqualTo(start to end)
|
||||||
|
// The end into the day before it starts.
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, -60f, edgeDay = -1))
|
||||||
|
.isEqualTo(start to end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an edge in the event's own days still clamps against the other end`() {
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f, edgeDay = 0))
|
||||||
|
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
|
||||||
|
// An overnight event's start may move anywhere up to its end's day.
|
||||||
|
val overnightEnd = 24 * 60 + 2 * 60
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Top, start, overnightEnd, 24 * 60 + 60f, edgeDay = 1))
|
||||||
|
.isEqualTo(24 * 60 + 60 to overnightEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,18 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.minutesPast
|
||||||
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.atTime
|
import kotlinx.datetime.atTime
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Instant
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,4 +157,135 @@ class TimelineDropTest {
|
|||||||
val block = layoutDay(listOf(plain), sat, zone).single()
|
val block = layoutDay(listOf(plain), sat, zone).single()
|
||||||
assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0)
|
assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val brussels = TimeZone.of("Europe/Brussels")
|
||||||
|
private val fallBack = LocalDate(2026, 10, 25)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a block on the fall-back day carries no clip offset`() {
|
||||||
|
// #387: 13:45Z is 14:45 CET, drawn and picked up at 14:45.
|
||||||
|
val event = overnight.copy(
|
||||||
|
start = Instant.parse("2026-10-25T13:45:00Z"),
|
||||||
|
end = Instant.parse("2026-10-25T15:45:00Z"),
|
||||||
|
)
|
||||||
|
val block = layoutDay(listOf(event), fallBack, brussels).single()
|
||||||
|
assertThat(block.clipOffsetMinutes(fallBack, brussels)).isEqualTo(0)
|
||||||
|
val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0)
|
||||||
|
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tail on the fall-back day is offset by wall-clock time`() {
|
||||||
|
val event = overnight.copy(
|
||||||
|
start = LocalDate(2026, 10, 24).atTime(22, 0).toInstant(brussels),
|
||||||
|
end = fallBack.atTime(6, 0).toInstant(brussels),
|
||||||
|
)
|
||||||
|
val tail = layoutDay(listOf(event), fallBack, brussels).single()
|
||||||
|
val offset = tail.clipOffsetMinutes(fallBack, brussels)
|
||||||
|
assertThat(offset).isEqualTo(2 * 60)
|
||||||
|
val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset)
|
||||||
|
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wall-clock span follows the clock change`() {
|
||||||
|
// 01:00 to 04:00 on the fall-back day is four real hours.
|
||||||
|
assertThat(wallClockSpanMinutes(fallBack, 60, 4.hours, brussels)).isEqualTo(3 * 60)
|
||||||
|
// 01:00 to 04:00 on the spring-forward day is two.
|
||||||
|
assertThat(wallClockSpanMinutes(LocalDate(2026, 3, 29), 60, 2.hours, brussels)).isEqualTo(3 * 60)
|
||||||
|
assertThat(wallClockSpanMinutes(sat, 20 * 60, 12.hours, zone)).isEqualTo(12 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a drop in the repeated fall-back hour keeps the event's offset`() {
|
||||||
|
// 02:30 CET, the second 02:30 of the day, dragged 15 minutes later.
|
||||||
|
val event = overnight.copy(
|
||||||
|
start = Instant.parse("2026-10-25T01:30:00Z"),
|
||||||
|
end = Instant.parse("2026-10-25T02:30:00Z"),
|
||||||
|
)
|
||||||
|
val block = layoutDay(listOf(event), fallBack, brussels).single()
|
||||||
|
val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0)
|
||||||
|
assertThat(drop.startInstant(brussels)).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an ambiguous time without an offset to keep takes the earlier one`() {
|
||||||
|
assertThat(wallClockInstant(fallBack, 2 * 60 + 45, brussels))
|
||||||
|
.isEqualTo(Instant.parse("2026-10-25T00:45:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a start in the spring-forward gap resolves an hour on`() {
|
||||||
|
val springForward = LocalDate(2026, 3, 29)
|
||||||
|
val landed = wallClockInstant(springForward, 2 * 60 + 30, brussels, kept = brussels.repeatedHourOffsetAt(overnight.start))
|
||||||
|
assertThat(landed.minutesPast(springForward, brussels)).isEqualTo(3 * 60 + 30)
|
||||||
|
assertThat(wallClockSpanMinutes(springForward, 3 * 60 + 30, 1.hours, brussels)).isEqualTo(60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a span ending in the repeated fall-back hour reaches that hour's end`() {
|
||||||
|
// 02:00 CEST plus one hour is 02:00 CET; 02:30 CEST plus one is 02:30 CET.
|
||||||
|
assertThat(wallClockSpanMinutes(fallBack, 2 * 60, 1.hours, brussels)).isEqualTo(60)
|
||||||
|
assertThat(wallClockSpanMinutes(fallBack, 2 * 60 + 30, 1.hours, brussels)).isEqualTo(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a drop onto the repeated fall-back hour doesn't depend on the event's season`() {
|
||||||
|
val slot = 2 * 60 + 30
|
||||||
|
val first = Instant.parse("2026-10-25T00:30:00Z")
|
||||||
|
val july = Instant.parse("2026-07-01T10:00:00Z")
|
||||||
|
val november = Instant.parse("2026-11-10T10:00:00Z")
|
||||||
|
assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(july))).isEqualTo(first)
|
||||||
|
assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(november))).isEqualTo(first)
|
||||||
|
// Later the same day, after the clocks went back: still no say.
|
||||||
|
val evening = Instant.parse("2026-10-25T18:00:00Z")
|
||||||
|
assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(evening))).isEqualTo(first)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bottom resize into the repeated hour keeps a second-pass start's pass`() {
|
||||||
|
// 02:30 CET (the second pass) to 04:00, its end pulled up to 02:45.
|
||||||
|
val event = overnight.copy(
|
||||||
|
start = Instant.parse("2026-10-25T01:30:00Z"),
|
||||||
|
end = Instant.parse("2026-10-25T03:00:00Z"),
|
||||||
|
)
|
||||||
|
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels)
|
||||||
|
assertThat(drop.start).isEqualTo(event.start)
|
||||||
|
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a resized edge keeps its own pass over the other edge's`() {
|
||||||
|
// 02:30 CEST (the first pass) to 02:15 CET (the second), the start pulled to 02:00.
|
||||||
|
val event = overnight.copy(
|
||||||
|
start = Instant.parse("2026-10-25T00:30:00Z"),
|
||||||
|
end = Instant.parse("2026-10-25T01:15:00Z"),
|
||||||
|
)
|
||||||
|
val drop = resizedDrop(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels)
|
||||||
|
assertThat(drop.start).isEqualTo(Instant.parse("2026-10-25T00:00:00Z"))
|
||||||
|
assertThat(drop.end).isEqualTo(event.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bottom resize up the repeated hour shortens the event`() {
|
||||||
|
// 02:20 CEST to 02:10 CET is drawn 02:20 to 03:00; its end pulled up to 02:50.
|
||||||
|
val event = overnight.copy(
|
||||||
|
start = Instant.parse("2026-10-25T00:20:00Z"),
|
||||||
|
end = Instant.parse("2026-10-25T01:10:00Z"),
|
||||||
|
)
|
||||||
|
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
|
||||||
|
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:50:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bottom resize to a time the grid draws once lands there`() {
|
||||||
|
// 01:00 CEST to 04:00 CET, its end pulled up to 02:15: only the first pass
|
||||||
|
// is drawn at 02:15, the second would be drawn down at 03:00.
|
||||||
|
val event = overnight.copy(
|
||||||
|
start = Instant.parse("2026-10-24T23:00:00Z"),
|
||||||
|
end = Instant.parse("2026-10-25T03:00:00Z"),
|
||||||
|
)
|
||||||
|
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
|
||||||
|
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:15:00Z"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fitting applied on top of the platform matcher. What the matcher itself
|
||||||
|
* finds is covered on a device by PhoneLinksPlatformTest.
|
||||||
|
*/
|
||||||
|
class PhoneLinksTest {
|
||||||
|
|
||||||
|
/** Refine a match the platform found at [found] in [text]. */
|
||||||
|
private fun refine(text: String, found: String): List<PhoneLink> {
|
||||||
|
val start = text.indexOf(found)
|
||||||
|
return refinePhoneMatches(
|
||||||
|
text,
|
||||||
|
listOf(PhoneMatch(start, start + found.length)),
|
||||||
|
findWebLinks(text),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a match keeps its span and dials only digits`() {
|
||||||
|
val text = "Call Anna on 0151 234-5678 before"
|
||||||
|
val link = refine(text, "0151 234-5678").single()
|
||||||
|
assertThat(link.number).isEqualTo("01512345678")
|
||||||
|
assertThat(text.substring(link.start, link.end)).isEqualTo("0151 234-5678")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an international number keeps its plus`() {
|
||||||
|
assertThat(refine("Tel. +49 30 1234567", "+49 30 1234567").single().number)
|
||||||
|
.isEqualTo("+49301234567")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the trunk zero of an international number is dropped`() {
|
||||||
|
assertThat(refine("+49 (0) 151 2345678", "+49 (0) 151 2345678").single().number)
|
||||||
|
.isEqualTo("+491512345678")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a country code stranded before the trunk zero is pulled back in`() {
|
||||||
|
val text = "Mobil: +49 (0)151 2345678"
|
||||||
|
val link = refine(text, "151 2345678").single()
|
||||||
|
assertThat(link.number).isEqualTo("+491512345678")
|
||||||
|
assertThat(text.substring(link.start, link.end)).isEqualTo("+49 (0)151 2345678")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a conference dial-in keeps its pauses and meeting id`() {
|
||||||
|
val text = "One tap mobile: +16465588656,,85012345678#,,,,*123456# (US)"
|
||||||
|
val link = refine(text, "+16465588656").single()
|
||||||
|
assertThat(link.number).isEqualTo("+16465588656,,85012345678#,,,,*123456#")
|
||||||
|
assertThat(text.substring(link.start, link.end))
|
||||||
|
.isEqualTo("+16465588656,,85012345678#,,,,*123456#")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `meeting ids and PINs are not numbers to call`() {
|
||||||
|
assertThat(refine("Meeting ID: 850 1234 5678", "850 1234 5678")).isEmpty()
|
||||||
|
assertThat(refine("Conference ID: 123 456 789#", "123 456 789")).isEmpty()
|
||||||
|
assertThat(refine("PIN: 123 456 789#", "123 456 789")).isEmpty()
|
||||||
|
assertThat(refine("Konferenz-ID: 123 456 789#", "123 456 789")).isEmpty()
|
||||||
|
assertThat(refine("Access code: 2345 678 9012", "2345 678 9012")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a label on an earlier line does not hide a number`() {
|
||||||
|
assertThat(refine("Meeting ID: 850 1234 5678\nDial +1 646 558 8656", "+1 646 558 8656"))
|
||||||
|
.hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a label earlier on the same line does not hide a later number`() {
|
||||||
|
assertThat(refine("PIN 1234, call +1 646 558 8656", "+1 646 558 8656")).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a match holding a date is not a number`() {
|
||||||
|
assertThat(refine("Am 14.10.2026 10:00 Uhr", "14.10.2026 10")).isEmpty()
|
||||||
|
assertThat(refine("2026-10-14 10:00", "2026-10-14 10")).isEmpty()
|
||||||
|
assertThat(refine("10/14/2026 9:00", "10/14/2026 9")).isEmpty()
|
||||||
|
assertThat(refine("14.10.26 - 16.10.26", "14.10.26 - 16.10.26")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `amounts are not numbers`() {
|
||||||
|
assertThat(refine("Budget 1.250.000 EUR", "1.250.000")).isEmpty()
|
||||||
|
assertThat(refine("Budget 1 250 000 EUR", "1 250 000")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an area code in threes is still a number`() {
|
||||||
|
assertThat(refine("030 123 456", "030 123 456")).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `numbers in dotted pairs are not dates`() {
|
||||||
|
assertThat(refine("01.23.45.67.89", "01.23.45.67.89").single().number)
|
||||||
|
.isEqualTo("0123456789")
|
||||||
|
assertThat(refine("555.123.4567", "555.123.4567")).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stray count after the number is trimmed`() {
|
||||||
|
val text = "Call +49 151 2345678 2 times"
|
||||||
|
val link = refine(text, "+49 151 2345678 2").single()
|
||||||
|
assertThat(link.number).isEqualTo("+491512345678")
|
||||||
|
assertThat(text.substring(link.start, link.end)).isEqualTo("+49 151 2345678")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `short trailing groups of a real number stay`() {
|
||||||
|
assertThat(refine("030 123 45 67", "030 123 45 67").single().number).isEqualTo("0301234567")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `spaced threes without a currency are a number`() {
|
||||||
|
assertThat(refine("Móvil 612 345 678", "612 345 678").single().number).isEqualTo("612345678")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a trailing pair after a short group stays`() {
|
||||||
|
val text = "Büro 0221 1234 56"
|
||||||
|
val link = refine(text, "0221 1234 56").single()
|
||||||
|
assertThat(link.number).isEqualTo("0221123456")
|
||||||
|
assertThat(text.substring(link.start, link.end)).isEqualTo("0221 1234 56")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an extension becomes a pause`() {
|
||||||
|
assertThat(refine("+1 212 555 1234 ext. 567", "+1 212 555 1234 ext. 567").single().number)
|
||||||
|
.isEqualTo("+12125551234,567")
|
||||||
|
assertThat(refine("+1 212 555 1234 x567", "+1 212 555 1234 x567").single().number)
|
||||||
|
.isEqualTo("+12125551234,567")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `postcodes and other short runs are not numbers`() {
|
||||||
|
assertThat(refine("Hauptstraße 12, 10115 Berlin", "10115")).isEmpty()
|
||||||
|
assertThat(refine("Raum 123456", "123456")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a Webex access code is not a number to call`() {
|
||||||
|
assertThat(refine("Meeting number (access code): 2732 123 4567", "2732 123 4567")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a conference number is a number to call`() {
|
||||||
|
assertThat(refine("Conference number: +49 30 1234567", "+49 30 1234567")).hasSize(1)
|
||||||
|
assertThat(refine("Konferenznummer: +49 30 1234567", "+49 30 1234567")).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a comma-separated list stays separate numbers`() {
|
||||||
|
val text = "Tel 030 1234567,0151 2345678"
|
||||||
|
val first = text.indexOf("030")
|
||||||
|
val second = text.indexOf("0151")
|
||||||
|
val links = refinePhoneMatches(
|
||||||
|
text,
|
||||||
|
listOf(PhoneMatch(first, first + 11), PhoneMatch(second, text.length)),
|
||||||
|
emptyList(),
|
||||||
|
)
|
||||||
|
assertThat(links.map { it.number }).containsExactly("0301234567", "01512345678").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a single-comma suffix closed by a hash is a dial-in`() {
|
||||||
|
assertThat(refine("+1 555 123 4567,123456#", "+1 555 123 4567").single().number)
|
||||||
|
.isEqualTo("+15551234567,123456#")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `labels behind invisible spacing still hide an id`() {
|
||||||
|
assertThat(refine("PIN: \u202A123 456 789\u202C#", "123 456 789")).isEmpty()
|
||||||
|
assertThat(refine("Meeting ID:\u00A0850 1234 5678", "850 1234 5678")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dotted times of day are not numbers`() {
|
||||||
|
assertThat(refine("Beginn 9.30 – 12.00 Uhr", "9.30 – 12.00")).isEmpty()
|
||||||
|
assertThat(refine("10.00-11.30", "10.00-11.30")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a trailing digit with no word after it stays`() {
|
||||||
|
assertThat(refine("Zentrale 030 123456 0", "030 123456 0").single().number)
|
||||||
|
.isEqualTo("0301234560")
|
||||||
|
assertThat(refine("0221 9876543 21\nnext line", "0221 9876543 21").single().number)
|
||||||
|
.isEqualTo("0221987654321")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `digits in a URL without its scheme are not a number`() {
|
||||||
|
assertThat(refine("zoom.us/j/85012345678", "85012345678")).isEmpty()
|
||||||
|
assertThat(refine("room?id=0301234567", "0301234567")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dotted threes without a currency are a number`() {
|
||||||
|
assertThat(refine("Móvil 612.345.678", "612.345.678")).hasSize(1)
|
||||||
|
assertThat(refine("Budget 125.000.000 €", "125.000.000")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `digits inside a web link are not a number`() {
|
||||||
|
assertThat(refine("https://x.example/030-1234567", "030-1234567")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a number beside a web link is still found`() {
|
||||||
|
assertThat(refine("https://zoom.us/j/85012345678 or dial +1 646 558 8656", "+1 646 558 8656"))
|
||||||
|
.hasSize(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class WebLinksTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a plain address has no links`() {
|
||||||
|
assertThat(findWebLinks("Office B, 12 Main St.")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bare meeting link spans the whole text`() {
|
||||||
|
val text = "https://meet.google.com/abc-defg-hij"
|
||||||
|
assertThat(findWebLinks(text)).containsExactly(WebLink(0, text.length, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a capitalised scheme is matched and lower-cased`() {
|
||||||
|
val links = findWebLinks("Https://zoom.us/j/123")
|
||||||
|
assertThat(links.single().url).isEqualTo("https://zoom.us/j/123")
|
||||||
|
assertThat(links.single().end).isEqualTo(21)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `angle brackets around a link stay outside it`() {
|
||||||
|
val text = "Microsoft Teams Meeting <https://teams.microsoft.com/l/meetup-join/x>"
|
||||||
|
val link = findWebLinks(text).single()
|
||||||
|
assertThat(link.url).isEqualTo("https://teams.microsoft.com/l/meetup-join/x")
|
||||||
|
assertThat(text.substring(link.start, link.end)).isEqualTo(link.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `trailing punctuation is left out of the link`() {
|
||||||
|
val link = findWebLinks("Office B (https://office.example.com).").single()
|
||||||
|
assertThat(link.url).isEqualTo("https://office.example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every link in the text is found`() {
|
||||||
|
val links = findWebLinks("Room 3 / https://zoom.us/j/1 / https://meet.google.com/x")
|
||||||
|
assertThat(links.map { it.url })
|
||||||
|
.containsExactly("https://zoom.us/j/1", "https://meet.google.com/x")
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `other schemes and a bare scheme are not links`() {
|
||||||
|
assertThat(findWebLinks("ftp://example.com mailto:a@b.c https://")).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.edit
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
|
import android.content.ContextWrapper
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
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.data.time.FakeZoneSource
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
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.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||||
|
import de.jeanlucmakiola.calendula.domain.startInstant
|
||||||
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
|
import de.jeanlucmakiola.calendula.domain.endInstant
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -23,9 +31,11 @@ import kotlinx.coroutines.test.resetMain
|
|||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import kotlinx.datetime.LocalDate
|
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 org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -59,11 +69,16 @@ class EventEditViewModelTest {
|
|||||||
syncsEvents = syncsEvents,
|
syncsEvents = syncsEvents,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
|
private fun detail(
|
||||||
|
calendarId: Long,
|
||||||
|
rrule: String? = null,
|
||||||
|
start: Instant = Instant.fromEpochMilliseconds(beginMillis),
|
||||||
|
end: Instant = Instant.fromEpochMilliseconds(endMillis),
|
||||||
|
): EventDetail = EventDetail(
|
||||||
instance = EventInstance(
|
instance = EventInstance(
|
||||||
instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
|
instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
|
||||||
start = Instant.fromEpochMilliseconds(beginMillis),
|
start = start,
|
||||||
end = Instant.fromEpochMilliseconds(endMillis),
|
end = end,
|
||||||
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
||||||
),
|
),
|
||||||
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
|
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
|
||||||
@@ -92,10 +107,12 @@ class EventEditViewModelTest {
|
|||||||
// Passed in by the tests that need to seed a setting first; a second
|
// Passed in by the tests that need to seed a setting first; a second
|
||||||
// DataStore on the same file would clash, so it is built only once.
|
// DataStore on the same file would clash, so it is built only once.
|
||||||
s: SettingsPrefs = settings(tempDir),
|
s: SettingsPrefs = settings(tempDir),
|
||||||
|
zones: FakeZoneSource = FakeZoneSource(),
|
||||||
): EventEditViewModel {
|
): EventEditViewModel {
|
||||||
val p = prefs(tempDir)
|
val p = prefs(tempDir)
|
||||||
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
|
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
|
||||||
return EventEditViewModel(repo, p, s, dispatcher)
|
// Only `shareUri()` touches the exporter, and nothing here shares.
|
||||||
|
return EventEditViewModel(repo, p, s, IcsExporter(ContextWrapper(null)), dispatcher, zones)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
|
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
|
||||||
@@ -221,12 +238,12 @@ class EventEditViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Calendar 1 has no length of its own, so it inherits the global 30 min.
|
// Calendar 1 has no length of its own, so it inherits the global 30 min.
|
||||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
|
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
|
||||||
|
|
||||||
vm.setCalendar(2L)
|
vm.setCalendar(2L)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
|
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +263,7 @@ class EventEditViewModelTest {
|
|||||||
vm.setCalendar(2L)
|
vm.setCalendar(2L)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
|
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +284,7 @@ class EventEditViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// The file owns the length; a calendar switch must not restretch it.
|
// The file owns the length; a calendar switch must not restretch it.
|
||||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
|
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,15 +305,15 @@ class EventEditViewModelTest {
|
|||||||
)
|
)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
|
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importedForm(end: LocalDateTime) = EventForm(
|
private fun importedForm(end: LocalDateTime) = EventForm(
|
||||||
calendarId = null,
|
calendarId = null,
|
||||||
title = "Imported",
|
title = "Imported",
|
||||||
start = LocalDateTime(2030, 1, 15, 9, 0),
|
start = FormTime(LocalDateTime(2030, 1, 15, 9, 0)),
|
||||||
end = end,
|
end = FormTime(end),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -316,7 +333,7 @@ class EventEditViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Dates carry no length: the times stay where the form put them.
|
// Dates carry no length: the times stay where the form put them.
|
||||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
|
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,4 +424,238 @@ class EventEditViewModelTest {
|
|||||||
|
|
||||||
assertThat(capLength(new, "")).isEqualTo("a".repeat(254))
|
assertThat(capLength(new, "")).isEqualTo("a".repeat(254))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an edit counts as unsaved only while it differs from the loaded event`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L))
|
||||||
|
eventDetailResult = { detail(calendarId = 1L) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.hasUnsavedChanges.value).isFalse()
|
||||||
|
|
||||||
|
vm.setTitle("Retro")
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.hasUnsavedChanges.value).isTrue()
|
||||||
|
|
||||||
|
vm.setTitle("Standup")
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.hasUnsavedChanges.value).isFalse()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a new form holding only its defaults has nothing to lose`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.openNew(LocalDate(2026, 10, 12), startMinutes = 9 * 60)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.hasUnsavedChanges.value).isFalse()
|
||||||
|
|
||||||
|
vm.setTitle("Lunch")
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.hasUnsavedChanges.value).isTrue()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleting from the form removes the event and reports it`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L))
|
||||||
|
eventDetailResult = { detail(calendarId = 1L) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.delete(RecurringWriteScope.AllEvents)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(fake.deletedEventIds).containsExactly(42L)
|
||||||
|
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Deleted)
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleting an event that is already gone counts as deleted`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L))
|
||||||
|
eventDetailResult = { detail(calendarId = 1L) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||||
|
advanceUntilIdle()
|
||||||
|
fake.writeError = NoSuchEventException(42L)
|
||||||
|
vm.delete(RecurringWriteScope.AllEvents)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Deleted)
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a delete without write access reports it as a delete`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L))
|
||||||
|
eventDetailResult = { detail(calendarId = 1L) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||||
|
advanceUntilIdle()
|
||||||
|
fake.writeError = SecurityException()
|
||||||
|
vm.delete(RecurringWriteScope.AllEvents)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.DeleteDenied)
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a blank rrule does not make the edited event recurring`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L))
|
||||||
|
eventDetailResult = { detail(calendarId = 1L, rrule = "") }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.editedEventRecurs()).isFalse()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `accepting the default reminders for an import counts as a change`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
|
||||||
|
val s = settings(tempDir)
|
||||||
|
s.setDefaultReminderMinutes(listOf(15))
|
||||||
|
val vm = viewModel(tempDir, fake, s)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.openImported(importedForm(end = LocalDateTime(2030, 1, 15, 10, 0)), ImportSource.File)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.importReminderPrompt.value).isNotNull()
|
||||||
|
assertThat(vm.hasUnsavedChanges.value).isFalse()
|
||||||
|
|
||||||
|
vm.applyImportedReminderDefault()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.hasUnsavedChanges.value).isTrue()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a drafted block opens at its own instants across the fall-back hour`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
|
||||||
|
val vm = viewModel(tempDir, fake, zones = FakeZoneSource(berlin))
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
// An hour from 02:30 CEST ends at 02:30 CET, the same wall clock.
|
||||||
|
vm.openDraft(Instant.parse("2026-10-25T00:30:00Z"), Instant.parse("2026-10-25T01:30:00Z"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val form = vm.state.value!!.form!!
|
||||||
|
assertThat(form.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
|
||||||
|
assertThat(form.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
|
||||||
|
assertThat(form.problems(berlin)).isEmpty()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a drafted block on the second pass stays on it at its length`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
|
||||||
|
val s = settings(tempDir)
|
||||||
|
s.setCalendarEventDuration(1L, 480)
|
||||||
|
val vm = viewModel(tempDir, fake, s, FakeZoneSource(berlin))
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
// 02:30 CET to 03:00 CET: the default length mustn't stretch it.
|
||||||
|
vm.openDraft(Instant.parse("2026-10-25T01:30:00Z"), Instant.parse("2026-10-25T02:00:00Z"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val form = vm.state.value!!.form!!
|
||||||
|
assertThat(form.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
|
||||||
|
assertThat(form.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T02:00:00Z"))
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a time picked in the repeated hour and back keeps the opened pass`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
// Opened at 02:30 CET (the second pass) to 03:30, its start moved away and back.
|
||||||
|
val begin = Instant.parse("2026-10-25T01:30:00Z")
|
||||||
|
val end = Instant.parse("2026-10-25T02:30:00Z")
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L))
|
||||||
|
eventDetailResult = { detail(calendarId = 1L, start = begin, end = end) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake, zones = FakeZoneSource(berlin))
|
||||||
|
val job = activate(vm)
|
||||||
|
vm.openForEdit(eventId = 1L, beginMillis = begin.toEpochMilliseconds(), endMillis = end.toEpochMilliseconds())
|
||||||
|
advanceUntilIdle()
|
||||||
|
val opened = vm.state.value!!.form!!
|
||||||
|
|
||||||
|
vm.setStartTime(LocalTime(4, 0))
|
||||||
|
vm.setStartTime(LocalTime(2, 30))
|
||||||
|
|
||||||
|
assertThat(vm.state.value!!.form).isEqualTo(opened)
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val berlin = TimeZone.of("Europe/Berlin")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day on and off keeps a second-pass event where it was`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val begin = Instant.parse("2026-10-25T01:30:00Z")
|
||||||
|
val end = Instant.parse("2026-10-25T02:30:00Z")
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L))
|
||||||
|
eventDetailResult = { detail(calendarId = 1L, start = begin, end = end) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake, zones = FakeZoneSource(berlin))
|
||||||
|
val job = activate(vm)
|
||||||
|
vm.openForEdit(eventId = 1L, beginMillis = begin.toEpochMilliseconds(), endMillis = end.toEpochMilliseconds())
|
||||||
|
advanceUntilIdle()
|
||||||
|
val opened = vm.state.value!!.form!!
|
||||||
|
|
||||||
|
vm.setAllDay(true)
|
||||||
|
vm.setAllDay(false)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.state.value!!.form).isEqualTo(opened)
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,5 +190,121 @@ class WeekLayoutTest {
|
|||||||
assertThat(spans.all { it.lane == 0 }).isTrue()
|
assertThat(spans.all { it.lane == 0 }).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Europe/Brussels falls back on 2026-10-25 (25 h) and springs forward on 2026-03-29 (23 h).
|
||||||
|
private val brussels = TimeZone.of("Europe/Brussels")
|
||||||
|
|
||||||
|
private fun brusselsEvent(date: LocalDate, from: Pair<Int, Int>, to: Pair<Int, Int>) = event(
|
||||||
|
date.atTime(from.first, from.second).toInstant(brussels),
|
||||||
|
date.atTime(to.first, to.second).toInstant(brussels),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed block sits at its wall-clock time on the fall-back day`() {
|
||||||
|
val day = LocalDate(2026, 10, 25)
|
||||||
|
// The event from #387: 13:45Z is 14:45 CET.
|
||||||
|
val ev = event(Instant.parse("2026-10-25T13:45:00Z"), Instant.parse("2026-10-25T15:45:00Z"))
|
||||||
|
val b = layoutDay(listOf(ev), day, brussels).single()
|
||||||
|
assertThat(b.startMin).isEqualTo(14 * 60 + 45)
|
||||||
|
assertThat(b.endMin).isEqualTo(16 * 60 + 45)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed block sits at its wall-clock time on the spring-forward day`() {
|
||||||
|
val day = LocalDate(2026, 3, 29)
|
||||||
|
val b = layoutDay(listOf(brusselsEvent(day, 14 to 45, 16 to 45)), day, brussels).single()
|
||||||
|
assertThat(b.startMin).isEqualTo(14 * 60 + 45)
|
||||||
|
assertThat(b.endMin).isEqualTo(16 * 60 + 45)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `late block on the fall-back day is not cut short`() {
|
||||||
|
val day = LocalDate(2026, 10, 25)
|
||||||
|
val b = layoutDay(listOf(brusselsEvent(day, 22 to 30, 23 to 59)), day, brussels).single()
|
||||||
|
assertThat(b.startMin).isEqualTo(22 * 60 + 30)
|
||||||
|
assertThat(b.endMin).isEqualTo(23 * 60 + 59)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `block running past midnight on the fall-back day reaches the day's end`() {
|
||||||
|
val day = LocalDate(2026, 10, 25)
|
||||||
|
val ev = event(
|
||||||
|
day.atTime(23, 0).toInstant(brussels),
|
||||||
|
day.plusDays(1).atTime(1, 0).toInstant(brussels),
|
||||||
|
)
|
||||||
|
val b = layoutDay(listOf(ev), day, brussels).single()
|
||||||
|
assertThat(b.startMin).isEqualTo(23 * 60)
|
||||||
|
assertThat(b.endMin).isEqualTo(MINUTES_PER_DAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instant event on the fall-back day sits at its wall-clock time`() {
|
||||||
|
val day = LocalDate(2026, 10, 25)
|
||||||
|
val at = day.atTime(9, 0).toInstant(brussels)
|
||||||
|
val b = layoutDay(listOf(event(at, at)), day, brussels).single()
|
||||||
|
assertThat(b.startMin).isEqualTo(9 * 60)
|
||||||
|
assertThat(b.endMin).isEqualTo(9 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `block ending in the repeated fall-back hour is drawn to that hour's end`() {
|
||||||
|
val day = LocalDate(2026, 10, 25)
|
||||||
|
// 02:00 CEST to 02:00 CET: one real hour, the same wall-clock time at both ends.
|
||||||
|
val ev = event(Instant.parse("2026-10-25T00:00:00Z"), Instant.parse("2026-10-25T01:00:00Z"))
|
||||||
|
val b = layoutDay(listOf(ev), day, brussels).single()
|
||||||
|
assertThat(b.startMin).isEqualTo(2 * 60)
|
||||||
|
assertThat(b.endMin).isEqualTo(3 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `block ending in the repeated fall-back hour leaves the next hour free`() {
|
||||||
|
val day = LocalDate(2026, 10, 25)
|
||||||
|
// 02:15 CEST to 02:15 CET, then 03:00 CET: an hour apart in real time.
|
||||||
|
val repeat = event(Instant.parse("2026-10-25T00:15:00Z"), Instant.parse("2026-10-25T01:15:00Z"))
|
||||||
|
val next = event(Instant.parse("2026-10-25T02:00:00Z"), Instant.parse("2026-10-25T03:00:00Z"))
|
||||||
|
val blocks = layoutDay(listOf(repeat, next), day, brussels)
|
||||||
|
assertThat(blocks.map { it.startMin to it.endMin })
|
||||||
|
.containsExactly(2 * 60 + 15 to 3 * 60, 3 * 60 to 4 * 60)
|
||||||
|
assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `minutesPast reads wall clock either side of the day`() {
|
||||||
|
val day = LocalDate(2026, 10, 25)
|
||||||
|
assertThat(Instant.parse("2026-10-24T21:00:00Z").minutesPast(day, brussels)).isEqualTo(-60)
|
||||||
|
assertThat(Instant.parse("2026-10-25T13:45:00Z").minutesPast(day, brussels)).isEqualTo(14 * 60 + 45)
|
||||||
|
assertThat(Instant.parse("2026-10-25T23:30:00Z").minutesPast(day, brussels))
|
||||||
|
.isEqualTo(MINUTES_PER_DAY + 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a block ending in the repeated fall-back hour is labelled with its own end`() {
|
||||||
|
val day = LocalDate(2026, 10, 25)
|
||||||
|
// 02:30 CEST to 02:30 CET: drawn to the repeated hour's end, but it ends at 02:30.
|
||||||
|
val ev = event(Instant.parse("2026-10-25T00:30:00Z"), Instant.parse("2026-10-25T01:30:00Z"))
|
||||||
|
val b = layoutDay(listOf(ev), day, brussels).single()
|
||||||
|
assertThat(b.endMin).isEqualTo(3 * 60)
|
||||||
|
assertThat(b.labelEndMin(day, brussels)).isEqualTo(2 * 60 + 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an ordinary block is labelled with its drawn end`() {
|
||||||
|
val day = LocalDate(2026, 10, 25)
|
||||||
|
val b = layoutDay(listOf(brusselsEvent(day, 14 to 45, 16 to 45)), day, brussels).single()
|
||||||
|
assertThat(b.labelEndMin(day, brussels)).isEqualTo(b.endMin)
|
||||||
|
}
|
||||||
|
|
||||||
private fun LocalDate.plusDays(n: Int): LocalDate = plus(n, DateTimeUnit.DAY)
|
private fun LocalDate.plusDays(n: Int): LocalDate = plus(n, DateTimeUnit.DAY)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a longer run into the repeated fall-back hour is drawn no shorter`() {
|
||||||
|
val day = LocalDate(2026, 10, 25)
|
||||||
|
// 02:10 CEST to 02:05 CET (55 min) and to 02:20 CET (70 min): both to 03:00.
|
||||||
|
val shorter = event(Instant.parse("2026-10-25T00:10:00Z"), Instant.parse("2026-10-25T01:05:00Z"))
|
||||||
|
val longer = event(Instant.parse("2026-10-25T00:10:00Z"), Instant.parse("2026-10-25T01:20:00Z"), id = 2L)
|
||||||
|
assertThat(layoutDay(listOf(shorter), day, brussels).single().endMin).isEqualTo(3 * 60)
|
||||||
|
assertThat(layoutDay(listOf(longer), day, brussels).single().endMin).isEqualTo(3 * 60)
|
||||||
|
// A run that starts on the second pass reads its plain wall clock.
|
||||||
|
val second = event(Instant.parse("2026-10-25T01:10:00Z"), Instant.parse("2026-10-25T01:40:00Z"))
|
||||||
|
assertThat(layoutDay(listOf(second), day, brussels).single().endMin).isEqualTo(2 * 60 + 40)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ the package list (recurring writes, save conflicts, reminder delivery).
|
|||||||
straight back to it. Sync is DAVx5's / Google's / the system's job.
|
straight back to it. Sync is DAVx5's / Google's / the system's job.
|
||||||
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
|
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
|
||||||
re-queries; every screen recomposes from fresh provider state. After a
|
re-queries; every screen recomposes from fresh provider state. After a
|
||||||
write, nothing is patched by hand — the provider notifies, the views
|
write, nothing is patched by hand — the views refresh from the provider.
|
||||||
refresh. This also covers external changes (sync) for free.
|
This also covers external changes (sync) for free. Some providers don't
|
||||||
|
reliably notify (#364), so the repository also re-queries after each of
|
||||||
|
its own writes, and `MainActivity` does on every return to the foreground.
|
||||||
3. **JVM-first testing.** Everything between the UI and the
|
3. **JVM-first testing.** Everything between the UI and the
|
||||||
`ContentResolver` is shaped so it runs as a plain JUnit 5 test: pure
|
`ContentResolver` is shaped so it runs as a plain JUnit 5 test: pure
|
||||||
domain logic, cursor-free mappers, a `FakeCalendarDataSource` for
|
domain logic, cursor-free mappers, a `FakeCalendarDataSource` for
|
||||||
@@ -117,7 +119,11 @@ gesture finishes from its scaled preview.
|
|||||||
switch only changes what they say.
|
switch only changes what they say.
|
||||||
A tapped reminder notification routes through `MainActivity` (`singleTop` +
|
A tapped reminder notification routes through `MainActivity` (`singleTop` +
|
||||||
`onNewIntent`) as an external detail key that `CalendarHost` consumes
|
`onNewIntent`) as an external detail key that `CalendarHost` consumes
|
||||||
exactly like an event tap.
|
like an event tap. With the "tapping an event opens" setting on the edit form
|
||||||
|
(#178), a tap in the views or search opens the edit overlay directly for events
|
||||||
|
in writable calendars; notifications and widgets always open the detail. The
|
||||||
|
edit form guards its own back: unsaved changes ask to save, discard or keep
|
||||||
|
editing.
|
||||||
|
|
||||||
### First-launch wizard
|
### First-launch wizard
|
||||||
|
|
||||||
|
|||||||
+75
-7
@@ -5,34 +5,56 @@ built, signed, and published automatically by `.gitea/workflows/release.yaml`
|
|||||||
when a **bumped `versionName` reaches `main`** — the pipeline then creates the
|
when a **bumped `versionName` reaches `main`** — the pipeline then creates the
|
||||||
matching `vX.Y.Z` tag and Gitea release itself.
|
matching `vX.Y.Z` tag and Gitea release itself.
|
||||||
|
|
||||||
|
Before a stable release you can ship **betas** (`X.Y.Z-beta.N`) from the
|
||||||
|
release branch. They go to **Codeberg only**, flagged as pre-releases; F-Droid
|
||||||
|
and Play never see them. See [Cutting a beta](#cutting-a-beta).
|
||||||
|
|
||||||
## Versioning — the committed version is the source of truth
|
## Versioning — the committed version is the source of truth
|
||||||
|
|
||||||
A release is defined by the `versionName`/`versionCode` committed in
|
A release is defined by the `versionName`/`versionCode` committed in
|
||||||
`app/build.gradle.kts`:
|
`app/build.gradle.kts`:
|
||||||
|
|
||||||
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `2.1.0`)
|
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `2.22.4`), or
|
||||||
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`2.1.0` → `20100`)
|
`MAJOR.MINOR.PATCH-beta.N` for a beta (e.g. `2.23.0-beta.2`)
|
||||||
|
- `versionCode` is derived from it by `scripts/version_info.sh`:
|
||||||
|
|
||||||
So `MINOR` and `PATCH` each have room for 0–99. The release pipeline reads
|
| `versionName` | `versionCode` | Example |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `X.Y.Z` up to 2.22.3 (legacy) | `X*10000 + Y*100 + Z` | `2.22.3` → `22203` |
|
||||||
|
| `X.Y.Z-beta.N` (N = 1–98) | `X*1000000 + Y*10000 + Z*100 + N` | `2.23.0-beta.2` → `2230002` |
|
||||||
|
| `X.Y.Z` from 2.22.4 | `X*1000000 + Y*10000 + Z*100 + 99` | `2.22.4` → `2220499` |
|
||||||
|
|
||||||
|
A beta's code sits below its own stable release and above everything before
|
||||||
|
it, so a beta install updates in place to the next beta and then to the stable
|
||||||
|
version. Every version up to 2.22.3 keeps the code it already shipped with, and
|
||||||
|
`2.22.4` is the first on the new scheme. `MINOR` and `PATCH` each have room for
|
||||||
|
0–99. Run `scripts/version_info.sh` to see what the committed version resolves
|
||||||
|
to; CI fails a PR whose committed `versionCode` doesn't match, because the
|
||||||
|
official F-Droid repo builds the tag exactly as committed.
|
||||||
|
|
||||||
|
The release pipeline reads
|
||||||
`versionName`, pins `versionCode` to the derived value, builds, and — once the
|
`versionName`, pins `versionCode` to the derived value, builds, and — once the
|
||||||
APK is published — creates the tag `v<versionName>` at that commit. The tag is
|
APK is published — creates the tag `v<versionName>` at that commit. The tag is
|
||||||
an **output** of a successful release, not its trigger, so a tag always marks a
|
an **output** of a successful release, not its trigger, so a tag always marks a
|
||||||
fully-shipped version (and a failure before publish leaves no tag, so re-running
|
fully-shipped version (and a failure before publish leaves no tag, so re-running
|
||||||
the workflow safely retries).
|
the workflow safely retries).
|
||||||
|
|
||||||
Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`→20000.
|
Published version codes so far: `v0.1.0`→100 … `v2.0.0`→20000 …
|
||||||
|
`v2.22.3`→22203, then `v2.22.4`→2220499.
|
||||||
|
|
||||||
## Cutting a release
|
## Cutting a release
|
||||||
|
|
||||||
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
|
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
|
||||||
feature/fix branches that make up this release into it. This branch is the
|
feature/fix branches that make up this release into it. This branch is the
|
||||||
release candidate — everything below happens on it, before it reaches `main`.
|
release candidate — everything below happens on it, before it reaches `main`.
|
||||||
|
To ship betas first, follow [Cutting a beta](#cutting-a-beta) from here and
|
||||||
|
come back to step 2 when going stable.
|
||||||
2. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new
|
2. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new
|
||||||
`## [X.Y.Z] — <date>` heading (Keep a Changelog format). The text between
|
`## [X.Y.Z] — <date>` heading (Keep a Changelog format). The text between
|
||||||
that heading and the next `## [` becomes both the Gitea release notes and
|
that heading and the next `## [` becomes both the Gitea release notes and
|
||||||
the F-Droid per-version changelog.
|
the F-Droid per-version changelog.
|
||||||
3. Bump the committed `versionName` (and `versionCode`) in
|
3. Bump the committed `versionName` (and `versionCode`, which
|
||||||
`app/build.gradle.kts` to the new version. **This bump is what triggers the
|
`scripts/version_info.sh` prints) in `app/build.gradle.kts` to the new version. **This bump is what triggers the
|
||||||
release** when the branch merges to `main`. Then write the per-version
|
release** when the branch merges to `main`. Then write the per-version
|
||||||
"What's New" by hand to
|
"What's New" by hand to
|
||||||
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` and commit it.
|
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` and commit it.
|
||||||
@@ -89,6 +111,48 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
|
|||||||
> The `releaseTest` build type exists only for step 4 — it is never published.
|
> The `releaseTest` build type exists only for step 4 — it is never published.
|
||||||
> The pipeline always builds and signs the real `release` variant.
|
> The pipeline always builds and signs the real `release` variant.
|
||||||
|
|
||||||
|
## Cutting a beta
|
||||||
|
|
||||||
|
A beta is a test build of the next version, published as a **pre-release on
|
||||||
|
Codeberg** and nowhere else. It is signed with the real app key, so testers
|
||||||
|
install it over their stable install and it updates in place to later betas
|
||||||
|
and to the stable release.
|
||||||
|
|
||||||
|
1. **On `release/vX.Y.Z`**, with the features merged in, set
|
||||||
|
`versionName = "X.Y.Z-beta.1"` and the matching `versionCode`
|
||||||
|
(`scripts/version_info.sh` prints it; `2.23.0-beta.1` → `2230001`).
|
||||||
|
No What's New file — betas don't ship to the stores. Notes come from a
|
||||||
|
`## [X.Y.Z-beta.N]` section of `CHANGELOG.md` if you write one, otherwise
|
||||||
|
from `## [Unreleased]`. So keep the entries under `## [Unreleased]` while
|
||||||
|
betas are going out and only move them to `## [X.Y.Z]` when going stable:
|
||||||
|
once they're moved, a beta's notes come out empty.
|
||||||
|
2. **Optionally verify it on a device** with `scripts/verify-release.sh`, as for
|
||||||
|
a stable release.
|
||||||
|
3. **Push the branch to Codeberg.** When it reaches Gitea, `beta.yaml` sees a
|
||||||
|
beta whose Codeberg pre-release doesn't carry its APK yet, runs the unit
|
||||||
|
tests, builds and signs the APK, creates the `vX.Y.Z-beta.1` tag + a Gitea
|
||||||
|
pre-release (with the R8 mapping) and publishes the **Codeberg
|
||||||
|
pre-release** with the APK + `.sha256`. If that publish fails part-way, the
|
||||||
|
next push of the branch redoes it.
|
||||||
|
4. **Next round:** bump to `-beta.2` (and its `versionCode`) and push again.
|
||||||
|
5. **Going stable:** set `versionName = "X.Y.Z"` and its `versionCode`
|
||||||
|
(`2.23.0` → `2230099`), then continue from step 2 of
|
||||||
|
[Cutting a release](#cutting-a-release).
|
||||||
|
|
||||||
|
Who gets a beta:
|
||||||
|
|
||||||
|
- **Obtainium** users only with *Include prereleases* switched on for the app.
|
||||||
|
That is how a tester opts in; everyone else stays on stable.
|
||||||
|
- **F-Droid** (self-hosted and official) never: `beta.yaml` doesn't touch the
|
||||||
|
self-hosted repo, and the official recipe's `UpdateCheckMode: Tags ^v[0-9.]+$`
|
||||||
|
ignores `-beta` tags. Keep that pattern if the recipe ever changes.
|
||||||
|
- **Play** never.
|
||||||
|
|
||||||
|
Guards: a beta version can't reach `main` (CI fails the PR, and `release.yaml`'s
|
||||||
|
`detect` refuses one as a backstop), `beta.yaml` refuses a beta that isn't
|
||||||
|
newer than the latest stable release, and betas start at 2.22.4 (the legacy
|
||||||
|
codes have no room below them).
|
||||||
|
|
||||||
## What the pipeline does
|
## What the pipeline does
|
||||||
|
|
||||||
CI and release are split so a change is built once on its PR and only does
|
CI and release are split so a change is built once on its PR and only does
|
||||||
@@ -108,6 +172,10 @@ release work when a merge actually cuts a release:
|
|||||||
mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
|
mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
|
||||||
(both best-effort). Ordinary merges with no version bump fall through `detect`
|
(both best-effort). Ordinary merges with no version bump fall through `detect`
|
||||||
and do nothing.
|
and do nothing.
|
||||||
|
- **`beta.yaml`** (on push to `release/**`) — when the committed `versionName`
|
||||||
|
is a beta with no tag yet: unit tests, build & sign with the app key, Gitea
|
||||||
|
pre-release with the R8 mapping, Codeberg pre-release with the APK +
|
||||||
|
`.sha256`. Nothing else; see [Cutting a beta](#cutting-a-beta).
|
||||||
- **`play` job** (same workflow, after `release`) — uploads the App Bundle to
|
- **`play` job** (same workflow, after `release`) — uploads the App Bundle to
|
||||||
Google Play. Runs last and separately so a Play rejection can't endanger a
|
Google Play. Runs last and separately so a Play rejection can't endanger a
|
||||||
release that already shipped; skips cleanly until Play is configured.
|
release that already shipped; skips cleanly until Play is configured.
|
||||||
@@ -184,7 +252,7 @@ on every pull request:
|
|||||||
| `images/icon.png` | 512×512, 32-bit PNG |
|
| `images/icon.png` | 512×512, 32-bit PNG |
|
||||||
| `images/featureGraphic.png` | 1024×500, JPEG or 24-bit PNG |
|
| `images/featureGraphic.png` | 1024×500, JPEG or 24-bit PNG |
|
||||||
| `images/*Screenshots/*` | JPEG or 24-bit PNG (no alpha), sides 320–3840, long edge ≤ 2× short, 2–8 per type |
|
| `images/*Screenshots/*` | JPEG or 24-bit PNG (no alpha), sides 320–3840, long edge ≤ 2× short, 2–8 per type |
|
||||||
| locale directories | exactly the ones in `fastlane/store-locales.txt`, which maps every app `values-*` to a Play locale code |
|
| locale directories | exactly the ones in `fastlane/store-locales.txt`, which maps every app `values-*` to a Play locale code; the code must be one Play accepts (`nl-NL`, not `nl`) |
|
||||||
|
|
||||||
A locale without its own images falls back to en-US in both stores, so graphics
|
A locale without its own images falls back to en-US in both stores, so graphics
|
||||||
only need to exist there. Adding an app language means adding its line to
|
only need to exist there. Adding an app language means adding its line to
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
إصلاحات
|
||||||
|
• النقر على رابط في موقع الحدث يفتحه الآن في المتصفح بدلًا من تطبيق الخرائط. النقر على العنوان ما زال يفتح الخرائط.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
لم يصل الإصداران 2.22.0 و2.22.1 إلى Google Play، لذا هذا أول إصدار 2.22 هناك. التطبيق مطابق للإصدار 2.22.1.
|
||||||
|
|
||||||
|
• يتنقّل اليوم والأسبوع والشهر بمُنزلق يتبع إصبعك.
|
||||||
|
• يعرض العرض متعدد الأيام عدداً قابلاً للتخصيص من الأيام جنباً إلى جنب.
|
||||||
|
• الساعات المرئية: يملأ نطاق ساعات تختاره الشاشة.
|
||||||
|
• يبرز اليوم في شبكة الشهر.
|
||||||
|
• يُفتح الرابط في موقع الحدث في المتصفح.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
عاد اسم صفحة المتجر إلى "Calendula: التقويم". لم يتغير شيء في التطبيق.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
إصلاحات
|
||||||
|
• الأحداث المعدّلة أو المنقولة أو المحذوفة تظهر الآن فورًا على كل الهواتف. على بعض الهواتف، ومنها سامسونج، كان الحدث المعدّل يبقى كما هو حتى إعادة تشغيل التطبيق.
|
||||||
|
• أدوات الشاشة الرئيسية تحمّل الآن الأحداث التي تعرضها فقط. هذا يُفترض أن يساعد الأدوات التي كانت تبقى على «جارٍ التحميل» في الهواتف الأبطأ.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
إصلاحات
|
||||||
|
• عند فتح التطبيق في صباح اليوم التالي يظهر اليوم الصحيح. كان عرض الشهر يعلّم الأمس على أنه اليوم ويُبقي اليوم الذي اخترته في المساء السابق محددًا. الآن تنتقل كل العروض إلى اليوم الجديد.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: التقويم
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Выпраўлена
|
||||||
|
• Націск на спасылку ў месцы падзеі адкрывае яе ў браўзеры, а не ў праграме карт. Націск на адрас па-ранейшаму адкрывае карты.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Вэрсіі 2.22.0 і 2.22.1 не трапілі ў Google Play, таму гэта першая вэрсія 2.22 там. Праграма такая ж, як 2.22.1.
|
||||||
|
|
||||||
|
• Дзень, Тыдзень і Месяц перагортваюцца пэйджарам, які сочыць за пальцам.
|
||||||
|
• Шматдзённы выгляд паказвае наладжвальную колькасць дзён побач.
|
||||||
|
• Бачныя гадзіны: абраны дыяпазон гадзін запаўняе экран.
|
||||||
|
• Сёньня вылучаецца ў сетцы месяца.
|
||||||
|
• Спасылка ў месцы падзеі адкрываецца ў браўзеры.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user