Compare commits
63 Commits
v0.5.0
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
| b5043a5d6e | |||
| 81baadfaf3 | |||
| 35022267dc | |||
| 588e024036 | |||
| eeef089e4a | |||
| 9023899ddb | |||
| 2f153fef56 | |||
| 290a905f8b | |||
| d20d446cbe | |||
| 6e14d5964b | |||
| 3dfc96718c | |||
| e1c2e9f2e5 | |||
| 90b219bdad | |||
| 233a9b03a3 | |||
| 0b683d374f | |||
| 64d0a89b28 | |||
| 7285e274df | |||
| 788ca3906e | |||
| bab6fd175a | |||
| 3d5cc55ef1 | |||
| 111b3782b0 | |||
| cf380b6eab | |||
| 9177a926df | |||
| 5e6defd4c7 | |||
| 6e7ae3e60d | |||
| b0b30eef91 | |||
| 8b25c9be39 | |||
| 2943f3945d | |||
| b62f097392 | |||
| 210ddff8d8 | |||
| e194da3766 | |||
| 15fb76005c | |||
| c27a645c19 | |||
| 21e7b1ff91 | |||
| 31163da868 | |||
| 9a1903e6ed | |||
| f990af1cb0 | |||
| e5be5f1ae5 | |||
| 54aed73726 | |||
| 82c3e1d605 | |||
| e5b523e907 | |||
| d028b70e6e | |||
| 626623bb6e | |||
| 264b2a86c1 | |||
| b03bd67678 | |||
| 301f105fbc | |||
| f0e2e12939 | |||
| bdedf47972 | |||
| a69be3da43 | |||
| 779fa1d480 | |||
| c59a071b82 | |||
| 285bfd90a7 | |||
| 9529f19c60 | |||
| 0013c9f3b1 | |||
| bd6ad4ae5f | |||
| 3697a58e5b | |||
| e290c92d78 | |||
| 9c4ebbc65a | |||
| c0d413ba11 | |||
| dca0245a42 | |||
| 024512959f | |||
| e78da3d7c1 | |||
| 2cb8b59fb7 |
@@ -6,7 +6,11 @@ on:
|
|||||||
- '**'
|
- '**'
|
||||||
tags-ignore:
|
tags-ignore:
|
||||||
- '**'
|
- '**'
|
||||||
pull_request:
|
|
||||||
|
# Cancel superseded runs on the same branch.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
@@ -26,30 +30,25 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
# Default ("tools platform-tools") drags in the Android Emulator
|
||||||
|
# (~300 MB) which the build never uses.
|
||||||
|
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
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
sdkmanager \
|
sdkmanager \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-36" \
|
|
||||||
"platforms;android-37.0" \
|
"platforms;android-37.0" \
|
||||||
"build-tools;36.0.0"
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
- 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: Setup Gradle cache
|
- name: Setup Gradle cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -63,16 +62,19 @@ jobs:
|
|||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
|
# No --no-daemon: the daemon lives only as long as this job container
|
||||||
|
# and lets the following steps skip JVM startup + reconfiguration.
|
||||||
- name: Lint (debug variant only)
|
- name: Lint (debug variant only)
|
||||||
run: ./gradlew lintDebug --no-daemon
|
run: ./gradlew lintDebug
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: ./gradlew testDebugUnitTest --no-daemon
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Assemble debug APK
|
- name: Assemble debug APK
|
||||||
run: ./gradlew assembleDebug --no-daemon
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
- name: Trivy filesystem scan
|
- name: Trivy filesystem scan
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Release to F-Droid
|
name: Release — F-Droid repo + Gitea release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -24,16 +24,33 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
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
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
sdkmanager \
|
sdkmanager \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-36" \
|
|
||||||
"platforms;android-37.0" \
|
"platforms;android-37.0" \
|
||||||
"build-tools;36.0.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: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
@@ -42,10 +59,10 @@ jobs:
|
|||||||
# any tag-resolved drift (e.g. version code substitution issues).
|
# any tag-resolved drift (e.g. version code substitution issues).
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: ./gradlew testDebugUnitTest --no-daemon
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Assemble debug APK (sanity)
|
- name: Assemble debug APK (sanity)
|
||||||
run: ./gradlew assembleDebug --no-daemon
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
needs: ci
|
needs: ci
|
||||||
@@ -65,16 +82,33 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
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
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
sdkmanager \
|
sdkmanager \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-36" \
|
|
||||||
"platforms;android-37.0" \
|
"platforms;android-37.0" \
|
||||||
"build-tools;36.0.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
|
- name: Install jq
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
@@ -87,7 +121,12 @@ jobs:
|
|||||||
$SUDO apk add --no-cache jq
|
$SUDO apk add --no-cache jq
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Tag-only build steps. On a manual workflow_dispatch (ref = a branch,
|
||||||
|
# not a tag) these are skipped: the job then just re-signs the existing
|
||||||
|
# index with the configured repo key and re-uploads — used for key
|
||||||
|
# rotation / repo recovery without publishing a new APK.
|
||||||
- name: Set version from git tag
|
- name: Set version from git tag
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||||||
@@ -101,8 +140,12 @@ jobs:
|
|||||||
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
|
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
|
||||||
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
||||||
grep -E 'versionName|versionCode' app/build.gradle.kts
|
grep -E 'versionName|versionCode' app/build.gradle.kts
|
||||||
|
# Export for later steps (F-Droid changelog, mapping asset name).
|
||||||
|
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
|
echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Setup Android Keystore
|
- name: Setup Android Keystore
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
env:
|
env:
|
||||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
@@ -121,7 +164,8 @@ jobs:
|
|||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
run: ./gradlew assembleRelease --no-daemon
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Setup F-Droid Server Tools
|
- name: Setup F-Droid Server Tools
|
||||||
run: |
|
run: |
|
||||||
@@ -131,29 +175,48 @@ jobs:
|
|||||||
$SUDO apt-get install -y sshpass python3-pip
|
$SUDO apt-get install -y sshpass python3-pip
|
||||||
pip3 install --break-system-packages --upgrade fdroidserver
|
pip3 install --break-system-packages --upgrade fdroidserver
|
||||||
|
|
||||||
- name: Initialize or fetch F-Droid Repository
|
- name: Fetch existing F-Droid repo from Hetzner
|
||||||
env:
|
env:
|
||||||
HOST: ${{ secrets.HETZNER_HOST }}
|
HOST: ${{ secrets.HETZNER_HOST }}
|
||||||
USER: ${{ secrets.HETZNER_USER }}
|
USER: ${{ secrets.HETZNER_USER }}
|
||||||
PASS: ${{ secrets.HETZNER_PASS }}
|
PASS: ${{ secrets.HETZNER_PASS }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
|
||||||
mkdir -p fdroid
|
mkdir -p fdroid
|
||||||
sshpass -p "$PASS" sftp -o StrictHostKeyChecking=no "$USER@$HOST" <<'SFTP'
|
# Pull only the published repo/ (all apps' APKs), any per-app
|
||||||
-mkdir dev
|
# metadata, and the repo icon — enough to rebuild the index without
|
||||||
-mkdir dev/fdroid
|
# dropping the other apps. The signing key is deliberately NOT pulled
|
||||||
-mkdir dev/fdroid/repo
|
# from the box; it comes from CI secrets in the next step so it never
|
||||||
SFTP
|
# has to live in the web-served tree.
|
||||||
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no -r "$USER@$HOST:dev/fdroid/." fdroid/ || (cd fdroid && fdroid init)
|
sshpass -p "$PASS" scp $SSH_OPTS -r "$USER@$HOST:dev/fdroid/repo" fdroid/ 2>/dev/null || true
|
||||||
|
sshpass -p "$PASS" scp $SSH_OPTS -r "$USER@$HOST:dev/fdroid/metadata" fdroid/ 2>/dev/null || true
|
||||||
|
sshpass -p "$PASS" scp $SSH_OPTS "$USER@$HOST:dev/fdroid/icon.png" fdroid/ 2>/dev/null || true
|
||||||
|
mkdir -p fdroid/repo fdroid/metadata
|
||||||
|
|
||||||
- name: Ensure F-Droid repo signing key and icon
|
- name: Restore F-Droid signing key and config from secrets
|
||||||
|
env:
|
||||||
|
FDROID_KEYSTORE_BASE64: ${{ secrets.FDROID_KEYSTORE_BASE64 }}
|
||||||
|
FDROID_CONFIG_BASE64: ${{ secrets.FDROID_CONFIG_BASE64 }}
|
||||||
run: |
|
run: |
|
||||||
cd fdroid
|
set -euo pipefail
|
||||||
mkdir -p repo/icons
|
# Fail loudly if the repo key is not configured. NEVER auto-generate
|
||||||
if [ ! -f keystore.p12 ]; then
|
# one: a fresh key changes the repo fingerprint and breaks every
|
||||||
fdroid update --create-key
|
# user's pinned repo. (Replaces the old `fdroid update --create-key`
|
||||||
|
# path, which silently rotated the key on a wiped server.)
|
||||||
|
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
|
||||||
|
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
|
||||||
|
echo "Refusing to continue — will not auto-generate a new repo key." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo "$FDROID_KEYSTORE_BASE64" | base64 --decode > fdroid/keystore.p12
|
||||||
|
echo "$FDROID_CONFIG_BASE64" | base64 --decode > fdroid/config.yml
|
||||||
|
test -s fdroid/keystore.p12
|
||||||
|
test -s fdroid/config.yml
|
||||||
|
mkdir -p fdroid/repo/icons
|
||||||
|
|
||||||
- name: Copy new APK to repo
|
- name: Copy new APK to repo
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
mkdir -p fdroid/repo
|
mkdir -p fdroid/repo
|
||||||
@@ -169,12 +232,33 @@ jobs:
|
|||||||
mkdir -p fdroid/metadata
|
mkdir -p fdroid/metadata
|
||||||
cp -r fdroid-metadata/* fdroid/metadata/
|
cp -r fdroid-metadata/* fdroid/metadata/
|
||||||
|
|
||||||
|
# Per-version "What's New" for F-Droid clients: the tag's CHANGELOG
|
||||||
|
# section written to changelogs/<versionCode>.txt (same extraction as the
|
||||||
|
# Gitea release notes). en-US only — F-Droid falls back to it for locales
|
||||||
|
# without their own changelog. fdroid update bakes this into the index.
|
||||||
|
- name: Generate F-Droid changelog for this version
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
awk -v ver="$VERSION" '
|
||||||
|
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||||
|
/^## \[/ { flag = 0 }
|
||||||
|
flag' CHANGELOG.md > /tmp/changelog.txt
|
||||||
|
sed -i -e '/./,$!d' /tmp/changelog.txt
|
||||||
|
if [ ! -s /tmp/changelog.txt ]; then
|
||||||
|
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
|
||||||
|
fi
|
||||||
|
CL_DIR="fdroid/metadata/de.jeanlucmakiola.calendula/en-US/changelogs"
|
||||||
|
mkdir -p "$CL_DIR"
|
||||||
|
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
|
||||||
|
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
|
||||||
|
|
||||||
- name: Generate F-Droid Index
|
- name: Generate F-Droid Index
|
||||||
run: |
|
run: |
|
||||||
cd fdroid
|
cd fdroid
|
||||||
fdroid update -c
|
fdroid update -c
|
||||||
|
|
||||||
- name: Upload Repo to Hetzner
|
- name: Upload repo/ to Hetzner
|
||||||
env:
|
env:
|
||||||
HOST: ${{ secrets.HETZNER_HOST }}
|
HOST: ${{ secrets.HETZNER_HOST }}
|
||||||
USER: ${{ secrets.HETZNER_USER }}
|
USER: ${{ secrets.HETZNER_USER }}
|
||||||
@@ -185,6 +269,113 @@ jobs:
|
|||||||
sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP'
|
sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP'
|
||||||
-mkdir dev
|
-mkdir dev
|
||||||
-mkdir dev/fdroid
|
-mkdir dev/fdroid
|
||||||
-mkdir dev/fdroid/repo
|
|
||||||
SFTP
|
SFTP
|
||||||
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/. "$USER@$HOST:dev/fdroid/"
|
# Publish the signed repo/ plus metadata/ (descriptions, screenshots,
|
||||||
|
# per-version changelogs) so changelog history survives across
|
||||||
|
# releases. keystore.p12 and config.yml are NEVER uploaded, so they
|
||||||
|
# can't re-enter the web-served tree; nginx serves only repo/ anyway.
|
||||||
|
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/"
|
||||||
|
|
||||||
|
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
|
||||||
|
# Attached to the Gitea 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: startsWith(github.ref, 'refs/tags/')
|
||||||
|
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="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||||||
|
ASSET="mapping-${VERSION:-$TAG}.txt.gz"
|
||||||
|
gzip -c "$MAP" > "/tmp/$ASSET"
|
||||||
|
# The release is created by the gitea-release job; ensure it exists
|
||||||
|
# (idempotent) so this job doesn't race it to a 404.
|
||||||
|
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||||
|
if [ -z "$ID" ]; then
|
||||||
|
ID=$(curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \
|
||||||
|
"$API/releases" | jq -r '.id // empty')
|
||||||
|
fi
|
||||||
|
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"
|
||||||
|
|
||||||
|
# A Gitea release per tag, carrying the tag's CHANGELOG section as its
|
||||||
|
# notes. Deliberately no APK assets — distribution stays with the F-Droid
|
||||||
|
# repo; the release is the human-readable record. Gated on the tests-only
|
||||||
|
# ci job (not the deploy) so notes appear even if the F-Droid upload has
|
||||||
|
# an infrastructure hiccup.
|
||||||
|
gitea-release:
|
||||||
|
needs: ci
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract changelog section for this tag
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
# Everything between "## [<version>]" and the next "## [" heading.
|
||||||
|
awk -v ver="$VERSION" '
|
||||||
|
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||||
|
/^## \[/ { flag = 0 }
|
||||||
|
flag' CHANGELOG.md > release-notes.md
|
||||||
|
# Trim leading blank lines.
|
||||||
|
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
|
||||||
|
echo "--- release notes ---"
|
||||||
|
cat release-notes.md
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||||||
|
python3 - "$TAG" <<'PY' > 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
|
||||||
|
# Upsert: the build-and-deploy job may have created a bare release
|
||||||
|
# first (to attach the mapping asset), so PATCH the notes if it
|
||||||
|
# exists, otherwise POST a new one. Both paths are re-run safe.
|
||||||
|
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
||||||
|
ID=$(python3 -c "import json,sys; d=json.load(open('existing.json')); print(d.get('id',''))" 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)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
42
.gitea/workflows/renovate.yml
Normal file
42
.gitea/workflows/renovate.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
|
||||||
|
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
|
||||||
|
schedule:
|
||||||
|
- cron: '0 5 * * 1'
|
||||||
|
# Manual run for an on-demand sweep from the Actions tab.
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Never let two Renovate runs touch the repo at once.
|
||||||
|
concurrency:
|
||||||
|
group: renovate
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: docker
|
||||||
|
# Run the Renovate image *as* the job container and invoke the `renovate`
|
||||||
|
# binary directly. The renovatebot/github-action wrapper is a thin Node
|
||||||
|
# action that shells out to `docker run …` — it needs a Docker CLI + socket
|
||||||
|
# inside the job, which the Gitea runner's plain node container has not, so
|
||||||
|
# it died on "Unable to locate executable file: docker". Running the image
|
||||||
|
# directly drops the docker-in-docker requirement entirely.
|
||||||
|
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
|
||||||
|
container:
|
||||||
|
image: ghcr.io/renovatebot/renovate:43.232.0
|
||||||
|
steps:
|
||||||
|
- name: Run Renovate
|
||||||
|
run: renovate
|
||||||
|
env:
|
||||||
|
# Self-hosted Gitea, not github.com.
|
||||||
|
RENOVATE_PLATFORM: gitea
|
||||||
|
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
|
||||||
|
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
# Scope to this repo only — no org-wide autodiscovery.
|
||||||
|
RENOVATE_AUTODISCOVER: 'false'
|
||||||
|
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
|
||||||
|
# Commits/PRs authored as the bot, not a real maintainer.
|
||||||
|
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
|
||||||
|
LOG_LEVEL: info
|
||||||
41
.gitea/workflows/translations.yaml
Normal file
41
.gitea/workflows/translations.yaml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Translations
|
||||||
|
|
||||||
|
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||||
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
|
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
tags-ignore:
|
||||||
|
- '**'
|
||||||
|
paths:
|
||||||
|
- 'app/src/main/res/values*/strings.xml'
|
||||||
|
- 'app/src/main/res/xml/locales_config.xml'
|
||||||
|
- 'scripts/check_translations.py'
|
||||||
|
- '.gitea/workflows/translations.yaml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: translations-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure python3
|
||||||
|
run: |
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update && apt-get install -y python3
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
apk add --no-cache python3
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
python3 --version
|
||||||
|
|
||||||
|
- name: Check translation parity
|
||||||
|
run: python3 scripts/check_translations.py
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -40,6 +40,7 @@ captures/
|
|||||||
# Keystore files
|
# Keystore files
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
|
*.p12
|
||||||
/key.properties
|
/key.properties
|
||||||
|
|
||||||
# Google Services (e.g. APIs or Firebase)
|
# Google Services (e.g. APIs or Firebase)
|
||||||
@@ -50,8 +51,7 @@ google-services.json
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# F-Droid local artifacts (the pipeline generates them in CI)
|
# F-Droid local artifacts (the pipeline generates them in CI)
|
||||||
fdroid/repo/
|
/fdroid/
|
||||||
fdroid/keystore.p12
|
|
||||||
|
|
||||||
# KSP
|
# KSP
|
||||||
.ksp/
|
.ksp/
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
A modern Material 3 Expressive Android calendar app, read-only V1. Lives
|
A modern Material 3 Expressive Android calendar app. Lives entirely on top
|
||||||
entirely on top of Android's `CalendarContract` — any calendar synced to the
|
of Android's `CalendarContract` — any calendar synced to the device (CalDAV
|
||||||
device (CalDAV via DAVx5, Google, local, WebCal, …) shows up automatically.
|
via DAVx5, Google, local, WebCal, …) shows up automatically; creating,
|
||||||
The differentiator is visual: real Material 3 Expressive design that no
|
editing, and deleting writes straight back, and reminders are delivered by
|
||||||
existing FOSS calendar app delivers.
|
the app itself (Etar model). The differentiator is visual: real Material 3
|
||||||
|
Expressive design that no existing FOSS calendar app delivers.
|
||||||
|
|
||||||
## Core Value
|
## Core Value
|
||||||
|
|
||||||
@@ -15,8 +16,10 @@ re-inventing the calendar sync stack — leave that to DAVx5 and the system.
|
|||||||
|
|
||||||
## Current Milestone
|
## Current Milestone
|
||||||
|
|
||||||
**v0.1 — Foundation & CI:** Buildable Android project scaffold with theme,
|
Milestones 1 (read, v1.0) and 2 (write support, v1.1–v2.0.0 incl. reminder
|
||||||
icon, i18n, Hilt, DataStore, green CI.
|
delivery) are **complete** — v2.0.0 shipped 2026-06-11. Next is v3.0
|
||||||
|
(power-user features) plus an undecided "Locations & People" idea backlog;
|
||||||
|
see `ROADMAP.md`.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -26,9 +29,8 @@ Expressive 1.5.0-alpha21 (alpha is intentional — Expressive APIs only
|
|||||||
live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL
|
live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL
|
||||||
with Version Catalog. AGP 9.1.1, Gradle 9.5.1. JVM target 17.
|
with Version Catalog. AGP 9.1.1, Gradle 9.5.1. JVM target 17.
|
||||||
|
|
||||||
Read-only V1, write support V2.
|
Android-only (minSdk 29, targetSdk 36). No iOS. No `INTERNET` permission —
|
||||||
|
any feature that would need one is an explicit product decision first.
|
||||||
Android-only (minSdk 29, targetSdk 36). No iOS.
|
|
||||||
|
|
||||||
## Naming
|
## Naming
|
||||||
|
|
||||||
|
|||||||
@@ -2,36 +2,43 @@
|
|||||||
|
|
||||||
See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
||||||
|
|
||||||
## V1 Scope (Variant "B")
|
## V1 Scope (Variant "B") — shipped in full (v1.0.0, 2026-06-11)
|
||||||
|
|
||||||
### Validated (shipped)
|
- [x] Foundation & CI infrastructure — v0.1.0 (2026-06-08)
|
||||||
- Foundation & CI infrastructure — v0.1.0 (2026-06-08)
|
|
||||||
|
|
||||||
### Active (V1)
|
|
||||||
|
|
||||||
- [x] Foundation & CI infrastructure
|
|
||||||
- [x] Data Layer over `CalendarContract`
|
- [x] Data Layer over `CalendarContract`
|
||||||
- [x] Permission flow (`READ_CALENDAR`)
|
- [x] Permission flow (`READ_CALENDAR`)
|
||||||
- [ ] Month view (S1)
|
- [x] Month view (S1)
|
||||||
- [ ] Week view (S2)
|
- [x] Week view (S2)
|
||||||
- [ ] Day view (S3)
|
- [x] Day view (S3)
|
||||||
- [ ] Event Detail Sheet (S4)
|
- [x] Event Detail Sheet (S4) — became a full screen, plus full event read (v0.6)
|
||||||
- [ ] Multi-Calendar Filter (M3)
|
- [x] Multi-Calendar Filter (M3)
|
||||||
- [ ] Today button + Jump-to-Date (M2)
|
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
|
||||||
- [ ] View-Switcher (M1)
|
- [x] View-Switcher (M1)
|
||||||
- [ ] Settings screen (M4)
|
- [x] Settings screen (M4)
|
||||||
- [ ] Empty / no-permission / no-calendars states
|
- [x] Empty / no-permission / no-calendars states
|
||||||
- [ ] German + English localization
|
- [x] German + English localization
|
||||||
- [ ] Loading/Failure/Success states per screen (architectural pattern)
|
- [x] Loading/Failure/Success states per screen (architectural pattern)
|
||||||
|
|
||||||
### Out of Scope (V2+)
|
## V2 Scope — write support, shipped in full (v2.0.0, 2026-06-11)
|
||||||
|
|
||||||
|
- [x] Write foundation: `WRITE_CALENDAR`, read-only-calendar detection, delete (v1.1)
|
||||||
|
- [x] Create event: form, FAB, last-used calendar (v1.2; polish v1.2.1)
|
||||||
|
- [x] Edit event: shared form, scoped recurring writes, recurrence picker (v1.3)
|
||||||
|
- [x] Reminder notifications (v1.4) — **reversal of the original
|
||||||
|
"system handles reminders" assumption:** Calendula targets
|
||||||
|
sole-calendar-app users, so it posts reminder notifications itself
|
||||||
|
(Etar model), incl. `POST_NOTIFICATIONS` onboarding
|
||||||
|
- [x] Conflict dialog on save + store polish (v2.0)
|
||||||
|
- Quick-add — **cut from scope** (the prefilled form covers it)
|
||||||
|
- Calendar switching while editing — moved to v3 backlog
|
||||||
|
|
||||||
|
### Out of Scope (V3+)
|
||||||
|
|
||||||
- Event create / edit / delete (V2)
|
|
||||||
- Home-screen widget
|
- Home-screen widget
|
||||||
- Full-text search
|
- Full-text search
|
||||||
- Quick-add
|
|
||||||
- Custom notifications/reminders (system already handles these)
|
|
||||||
- Tablet/foldable-specific layouts
|
- Tablet/foldable-specific layouts
|
||||||
|
- Locations & People ideas (contact picker, OSM autocomplete) — see
|
||||||
|
`ROADMAP.md` idea backlog, undecided
|
||||||
- iOS support (Android-only by design)
|
- iOS support (Android-only by design)
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|||||||
@@ -9,28 +9,459 @@
|
|||||||
| v0.3 | Month + Week + Day views, view switcher | complete |
|
| v0.3 | Month + Week + Day views, view switcher | complete |
|
||||||
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
||||||
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
||||||
| v1.0 | Polish + jump-to-date (M2), F-Droid release | pending |
|
| v0.6 | Full event read — surface every readable field | complete |
|
||||||
|
| v1.0 | First public release — polish pass, F-Droid | complete |
|
||||||
|
|
||||||
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
||||||
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
|
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
|
||||||
Jump-to-date (M2) was deferred out of v0.5 and folds into the v1.0 polish pass.
|
|
||||||
|
|
||||||
## v1.0 — First Public Release
|
Jump-to-date (the date-picker half of M2) was **cut from scope** and will not
|
||||||
|
ship. The "Today" half of M2 already shipped in v0.5 (drawer entry).
|
||||||
|
|
||||||
All V1 features shipped, polished, on F-Droid. Read-only calendar.
|
## v0.6 — Full event read
|
||||||
Remaining before v1.0: jump-to-date (M2) and a UI polish/QA pass.
|
|
||||||
|
|
||||||
## v2.0 — Write Support
|
Round out the read-only model so a detail view shows everything the system
|
||||||
|
actually stores, before write support starts. Scope = `CalendarContract`
|
||||||
|
columns we don't yet read/display:
|
||||||
|
|
||||||
- Event create / edit / delete via `CalendarContract` writes
|
- **Reminders** (`VALARM`) — read `CalendarContract.Reminders`, list lead times
|
||||||
- Quick-add sheet
|
- **Status** — Confirmed / Tentative / Cancelled (cancelled shown struck-through)
|
||||||
- Conflict UX (event modified externally during edit)
|
- **Availability** (`TRANSP`) — Free / Busy chip
|
||||||
|
- **Attendee extras** — role (required / optional / organizer) + the user's own
|
||||||
|
`SELF_ATTENDEE_STATUS`
|
||||||
|
- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone
|
||||||
|
- **URL** — ~~tappable link card~~ **cut**: `CalendarContract` exposes no
|
||||||
|
`Events.URL` column (only `CUSTOM_APP_URI`, an originating-app deep-link).
|
||||||
|
URLs are instead surfaced by linkifying the description text
|
||||||
|
- **Access level / class** (private / confidential) — small chip (optional, trivial)
|
||||||
|
|
||||||
## v3.0 — Power-User Features
|
All of the above shipped in v0.6.0 (2026-06-11).
|
||||||
|
|
||||||
- Home-screen widget
|
Deliberately out of v0.6:
|
||||||
- Full-text search
|
- Recurrence exception / modified-occurrence badges — `Instances` already
|
||||||
- Tablet / foldable layouts
|
resolves correct per-occurrence times for display; this only matters for
|
||||||
- Optional: ICS file import (drag-and-drop)
|
editing, so it folds into v2
|
||||||
|
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
|
||||||
|
(provider limitation, not our choice)
|
||||||
|
|
||||||
Order is indicative — community feedback after V1 may re-prioritize.
|
## v1.0 — First Public Release — shipped 2026-06-11
|
||||||
|
|
||||||
|
All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly
|
||||||
|
after v0.6 (full event read) plus the onboarding-screen polish pass.
|
||||||
|
|
||||||
|
### Polish backlog (pre-1.0)
|
||||||
|
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
|
||||||
|
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
|
||||||
|
|
||||||
|
## v2.0 — Write Support (complete, shipped 2026-06-11)
|
||||||
|
|
||||||
|
Delivered in four releasable slices (plan:
|
||||||
|
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
|
||||||
|
guide here, not a contract — scope per slice is decided as we go.
|
||||||
|
|
||||||
|
| Version | Milestone | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
|
||||||
|
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
||||||
|
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
||||||
|
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
|
||||||
|
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
|
||||||
|
| v2.0 | Conflict dialog, polish pass (store copy refresh, F-Droid screenshots), release | complete (shipped 2026-06-11) |
|
||||||
|
|
||||||
|
v2.0 scope was re-cut on 2026-06-11, after v1.4:
|
||||||
|
- **Occurrence edit** already shipped early, in v1.3.
|
||||||
|
- **Quick-add** is **cut from scope**: the full form already opens prefilled
|
||||||
|
(visible day, last-used calendar, optional fields hidden), so the sheet
|
||||||
|
would only save one screen transition while adding a second create-surface
|
||||||
|
to maintain. Revisit only if real-world feedback says creation feels heavy.
|
||||||
|
- **Calendar switching while editing** moves to the v3 backlog (sync-adapter
|
||||||
|
minefield: `CALENDAR_ID` is sync-adapter-owned, AOSP locks the field; an
|
||||||
|
honest implementation is copy+delete like Google Calendar, with sync-identity
|
||||||
|
and attendee side effects).
|
||||||
|
- **Conflict dialog** stays (plan 03, decision 5): on save, compare against
|
||||||
|
the row as it was when the form loaded; on external change, ask
|
||||||
|
overwrite / discard. Closes the silent-clobber gap on synced calendars.
|
||||||
|
|
||||||
|
## v1.4 — Reminder Notifications
|
||||||
|
|
||||||
|
**Essential**, not nice-to-have: Calendula targets users for whom it is their
|
||||||
|
*only* calendar app, so reminder delivery can't be delegated to Google/OEM
|
||||||
|
Calendar. The calendar provider schedules reminders and broadcasts
|
||||||
|
`android.intent.action.EVENT_REMINDER`, but it does **not** post the visible
|
||||||
|
notification — a calendar app must. We become that app (the Etar model).
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Manifest-registered `BroadcastReceiver` for `EVENT_REMINDER`
|
||||||
|
(data scheme `content://com.android.calendar`) — wakes us at reminder time,
|
||||||
|
no foreground service.
|
||||||
|
- Read `CalendarContract.CalendarAlerts` / `Reminders`, filter to
|
||||||
|
`METHOD_ALERT` / `METHOD_DEFAULT` (skip `METHOD_EMAIL`); post on a dedicated
|
||||||
|
notification channel; tap opens event detail.
|
||||||
|
- `POST_NOTIFICATIONS` runtime permission (API 33+) — requested in onboarding.
|
||||||
|
- Onboarding step: (a) request `POST_NOTIFICATIONS`, (b) in-app reminders
|
||||||
|
toggle, **default ON**, with copy warning that a second calendar app with
|
||||||
|
notifications on will cause duplicate reminders. Mirrored into Settings
|
||||||
|
(reversible).
|
||||||
|
|
||||||
|
Deliberately deferred (add only if needed):
|
||||||
|
- Snooze / dismiss notification actions (Etar has them)
|
||||||
|
- Battery-optimization exemption prompt for delivery reliability
|
||||||
|
|
||||||
|
## v2.1 — Month event grid + drawer view tabs (shipped 2026-06-15)
|
||||||
|
|
||||||
|
- Month grid shows real events as continuous multi-day bars (not just dots)
|
||||||
|
- View section in the navigation drawer to switch Month / Week / Day
|
||||||
|
- Fix: text cursor no longer jumps in event text fields
|
||||||
|
|
||||||
|
## v2.2 — Tap-to-create + local calendar management (shipped 2026-06-16)
|
||||||
|
|
||||||
|
- Tap an empty slot in day/week → create form prefilled with that day + the
|
||||||
|
tapped hour (snapped to the hour, 1 h long)
|
||||||
|
- Local (device-only) calendar management in a full-screen editor from
|
||||||
|
Settings → Calendars: create / rename / recolor / delete, with name,
|
||||||
|
pastel-previewed colour, and description (stored in `CAL_SYNC1`)
|
||||||
|
- Synced calendars listed read-only, grouped by account, each with a
|
||||||
|
per-account "manage in source app" deep-link (resolved from the account's
|
||||||
|
authenticator — DAVx5/ICSx5/…) + an add-account shortcut
|
||||||
|
- Shared `InlineTextField` extracted to `ui.common` (event form + calendar
|
||||||
|
editor share one input style)
|
||||||
|
|
||||||
|
## v2.3 — Material 3 grouped-list redesign (shipped 2026-06-16)
|
||||||
|
|
||||||
|
A structural + visual pass adopting one shared blueprint (modelled on the ReFra
|
||||||
|
gallery app) across Settings, the calendar manager and the navigation drawer.
|
||||||
|
|
||||||
|
- Shared `ui/common/GroupedList.kt`: `CollapsingScaffold` (a `LargeTopAppBar`
|
||||||
|
whose title collapses on scroll) + `GroupedRow` (Position-based corner
|
||||||
|
grouping, press-animated corners, `selected` + `minHeight` knobs).
|
||||||
|
- Settings: category hub with About card on top and sliding sub-pages
|
||||||
|
(Appearance / New event form / Notifications); theme/week-start/language
|
||||||
|
pickers moved from `DropdownMenu` to OptionCard dialogs; token-based icon
|
||||||
|
chips; `ic_gitea.xml` for the About "Source" button.
|
||||||
|
- Calendar manager + drawer restyled to match; shared `CalendarColorChip`;
|
||||||
|
drawer scrolls as one with the active view highlighted.
|
||||||
|
- Cards use `surfaceContainerHigh` for readable contrast.
|
||||||
|
- Donate button on the About card deferred (target TBD).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backlog (theme-based, post-v2.1)
|
||||||
|
|
||||||
|
The old v3.0 / "daily-driver polish" / "Locations & People" lists are
|
||||||
|
consolidated here by theme. Within a group, **(in progress)** /
|
||||||
|
**(next)** mark what is being or about to be worked; everything else is an
|
||||||
|
approved-but-unscheduled idea unless tagged **(idea)** /
|
||||||
|
**(go/no-go)** / **(rejected)**. Order across groups is not a commitment.
|
||||||
|
|
||||||
|
## Near-term sequence (ranked, 2026-06-16)
|
||||||
|
|
||||||
|
The theme groups below are the full menu; this is the committed *order* for
|
||||||
|
the next stretch. Ranking favours finishing the current create/edit + calendar
|
||||||
|
arc before opening new fronts, then cheap-relative-to-value items and ones that
|
||||||
|
unblock a later item. Order is a plan, not a contract — revisit after each lands.
|
||||||
|
|
||||||
|
**Tier 1 — finish the current arc (create/edit + calendars)**
|
||||||
|
1. Tap-to-create in day/week *(shipped v2.2.0)* — prefilled create from an empty slot
|
||||||
|
2. Local calendar management + "manage in source app" deep-links *(shipped v2.2.0)*
|
||||||
|
3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
|
||||||
|
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
|
||||||
|
above)*
|
||||||
|
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
|
||||||
|
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
|
||||||
|
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
|
||||||
|
Tier 1's create/edit + calendars arc is effectively closed. **Duplicate event**
|
||||||
|
was deprioritised (2026-06-17) as low-importance and dropped to the bottom of
|
||||||
|
the sequence; the next item is now **Jump-to-date** (formerly Tier 2).
|
||||||
|
|
||||||
|
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
|
||||||
|
|
||||||
|
### Settings redesign & restructure *(shipped v2.3.0)*
|
||||||
|
|
||||||
|
The original scope below is kept as a record; the implementation expanded from a
|
||||||
|
sub-screen restructure into the shared grouped-list blueprint (see "v2.3" above).
|
||||||
|
|
||||||
|
The settings screen has grown into a flat vertical scroll of divider-separated
|
||||||
|
sections (Appearance, Event form, Notifications, Calendars, Language, About) and
|
||||||
|
will keep accreting rows (per-event-color defaults, default reminder, more
|
||||||
|
calendar entries are all queued). It needs structure before it gets unwieldy.
|
||||||
|
|
||||||
|
**Decided (2026-06-16): sub-screens**, not flat-but-carded. The top level
|
||||||
|
becomes a category list; each category opens its own destination. More
|
||||||
|
M3-idiomatic for a settings surface that will keep growing, and it mirrors the
|
||||||
|
existing Calendars row, which already navigates out to its own screen.
|
||||||
|
|
||||||
|
Structure — top-level settings list → category destinations:
|
||||||
|
- **Appearance** → theme, dynamic colour, week start
|
||||||
|
- **Event form** → the 6 default-field toggles + the hint text
|
||||||
|
- **Notifications** → reminders toggle (POST_NOTIFICATIONS flow stays)
|
||||||
|
- **Calendars** → already its own screen (`CalendarsScreen`); just becomes a
|
||||||
|
peer category row, no change to that screen
|
||||||
|
- **Language** → single control; keep as a top-level row that opens an
|
||||||
|
OptionCard directly (a whole sub-screen for one choice is overkill)
|
||||||
|
- **About** → kept inline on the top-level list as a card (read-only info,
|
||||||
|
not worth a navigation hop). Card layout, top → bottom:
|
||||||
|
- **Identity** — app logo + name "Calendula", with "by Jean-Luc Makiola"
|
||||||
|
as a subtitle beneath the name
|
||||||
|
- **Action buttons** (small, button-styled, sit in a row):
|
||||||
|
- **Source** — Gitea logo, opens the repo (`about_source_url`)
|
||||||
|
- **License** — opens the LICENSE file on Gitea
|
||||||
|
- **Donate** *(tentative)* — sits next to Source; target TBD (decide
|
||||||
|
before building: Liberapay / Ko-fi / Gitea sponsor / etc.)
|
||||||
|
- **Version** — small version number at the bottom of the card
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- **Navigation** — add the settings sub-screen destinations alongside the
|
||||||
|
existing settings/calendars routes in `CalendarHost`; back pops to the
|
||||||
|
settings list (mind the existing `BackHandler` that guards against falling
|
||||||
|
through to the activity).
|
||||||
|
- **Fix the dialog-pattern violation** — theme, week-start and language use
|
||||||
|
`DropdownMenu`; the project default is the full-width tonal OptionCard modal
|
||||||
|
(radio/dropdown/text-list dialogs are banned, see
|
||||||
|
`option-card-modal-style-default`). Migrate these selectors to OptionCard.
|
||||||
|
- **Visual pass** — top-level category rows with leading icons; consistent
|
||||||
|
spacing and row affordances aligned with the event-form card design system.
|
||||||
|
|
||||||
|
Out of scope (no new settings *features* here) — this is a structure + style
|
||||||
|
pass on the existing controls; new toggles ride in with their own features.
|
||||||
|
|
||||||
|
**Tier 2 — navigation & daily-driver completeness**
|
||||||
|
5. ~~Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap~~ *(done, v2.5.0)*
|
||||||
|
6. ~~Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget~~ *(done, v2.5.0)*
|
||||||
|
|
||||||
|
**Tier 3 — platform reach (depends on Tier 2)**
|
||||||
|
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
|
||||||
|
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open
|
||||||
|
|
||||||
|
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||||
|
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
||||||
|
default reminder **+ per-calendar override**, bundled with battery-exemption
|
||||||
|
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
|
||||||
|
10. **The `.ics` engine — export + import** *(in progress → v2.7)* — one
|
||||||
|
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
|
||||||
|
four surfaces: single-event share + whole-calendar backup (export),
|
||||||
|
open-`.ics`→form + whole-calendar restore (import). Closes the
|
||||||
|
device-local-calendar data-loss gap (#10/#11 merged here). Built as **two
|
||||||
|
sequential branches in one release**: `feat/ics-export` (write side +
|
||||||
|
UID-on-create precursor) then `feat/ics-import` (parser, restore, dedup).
|
||||||
|
Import is liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` /
|
||||||
|
`RECURRENCE-ID` it can't model. Timezone rule: all-day `VALUE=DATE`,
|
||||||
|
non-recurring timed UTC `Z`, recurring timed `TZID`-labelled from the stored
|
||||||
|
`EVENT_TIMEZONE` (no `VTIMEZONE` blocks; resolved against the OS tz DB on
|
||||||
|
import). Plan: `docs/superpowers/plans/2026-06-18-05-ics-export.md`.
|
||||||
|
11. **Snooze / dismiss notification actions** *(next, after v2.7)* — follows the
|
||||||
|
`.ics` work; inherits v2.6's deferred exact-alarm/WorkManager decision (snooze
|
||||||
|
must re-fire an alarm).
|
||||||
|
12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
|
||||||
|
|
||||||
|
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||||
|
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
||||||
|
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET
|
||||||
|
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
||||||
|
|
||||||
|
**Bottom — deprioritised, not important**
|
||||||
|
- Duplicate event (detail action → prefilled create form) — moved here
|
||||||
|
2026-06-17; cheap but low value, pick up only if asked
|
||||||
|
|
||||||
|
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
||||||
|
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
||||||
|
|
||||||
|
Debatable calls worth a second look: whether **local-calendar backup (#10)**
|
||||||
|
should lead Tier 4 outright (it's a silent data-loss risk, not a feature);
|
||||||
|
whether drag-drop (#12) jumps ahead given its daily-driver impact.
|
||||||
|
|
||||||
|
## Navigation & views
|
||||||
|
|
||||||
|
- ~~Tap an empty slot in day/week → create form prefilled with that
|
||||||
|
date+time, snapped to the hour~~ **shipped v2.2.0** (long-press variant
|
||||||
|
not added — single tap covers it)
|
||||||
|
- Agenda view (fourth view: upcoming events grouped by day; also the
|
||||||
|
natural data source for a future widget)
|
||||||
|
- Jump to date — drawer date picker (un-cut from V1)
|
||||||
|
- Current-time "now" line in day/week — standard in every calendar, cheap,
|
||||||
|
currently absent. Daily-driver polish.
|
||||||
|
- Week numbers in the **month** grid — week view already shows the badge
|
||||||
|
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
|
||||||
|
- Pinch-to-zoom time scale in day/week
|
||||||
|
- Tablet / foldable layouts *(was v3.0)*
|
||||||
|
- Full-text search *(was v3.0)* — promote out of "fill-in": for a daily driver
|
||||||
|
with real event history, finding an event is core completeness, not optional.
|
||||||
|
|
||||||
|
## Event editing & creation
|
||||||
|
|
||||||
|
- Drag & drop rescheduling in day/week (recurring drops reuse the scope
|
||||||
|
dialog) — big-ticket, own slice
|
||||||
|
- Duplicate event (detail action → prefilled create form)
|
||||||
|
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
|
||||||
|
*(next)* — chosen to follow the in-progress tap-to-create + calendar
|
||||||
|
management work: reuses the color-picker component and palette plumbing
|
||||||
|
being built for local calendar management, and finishes the create/edit
|
||||||
|
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list
|
||||||
|
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
|
||||||
|
|
||||||
|
## Calendars & accounts
|
||||||
|
|
||||||
|
- ~~Create / manage local (device-only) calendars~~ **shipped v2.2.0** —
|
||||||
|
name + color + description; rename / recolor / delete the calendars the app
|
||||||
|
owns. Inserted under `ACCOUNT_TYPE_LOCAL` as a sync adapter; description in
|
||||||
|
`CAL_SYNC1`. Full-screen "Calendars" editor reached from Settings.
|
||||||
|
- ~~Per-calendar "manage in source app" deep-link~~ **shipped v2.2.0** — for
|
||||||
|
synced calendars, open the app the calendar actually came from based on
|
||||||
|
its `ACCOUNT_TYPE` (DAVx5 `bitfire.at.davdroid`, Google `com.google`,
|
||||||
|
…); fall back to system account/sync settings. Plus an "add account"
|
||||||
|
entry into system Accounts. Honest boundary for remote calendars.
|
||||||
|
- **Remote calendar create/edit** *(go/no-go)* — creating a CalDAV
|
||||||
|
collection (`MKCALENDAR`) or a Google calendar means an in-app sync
|
||||||
|
client: **INTERNET permission, credential storage, the full server
|
||||||
|
round-trip** — i.e. re-implementing DAVx5. DAVx5 exposes no public
|
||||||
|
intent to delegate the create to it. Cosmetic local edits (color/name)
|
||||||
|
to an existing synced row are possible but don't propagate to the server
|
||||||
|
and may be overwritten on next sync — not promised. Same explicit
|
||||||
|
go/no-go gate as the OSM/INTERNET item below.
|
||||||
|
- Move event to another calendar (copy+delete model with a consequences
|
||||||
|
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
||||||
|
- **Local-calendar backup / export** *(Tier 4 #10)* — device-only
|
||||||
|
(`ACCOUNT_TYPE_LOCAL`) calendars are first-class in Calendula but have **no
|
||||||
|
sync and therefore no backup**: a lost/wiped phone destroys them permanently.
|
||||||
|
Whole-calendar `.ics` (VCALENDAR) export to a user-chosen file (SAF), plus
|
||||||
|
restore-on-import that recreates events into a chosen local calendar. Reuses
|
||||||
|
the .ics serializer from the single-event share work; the restore path reuses
|
||||||
|
the import parser. A data-integrity obligation, not a feature.
|
||||||
|
|
||||||
|
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
|
||||||
|
|
||||||
|
Two themes bundled because both are "make reminders trustworthy" — the core of
|
||||||
|
the "Calendula is your only calendar app" promise.
|
||||||
|
|
||||||
|
**Built in this slice (A + the safe half of B):** global timed default reminder
|
||||||
|
+ a **separate all-day default** (day-scale lead times) + per-calendar override
|
||||||
|
(timed events), applied on create with manual-edit / calendar-switch / all-day-
|
||||||
|
toggle handling; three pickers + per-calendar override list in Settings →
|
||||||
|
Notifications; battery-optimisation exemption row (status + system deep-link, no
|
||||||
|
extra permission). `resolveDefaultReminder` + prefs round-trips unit-tested.
|
||||||
|
Resolution model: all-day events use the all-day global default outright;
|
||||||
|
per-calendar overrides govern timed events only. Reviewed (8-angle), fixes
|
||||||
|
applied: form-reset state race, label-fn consolidation with the detail screen,
|
||||||
|
inline wrapper + single combined flow read.
|
||||||
|
|
||||||
|
**Deliberately deferred (documented decisions, not oversights):**
|
||||||
|
- *Absolute time-of-day for all-day reminders* — the all-day default is still
|
||||||
|
minutes-before-midnight (day-scale presets), not "9am the day before" (open
|
||||||
|
decision #2's richer half). Per-calendar all-day overrides also deferred.
|
||||||
|
- *Self-scheduled alarms* — kept the existing provider-broadcast architecture
|
||||||
|
(open decision #1). The battery exemption is the reliability lever; no
|
||||||
|
`AlarmManager`/`USE_EXACT_ALARM` subsystem was added.
|
||||||
|
- *Test-reminder diagnostic* and *battery prompt inside onboarding* — the
|
||||||
|
exemption lives only in Settings for now (onboarding flow untouched to keep
|
||||||
|
the change reviewable).
|
||||||
|
|
||||||
|
### A. Default reminders (global + per-calendar override)
|
||||||
|
|
||||||
|
**No provider backing.** `CalendarContract` has no column that auto-applies a
|
||||||
|
default reminder per calendar — Google's per-calendar defaults live server-side.
|
||||||
|
So both the global default *and* the per-calendar override are **app-side
|
||||||
|
preferences**, applied by us at event-insert time. We inherit nothing from the
|
||||||
|
synced calendar.
|
||||||
|
|
||||||
|
- **Storage (DataStore):**
|
||||||
|
- `defaultReminderMinutes: Int?` — global default; `null` = "no reminder".
|
||||||
|
- `defaultAllDayReminderMinutes: Int?` — separate all-day default (all-day
|
||||||
|
reminders are expressed as minutes before midnight / day-before-at-time, not
|
||||||
|
minutes before a start instant — they need their own value).
|
||||||
|
- `perCalendarReminderOverride: Map<Long, Int?>` — keyed by calendar id;
|
||||||
|
**absent key = inherit global**, explicit `null` = "no reminder for this
|
||||||
|
calendar". (Same for an all-day override map if we want per-calendar all-day.)
|
||||||
|
- **Apply on create:** a fresh event prefills its reminders list from
|
||||||
|
override-or-global for the preselected calendar. Changing the calendar in the
|
||||||
|
form re-applies the *new* calendar's default **only if the user hasn't manually
|
||||||
|
edited the reminders** — track a dirty flag, mirroring the per-event-color
|
||||||
|
reset pattern (v2.4).
|
||||||
|
- **Edit semantics:** defaults apply to **new events only**; never rewrite
|
||||||
|
reminders on existing events on open or on calendar-switch-during-edit.
|
||||||
|
- **Settings UI (Notifications sub-page):**
|
||||||
|
- Global default via OptionCard (None / at time of event / 5 / 10 / 15 / 30 min
|
||||||
|
/ 1 h / 1 day / custom), plus the separate all-day default.
|
||||||
|
- Per-calendar overrides: a row per writable calendar (in the Calendars screen
|
||||||
|
or a Notifications subsection), each opening the same OptionCard with a
|
||||||
|
leading **"Use global default"** option.
|
||||||
|
|
||||||
|
### B. Delivery reliability (exact alarms + battery)
|
||||||
|
|
||||||
|
The provider broadcasts `EVENT_REMINDER`, but on modern Android (Doze / OEM
|
||||||
|
battery managers) delivery can be silently delayed or dropped. v1.4 deferred this;
|
||||||
|
it directly undermines the feature's premise, so it rides in here.
|
||||||
|
|
||||||
|
- **Exact alarm — decision first:** trust the provider broadcast, or
|
||||||
|
self-schedule via `AlarmManager.setExactAndAllowWhileIdle` for reliability?
|
||||||
|
If we self-schedule, declare `USE_EXACT_ALARM` (API 33+, auto-granted for
|
||||||
|
calendar/alarm-category apps, F-Droid-clean) with a `SCHEDULE_EXACT_ALARM`
|
||||||
|
fallback for API 31–32 (user-revocable → settings deep-link prompt).
|
||||||
|
- **Battery-optimization exemption:** a *soft, optional* prompt via
|
||||||
|
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (settings deep-link — never the
|
||||||
|
auto-grant intent), honest copy: "Android may delay reminders to save battery;
|
||||||
|
exempt Calendula for on-time delivery." Shown once after the existing
|
||||||
|
`POST_NOTIFICATIONS` onboarding step, reversible in Settings → Notifications.
|
||||||
|
- **Diagnostics:** a "send a test reminder in 1 minute" button in Notifications
|
||||||
|
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
||||||
|
are notorious for suppressing it).
|
||||||
|
|
||||||
|
### Open decisions (resolve before building)
|
||||||
|
|
||||||
|
1. Self-schedule via `AlarmManager` vs trust the provider broadcast
|
||||||
|
(reliability vs simplicity + battery cost).
|
||||||
|
2. All-day reminder representation (minutes-before vs absolute time-of-day).
|
||||||
|
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
|
||||||
|
a list inside the Notifications sub-page).
|
||||||
|
|
||||||
|
### Later (round two)
|
||||||
|
|
||||||
|
- Snooze + dismiss actions on the notification (snooze needs an
|
||||||
|
exact-alarm / WorkManager decision) — Tier 4 #13.
|
||||||
|
|
||||||
|
## Sharing & interop
|
||||||
|
|
||||||
|
- Share event as .ics + open/receive .ics into a prefilled create form
|
||||||
|
(front-runs the import below)
|
||||||
|
- ICS file import (drag-and-drop) *(was v3.0, optional)*
|
||||||
|
|
||||||
|
## Platform & launchers
|
||||||
|
|
||||||
|
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
||||||
|
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0** —
|
||||||
|
optional quick-settings tile still open
|
||||||
|
|
||||||
|
## Quality & reliability
|
||||||
|
|
||||||
|
- **Accessibility pass** — TalkBack content descriptions across all screens,
|
||||||
|
dynamic-type / large-font reflow, touch-target audit. Quality bar for an
|
||||||
|
F-Droid app; nothing tracks it yet.
|
||||||
|
- **Reminder delivery reliability** — exact alarms + battery-optimization
|
||||||
|
exemption; specced in the "Reminders — defaults & delivery reliability" slice
|
||||||
|
above (Tier 4 #9).
|
||||||
|
|
||||||
|
## Locations & People *(go/no-go, captured 2026-06-11)*
|
||||||
|
|
||||||
|
Beyond classic calendar-client scope; discussed, deliberately not planned
|
||||||
|
in detail yet:
|
||||||
|
|
||||||
|
- **Contact address picker** for the location field via the system picker
|
||||||
|
(`ACTION_PICK` on postal addresses) — one-shot, needs no READ_CONTACTS,
|
||||||
|
fits the privacy story. Same mechanism later for picking emails.
|
||||||
|
- **OSM address autocomplete** in the location field (type "Brandenburger
|
||||||
|
Tor" → tap suggestion → resolved address inserted). Backend would be
|
||||||
|
Photon (Nominatim's public policy forbids autocomplete). **Requires the
|
||||||
|
INTERNET permission** — first dent in the "no network access" promise;
|
||||||
|
if built: opt-in (off by default), honest copy, configurable endpoint
|
||||||
|
for self-hosters, onboarding footnote + F-Droid copy reworded. This
|
||||||
|
trade-off is an explicit go/no-go decision before any work starts.
|
||||||
|
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
|
||||||
|
if the picker proves clunky.
|
||||||
|
- **Attendee editing / invites from contacts** — own milestone; writing
|
||||||
|
`Attendees` rows touches sync-adapter invitation behavior (Google vs
|
||||||
|
DAVx5 differ).
|
||||||
|
|
||||||
|
## Consciously rejected
|
||||||
|
|
||||||
|
- Travel time / weather / smart suggestions (network, core-promise conflict)
|
||||||
|
- Natural-language quick entry (high effort, locale-fragile; the prefilled
|
||||||
|
form already covers fast entry)
|
||||||
|
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
# Calendula — Current State
|
# Calendula — Current State
|
||||||
|
|
||||||
*Last updated: 2026-06-10*
|
*Last updated: 2026-06-17*
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Milestone:** v0.5 — Calendar filter (M3) + Settings (M4) (complete)
|
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11;
|
||||||
**Phase:** All V1 screens and cross-cutting wiring done except jump-to-date
|
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
|
||||||
(M2), which is deferred to the v1.0 polish pass
|
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
|
||||||
|
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
|
||||||
|
the calendar manager and the navigation drawer) both shipped 2026-06-16;
|
||||||
|
v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen
|
||||||
|
agenda + month widgets, and a "New event" launcher shortcut) shipped
|
||||||
|
2026-06-17. The backlog is now organised by theme in `ROADMAP.md`.
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -21,10 +26,105 @@
|
|||||||
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
|
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
|
||||||
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
|
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
|
||||||
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
|
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
|
||||||
- [ ] Jump-to-date (M2) — drawer entry still stubbed (deferred to v1.0)
|
- [~] Jump-to-date (M2) — **cut from scope**; "Today" half shipped in v0.5, date-picker dropped
|
||||||
|
- [x] Full event read (v0.6) — reminders, status, availability, access level,
|
||||||
|
attendee role + self-response, foreign timezone, and linkified description
|
||||||
|
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
|
||||||
|
URL field was cut — no `CalendarContract` column backs it.)
|
||||||
|
|
||||||
|
- [x] v1.1 write foundation — `WRITE_CALENDAR` (onboarding asks READ+WRITE,
|
||||||
|
only READ gates; contextual upgrade for v1.0 installs), read-only-calendar
|
||||||
|
detection (`CALENDAR_ACCESS_LEVEL` → `canModifyContents`, actions hidden for
|
||||||
|
WebCal/birthday calendars), delete from the detail screen (recurring:
|
||||||
|
"only this event" via cancelled exception / "all events in the series"),
|
||||||
|
repository + mapper tests
|
||||||
|
|
||||||
|
- [x] v1.2 create event — full-screen `EventEditScreen` (title, all-day,
|
||||||
|
M3 date/time pickers with duration-preserving start moves, writable-only
|
||||||
|
calendar picker preselecting the last-used calendar, location, description),
|
||||||
|
"+" FAB on all three views prefilled with the visible day, `insertEvent`
|
||||||
|
with provider-correct all-day normalisation (UTC midnights, exclusive end),
|
||||||
|
domain/mapper/repository tests
|
||||||
|
|
||||||
|
- [x] v1.3 edit event (shipped 2026-06-11) — `EventEditScreen` reused for
|
||||||
|
edit (detail-screen Edit action, `canModify`-gated, contextual WRITE
|
||||||
|
upgrade), dirty-checked partial `update` on the Events row (recurring:
|
||||||
|
series DTSTART moves by the user's delta, DURATION instead of DTEND),
|
||||||
|
reminder diff by minutes (kept rows keep their method), simple recurrence
|
||||||
|
picker (FREQ/INTERVAL/UNTIL/COUNT; complex RRULEs preserved verbatim and
|
||||||
|
shown humanized), `EventFormField.Recurrence` incl. settings default,
|
||||||
|
recurrence also available on create; domain/mapper/repository tests.
|
||||||
|
Review round 1: weekly BYDAY day-toggles in the custom picker ("every week
|
||||||
|
on Mon+Fri"). Review rounds 2–4: occurrence edit pulled forward from v2.0
|
||||||
|
and made three-way like delete ("this" = exception row via
|
||||||
|
`CONTENT_EXCEPTION_URI`, "this and following" = series split, "all" =
|
||||||
|
series update); delete equally three-way (truncation via RRULE UNTIL);
|
||||||
|
the edit-scope question moved to save time (Google model) — dirty
|
||||||
|
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
|
||||||
|
the "only this event" option
|
||||||
|
|
||||||
|
- [x] v1.4 reminder notifications (shipped 2026-06-11) — exported
|
||||||
|
`EVENT_REMINDER` receiver → `CalendarAlerts` (SCHEDULED & due) →
|
||||||
|
dedicated channel, tap opens detail (singleTop deep link); best-effort
|
||||||
|
FIRED marking; one-time onboarding step requesting `POST_NOTIFICATIONS`
|
||||||
|
with duplicate-reminders warning; Settings mirror. Provider only fires
|
||||||
|
`METHOD_ALERT` rows (AOSP-verified), so email reminders never reach us
|
||||||
|
|
||||||
|
- [x] v2.0 conflict dialog + store polish (shipped 2026-06-11 as v2.0.0) —
|
||||||
|
`EditSnapshot` compare on save (overwrite/discard; deleted → close),
|
||||||
|
quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy
|
||||||
|
refreshed, fastlane screenshots DE+EN captured on-device
|
||||||
|
|
||||||
|
- [x] v2.1 (shipped 2026-06-15) — month grid shows real events as
|
||||||
|
continuous multi-day bars; navigation-drawer View section
|
||||||
|
(Month/Week/Day); cursor-jump fix in event text fields
|
||||||
|
|
||||||
|
- [x] v2.2 (shipped 2026-06-16) — tap an empty slot in day/week to create
|
||||||
|
(prefilled with that day + tapped hour, snapped to the hour); local
|
||||||
|
calendar management in a full-screen editor from Settings →
|
||||||
|
Calendars: create/rename/recolor/delete device-only calendars
|
||||||
|
(`ACCOUNT_TYPE_LOCAL`, sync-adapter insert) with name, pastel-previewed
|
||||||
|
colour, and description (stored in `CAL_SYNC1`); synced calendars listed
|
||||||
|
read-only grouped by account with a per-account "manage in source app"
|
||||||
|
deep-link (resolved from the account's authenticator: DAVx5/ICSx5/…) and
|
||||||
|
an add-account shortcut. Shared `InlineTextField` extracted to `ui.common`
|
||||||
|
|
||||||
|
- [x] v2.3 settings/calendars/drawer redesign (shipped 2026-06-16) — adopted a
|
||||||
|
shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app
|
||||||
|
and extracted to `ui/common/GroupedList.kt` (`CollapsingScaffold` with a
|
||||||
|
`LargeTopAppBar` exit-until-collapsed title; `GroupedRow` with Position-based
|
||||||
|
corner grouping, press-animated corners, `selected` + `minHeight` knobs).
|
||||||
|
- Settings: category hub (About card on top → version mark at the foot) with
|
||||||
|
sliding sub-pages (Appearance / New event form / Notifications); token-
|
||||||
|
based icon chips; theme/week-start/language pickers migrated from
|
||||||
|
`DropdownMenu` to OptionCard dialogs. New `ic_gitea.xml` (Simple Icons,
|
||||||
|
verbatim path) for the About "Source" button; en+de strings.
|
||||||
|
- Calendar manager: same collapsing scaffold + grouped rows; shared
|
||||||
|
`CalendarColorChip` (neutral chip, pastelised calendar glyph).
|
||||||
|
- Navigation drawer: branded header, grouped View switcher (active view
|
||||||
|
highlighted via `secondaryContainer`), the filter list restyled to
|
||||||
|
grouped rows with a trailing checkbox; the whole drawer scrolls as one.
|
||||||
|
- Cards use `surfaceContainerHigh` for readable contrast against `surface`.
|
||||||
|
- Donate button on the About card deferred (target still TBD).
|
||||||
|
|
||||||
|
- [x] v2.4 per-event color (shipped 2026-06-17) — an optional "Color" field in
|
||||||
|
the event form. Read/render already resolved `EVENT_COLOR` with a calendar
|
||||||
|
fallback; this adds the write side and the picker. Palette-backed calendars
|
||||||
|
(Google, some CalDAV) pick from the account's `Colors` (`TYPE_EVENT`) and
|
||||||
|
write `EVENT_COLOR_KEY` so the color round-trips through sync; local
|
||||||
|
calendars write a raw `EVENT_COLOR` from the shared `CALENDAR_COLOR_PALETTE`
|
||||||
|
(extracted with the swatch row to `ui/common/ColorSwatchRow.kt`). Switching
|
||||||
|
calendars resets the choice (a key is account-scoped). A settings toggle
|
||||||
|
("Allow colors on unsupported calendars", off by default) extends the raw
|
||||||
|
path to synced calendars with no palette, with an honest "may not survive
|
||||||
|
sync" warning on the picker and in Settings. Color writes flow through
|
||||||
|
insert / dirty-checked update / occurrence-exception; mapper + form tests.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. Jump-to-date (M2) — date picker from the drawer, reachable on every view
|
1. Monitor the F-Droid build/publish for the v2.4.0 tag
|
||||||
2. UI polish / QA pass across all views before v1.0
|
2. Decide the "Locations & People" and "remote calendar create/edit"
|
||||||
3. F-Droid release of v1.0
|
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
|
||||||
|
3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then
|
||||||
|
agenda view (strategic, backs a future widget). Full ranked sequence in
|
||||||
|
`ROADMAP.md` → "Near-term sequence".
|
||||||
|
|||||||
354
CHANGELOG.md
354
CHANGELOG.md
@@ -7,6 +7,357 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.7.0] — 2026-06-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Share a single event as an `.ics` file from the event detail screen — hands a
|
||||||
|
standard calendar file to any app via the system share sheet.
|
||||||
|
- Back up your local (device-only) calendars: Settings → Calendars → Export as
|
||||||
|
`.ics` file writes every event of your on-device calendars to a file you
|
||||||
|
choose. Local calendars aren't synced anywhere, so this is their only backup.
|
||||||
|
- Open or share an `.ics` file into Calendula: a single event opens the create
|
||||||
|
form prefilled for review, while a file with many events (e.g. a backup) opens
|
||||||
|
a bulk import — pick a calendar and import them all. Re-importing a backup
|
||||||
|
won't create duplicates (events are matched by their unique identifier), and
|
||||||
|
anything Calendula can't represent (changed recurring occurrences, guest
|
||||||
|
lists) is reported rather than silently dropped.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- All-day events that cover a single day (e.g. a birthday) no longer show up on
|
||||||
|
the following day as well — in the day, week and month views or on the event
|
||||||
|
detail screen. The extra day came from interpreting the all-day date range in
|
||||||
|
the device's time zone instead of UTC.
|
||||||
|
- Fixed the app crashing immediately on every launch in the optimized release
|
||||||
|
build: release code-shrinking (R8) was stripping a database class the
|
||||||
|
home-screen widget framework needs, so the app died at startup before showing
|
||||||
|
anything. Added the missing keep rule.
|
||||||
|
|
||||||
|
## [2.6.0] — 2026-06-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- App language can now be set from Android's system per-app language settings
|
||||||
|
(Android 13+), in addition to the in-app picker in Settings — and the app is
|
||||||
|
set up so further languages can be added by community translators
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Changing the app language in Settings now takes effect immediately; the
|
||||||
|
picker previously had no effect
|
||||||
|
|
||||||
|
## [2.5.0] — 2026-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Home-screen widgets (two of them): an "Upcoming" agenda widget — a scrolling
|
||||||
|
list of the next month of events grouped under day headers, with refresh and
|
||||||
|
"New event" buttons — and a month-grid widget showing the full month with
|
||||||
|
today highlighted, connected multi-day event bars, and prev/next/today
|
||||||
|
navigation. Both reuse the in-app grouping and layout so they match the app
|
||||||
|
exactly, respect your hidden-calendar choices, and refresh automatically when
|
||||||
|
the calendar changes or the day rolls over. Tapping a day opens that day;
|
||||||
|
tapping an event opens its details
|
||||||
|
- App shortcut: long-press the Calendula icon for a "New event" action that
|
||||||
|
jumps straight into the create-event form
|
||||||
|
- Agenda view — a fourth top-level view alongside Month/Week/Day: a
|
||||||
|
forward-looking list of upcoming events grouped under "Today"/"Tomorrow"/date
|
||||||
|
headers, reachable from the view switcher
|
||||||
|
- Jump to date — a "Jump to date" row in the navigation drawer opens a date
|
||||||
|
picker and moves the active view (Month/Week/Day/Agenda) to the chosen day
|
||||||
|
|
||||||
|
## [2.4.0] — 2026-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Per-event colors: give a single event its own color, instead of always
|
||||||
|
inheriting its calendar's. Add the new "Color" field from "More fields" in
|
||||||
|
the event form. On calendars that publish their own color set — such as
|
||||||
|
Google — you pick from that calendar's palette, so the color is stored
|
||||||
|
with the event and shows correctly on every synced device. On local
|
||||||
|
calendars you pick from Calendula's palette. "Reset" returns an event to
|
||||||
|
its calendar's color
|
||||||
|
- A new "Allow colors on unsupported calendars" setting (New event form,
|
||||||
|
off by default) extends per-event colors to calendars that publish no
|
||||||
|
color set of their own (some CalDAV). Such a color is kept on the device
|
||||||
|
and may be dropped or overwritten on that calendar's next sync — a
|
||||||
|
limitation of those calendars, called out plainly in the setting and on
|
||||||
|
the color picker
|
||||||
|
|
||||||
|
## [2.3.0] — 2026-06-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Redesigned Settings around the Material 3 grouped-list pattern: a large
|
||||||
|
title that collapses into the toolbar as you scroll, category cards on the
|
||||||
|
main screen, and dedicated sub-pages for Appearance, the new-event form, and
|
||||||
|
Notifications. The theme, week-start and language pickers now use the app's
|
||||||
|
standard option-card dialogs instead of dropdown menus
|
||||||
|
- About moved to the top of Settings as a card — app icon, author, and quick
|
||||||
|
links to the source code and licence — with the version shown plainly at the
|
||||||
|
foot of the list
|
||||||
|
- The Calendars screen now uses the same grouped-card layout and collapsing
|
||||||
|
title, and each calendar shows a soft pastel-tinted calendar glyph rather
|
||||||
|
than a plain colour swatch
|
||||||
|
- Redesigned the navigation drawer to match: a branded header, the
|
||||||
|
Month / Week / Day switch and your calendars as grouped cards (with the
|
||||||
|
active view highlighted), and the whole drawer now scrolls as one
|
||||||
|
|
||||||
|
## [2.2.0] — 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Tap an empty slot in the day or week view to create an event there: the
|
||||||
|
create form opens prefilled with that day and the tapped hour (snapped to
|
||||||
|
the hour, one hour long). Tapping an existing event still opens it
|
||||||
|
- Local calendars: create and manage device-only calendars that live
|
||||||
|
entirely on this phone — no account, no sync — from a new "Calendars"
|
||||||
|
screen in Settings. Give each a name, a colour, and an optional
|
||||||
|
description; rename, recolour, or delete them later. Useful when you want
|
||||||
|
a calendar without setting up an account
|
||||||
|
- The Calendars screen also lists your synced calendars (DAVx5, ICSx5, …)
|
||||||
|
grouped by account, each with a "Manage" button that opens the app the
|
||||||
|
calendar actually comes from, plus an "Add account" shortcut to the
|
||||||
|
system account settings. Calendula never touches a synced calendar's
|
||||||
|
server itself — that stays with its own app
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Colour swatches in the calendar editor now preview the soft, pastel tone
|
||||||
|
a calendar is actually drawn with, instead of a bright raw colour
|
||||||
|
- The calendar editor reuses the event form's field and button styling for
|
||||||
|
a consistent look
|
||||||
|
|
||||||
|
## [2.1.0] — 2026-06-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- The month view now shows real events in each day instead of coloured
|
||||||
|
dots: all-day and multi-day events render as continuous bars at the top
|
||||||
|
(a multi-day event is one connected bar across the days it spans, not a
|
||||||
|
chip per day), with single-day timed events as filled pills beneath.
|
||||||
|
Up to three rows show per day, then a "+N" dot indicator for the rest.
|
||||||
|
Each day keeps a rounded surface background, matching the week and day
|
||||||
|
views; today is marked with a filled circle on its number
|
||||||
|
- The slide-out panel now has a "View" section to switch between Month,
|
||||||
|
Week, and Day, mirroring the top-bar switcher pill — tapping a view
|
||||||
|
selects it and closes the drawer. The current view is highlighted
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Typing in the event title, location, and description fields no longer
|
||||||
|
makes the cursor jump around: the form state's round-trip to the UI was
|
||||||
|
hopping to a background dispatcher, so the text field saw a lagging value
|
||||||
|
while typing. Only the calendar/preferences reads stay off the main
|
||||||
|
thread now; the keystroke path is synchronous again
|
||||||
|
|
||||||
|
## [2.0.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Conflict handling when saving an edit: if the event changed elsewhere
|
||||||
|
(sync, another device) while the form was open, saving now asks whether
|
||||||
|
to keep or discard your changes instead of silently overwriting the
|
||||||
|
edited fields — and tells you when the event was deleted in the meantime.
|
||||||
|
"Keep" still writes only the fields you touched; external changes to
|
||||||
|
untouched fields survive either way
|
||||||
|
- F-Droid store screenshots (German + English), captured with demo data
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- F-Droid description and README no longer claim the app is read-only —
|
||||||
|
they now describe write support and reminder delivery
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `versionName`/`versionCode` bumped to 2.0.0 / 13 — closing out the
|
||||||
|
write-support milestone (v1.1 through v2.0)
|
||||||
|
|
||||||
|
## [1.4.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Reminder notifications (v1.4): Calendula now delivers event reminders as
|
||||||
|
notifications itself — the system schedules them but posts nothing, so a
|
||||||
|
calendar app must (essential when Calendula is the only one installed).
|
||||||
|
Due reminders appear on a dedicated "Event reminders" channel; tapping one
|
||||||
|
opens the event's detail screen. Email reminders are never posted (the
|
||||||
|
provider only schedules alert-type reminders)
|
||||||
|
- A one-time onboarding step after the calendar grant introduces reminders,
|
||||||
|
requests the notification permission (Android 13+), and warns that a second
|
||||||
|
calendar app with notifications on will duplicate them. "Not now" leaves
|
||||||
|
the feature off
|
||||||
|
- Settings gained a "Notifications" section mirroring the choice: an event-
|
||||||
|
reminders toggle (default on) with the duplicate-reminders hint; turning it
|
||||||
|
on re-requests the notification permission when missing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `versionName`/`versionCode` bumped to 1.4.0 / 12
|
||||||
|
|
||||||
|
## [1.3.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Event editing: a pencil action on the detail screen (writable calendars
|
||||||
|
only) opens the event form prefilled with the event. Only fields you
|
||||||
|
actually changed are written back; saving an untouched form is a no-op.
|
||||||
|
Sections holding data are always shown, regardless of the form-field
|
||||||
|
defaults; the calendar itself can't be changed while editing
|
||||||
|
- Recurring events — scoped writes, chosen when saving (Google model):
|
||||||
|
"only this event" (a modified-occurrence exception), "this and all
|
||||||
|
following" (the series is split at the occurrence), or "all events in
|
||||||
|
the series". Changing the recurrence rule rules out "only this event"
|
||||||
|
- Deleting a recurring event gained the middle option too: "this and all
|
||||||
|
following events" ends the series just before the chosen occurrence
|
||||||
|
- Recurrence picker (create and edit): one-tap daily/weekly/monthly/yearly
|
||||||
|
presets plus a custom step with interval + unit, weekday toggles for
|
||||||
|
weekly rules ("every week on Mon and Fri"), and an end condition (never /
|
||||||
|
on a date / after a number of times). Rules the picker can't express
|
||||||
|
(e.g. "second Thursday monthly") are shown humanized and preserved
|
||||||
|
verbatim unless replaced. Recurrence also joined the optional form
|
||||||
|
fields and their settings defaults
|
||||||
|
- Validation: a repeat that would end before the event starts is flagged
|
||||||
|
(it would otherwise vanish from every view)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Editing reminders reconciles against the provider's actual rows:
|
||||||
|
reminders you didn't touch keep their method (e.g. email reminders on
|
||||||
|
synced events survive unrelated edits)
|
||||||
|
- The contextual WRITE_CALENDAR upgrade for v1.0 installs covers the edit
|
||||||
|
action like delete
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Splitting a series ("this and following") sends the complete time-column
|
||||||
|
set in one update, so the provider regenerates its cached instances — an
|
||||||
|
RRULE-only update left a stale duplicate of the tapped occurrence on the
|
||||||
|
split day
|
||||||
|
- RRULE UNTIL values are written as the local end of day expressed in UTC
|
||||||
|
(instead of a flat `T235959Z`), so recurrences can't leak an extra day in
|
||||||
|
timezones ahead of UTC
|
||||||
|
- `versionName`/`versionCode` bumped to 1.3.0 / 11
|
||||||
|
|
||||||
|
## [1.2.1] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Optional event-form fields with user-controlled defaults: reminders,
|
||||||
|
availability (busy/free), and visibility (default/public/private/
|
||||||
|
confidential) joined location and description as form sections. Settings
|
||||||
|
gained a "New event form" section choosing which show by default; the rest
|
||||||
|
unfold via a "More fields" picker
|
||||||
|
- Reminders editor: stacked rows with right-bound remove, full-width add
|
||||||
|
action; the picker offers one-tap presets and a custom amount + unit
|
||||||
|
(minutes/hours/days/weeks) step
|
||||||
|
- `OptionCard` — the app's standard selection-dialog row (full-width tonal
|
||||||
|
card, optional icon + supporting line, highlighted selection). All dialogs
|
||||||
|
(calendar, visibility, more-fields, reminder presets, recurring-delete)
|
||||||
|
now use it; radio-row dialogs are retired
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Event form redesigned onto the detail screen's design system: tonal cards
|
||||||
|
with gutter icons (top-aligned on tall cards), borderless inline text
|
||||||
|
fields, calendar-coloured accent bar under the title, no dividers, no
|
||||||
|
top-bar title; placeholders render clearly fainter than input
|
||||||
|
- M3 Expressive motion: the theme now provides a MotionScheme
|
||||||
|
(`MaterialExpressiveTheme`, standard springs — expressive bounce reviewed
|
||||||
|
as overdone), the FAB stack and "more fields" reveals animate on theme
|
||||||
|
springs
|
||||||
|
- The jump-to-today slide is direction-aware (future → today slides in from
|
||||||
|
the left, past → from the right)
|
||||||
|
- `versionName`/`versionCode` bumped to 1.2.1 / 10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The keyboard no longer pans the whole event form; the screen stays
|
||||||
|
anchored and the focused field scrolls into view (`adjustResize` +
|
||||||
|
`imePadding`)
|
||||||
|
|
||||||
|
## [1.2.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Create events (milestone 2, slice 2):
|
||||||
|
- A "+" FAB on the month, week, and day views opens a new full-screen event
|
||||||
|
form, prefilled with the visible day (today at the next full hour, or
|
||||||
|
09:00 on other days)
|
||||||
|
- The form covers title, all-day toggle, start/end with Material 3 date and
|
||||||
|
time pickers (moving the start drags the end along, preserving duration),
|
||||||
|
target calendar, location, and description
|
||||||
|
- The calendar picker offers only writable calendars and preselects the one
|
||||||
|
you last created an event in
|
||||||
|
- Validation on save ("ends before it starts", no writable calendar), with
|
||||||
|
the same contextual write-permission upgrade as delete
|
||||||
|
- All-day events are stored provider-correctly (UTC midnights, exclusive
|
||||||
|
end), timed events in the device time zone
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The jump-to-today pill now stacks above the new "+" FAB instead of being
|
||||||
|
the only floating action
|
||||||
|
- `versionName`/`versionCode` bumped to 1.2.0 / 9
|
||||||
|
|
||||||
|
## [1.1.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Write foundation (milestone 2, slice 1): Calendula can now **delete events**.
|
||||||
|
- Delete action on the event detail screen, with a confirmation dialog;
|
||||||
|
recurring events choose between "Only this event" (a cancelled exception,
|
||||||
|
so the rest of the series survives) and "All events in the series"
|
||||||
|
- `WRITE_CALENDAR` permission: onboarding asks for read+write in one system
|
||||||
|
dialog, but only read access is required — declining write keeps the app
|
||||||
|
fully usable read-only. Existing v1.0 installs are asked for the write
|
||||||
|
upgrade in place, on their first delete
|
||||||
|
- Read-only calendars (WebCal subscriptions, birthday calendars, …) are
|
||||||
|
detected via `CALENDAR_ACCESS_LEVEL` and show no edit/delete actions at all
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Onboarding copy no longer claims "read-only"; it now says your data stays on
|
||||||
|
the device (still no internet permission, still zero telemetry)
|
||||||
|
- The placeholder Edit button on the detail screen (a no-op since v0.4) is
|
||||||
|
removed until editing ships in a later slice
|
||||||
|
- `versionName`/`versionCode` bumped to 1.1.0 / 8
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-06-11
|
||||||
|
|
||||||
|
First public release. Calendula is a read-only, Material 3 Expressive calendar
|
||||||
|
that lives entirely on top of Android's `CalendarContract` — every calendar
|
||||||
|
synced to the device (CalDAV via DAVx5, Google, local, WebCal, …) shows up
|
||||||
|
automatically, with zero telemetry and no internet permission.
|
||||||
|
|
||||||
|
### Highlights (accumulated across v0.1 → v0.6)
|
||||||
|
- Month, week, and day views with a view switcher, swipe navigation, and
|
||||||
|
Loading / Failure / Success states on every screen
|
||||||
|
- Full-screen event detail surfacing every readable `CalendarContract` field —
|
||||||
|
times, recurrence (humanised), location, description (with tappable links),
|
||||||
|
attendees + roles + your own response, reminders, status, availability,
|
||||||
|
access level, and foreign time zones
|
||||||
|
- Per-calendar visibility filter (grouped by account, persisted) and a Settings
|
||||||
|
screen (theme, Material You dynamic colour, week start, app language)
|
||||||
|
- Material 3 Expressive first-run onboarding for calendar access
|
||||||
|
- German + English localization throughout
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `versionName`/`versionCode` bumped to 1.0.0 / 7
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Full event read (v0.6): the detail screen now surfaces every readable
|
||||||
|
`CalendarContract` field that V1 had been dropping —
|
||||||
|
- **Reminders** — each configured lead time, humanised ("10 minutes before",
|
||||||
|
"1 day before", "At time of event"), read from `CalendarContract.Reminders`
|
||||||
|
- **Status** — Tentative / Cancelled chip under the title; a cancelled event
|
||||||
|
also strikes through its title (Confirmed shows no chip)
|
||||||
|
- **Availability** — a "Free" pill pinned top-right of the title when the
|
||||||
|
event doesn't block your time (`Events.AVAILABILITY`, the iCal TRANSP
|
||||||
|
field); the default "Busy" is left implicit to avoid noise on every event
|
||||||
|
- **Access level** — a Private / Confidential chip when the event isn't public
|
||||||
|
- **Attendee role** — organizer / optional / resource badge under each
|
||||||
|
attendee, plus the device user's own response ("Your response: …") from
|
||||||
|
`Events.SELF_ATTENDEE_STATUS`
|
||||||
|
- **Time zone** — shown only for timed events pinned to a zone other than the
|
||||||
|
device's, so cross-zone events read unambiguously
|
||||||
|
- **Linked URLs** — http(s) links in the description are now tappable
|
||||||
|
- Domain model rounded out with `Reminder`, `EventStatus`, `Availability`,
|
||||||
|
`AccessLevel`, `AttendeeRelationship`, `AttendeeType`, and the attendee/self
|
||||||
|
status fields; mappers + unit tests cover every new column's integer codes
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Redesigned the first-run grant-access screen — the onboarding a new user
|
||||||
|
sees. Material 3 Expressive layout: branded launcher-mark hero, an app-name
|
||||||
|
eyebrow, a benefit-led headline, three trust rows (on-device, every calendar,
|
||||||
|
no tracking) with tonal icon chips, a full-width filled CTA with a trailing
|
||||||
|
arrow, and a "Read-only · no internet permission" footnote (the app declares
|
||||||
|
only `READ_CALENDAR`). The denied/recovery state shares the same shell with a
|
||||||
|
lock-badged hero and Open-settings / Try-again actions
|
||||||
|
- `versionName`/`versionCode` bumped to 0.6.0 / 6
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- A dedicated event **URL** field was dropped from scope: `CalendarContract`
|
||||||
|
has no `Events.URL` column (only `CUSTOM_APP_URI`, an app deep-link), so URLs
|
||||||
|
are surfaced by linkifying the description instead
|
||||||
|
|
||||||
## [0.5.0] — 2026-06-10
|
## [0.5.0] — 2026-06-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -16,7 +367,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
separate from the system VISIBLE flag) and applied centrally in the
|
separate from the system VISIBLE flag) and applied centrally in the
|
||||||
repository, so month/week/day re-filter live the moment a switch flips.
|
repository, so month/week/day re-filter live the moment a switch flips.
|
||||||
The drawer was trimmed to just Today, the calendar filter, and Settings
|
The drawer was trimmed to just Today, the calendar filter, and Settings
|
||||||
(the stubbed jump-to-date entry was removed; M2 returns in v1.0)
|
(the stubbed jump-to-date entry was removed; jump-to-date was later cut
|
||||||
|
from scope entirely)
|
||||||
- Settings (M4): a full-screen destination with
|
- Settings (M4): a full-screen destination with
|
||||||
- **Appearance** — theme (System / Light / Dark), Material You dynamic colour
|
- **Appearance** — theme (System / Light / Dark), Material You dynamic colour
|
||||||
(auto-disabled below Android 12), week start (Automatic / Monday / Sunday)
|
(auto-disabled below Android 12), week start (Automatic / Monday / Sunday)
|
||||||
|
|||||||
129
README.md
129
README.md
@@ -1,43 +1,120 @@
|
|||||||
# Calendula
|
<div align="center">
|
||||||
|
|
||||||
A modern Material 3 Expressive calendar app for Android.
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png" width="112" alt="Calendula icon">
|
||||||
|
|
||||||
Calendula is named after the flower of the same name, whose name comes from
|
<h1>Calendula</h1>
|
||||||
the Latin *kalendae* — the first day of the month — the same root as the
|
|
||||||
word "calendar". Calendula reads from Android's built-in `CalendarContract`,
|
|
||||||
so any calendar source synced to your device (CalDAV via DAVx5, Google,
|
|
||||||
local, WebCal subscriptions, ...) is shown.
|
|
||||||
|
|
||||||
## Features (V1)
|
<p><strong>A modern Material 3 Expressive calendar for Android.</strong><br>
|
||||||
|
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
||||||
|
|
||||||
- Month, Week, and Day views
|
<p>
|
||||||
- Read-only event details (write support comes in V2)
|
<a href="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions"><img src="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||||
- Multi-calendar visibility toggle
|
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
||||||
- Material You Dynamic Color (Android 12+)
|
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
||||||
- Light/Dark theme follows system
|
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
||||||
- German + English UI
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Building
|
<p>
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
|
||||||
|
</p>
|
||||||
|
|
||||||
Requires Android SDK 36 and JDK 17. The Gradle wrapper is checked in, so no host Gradle install is needed:
|
</div>
|
||||||
|
|
||||||
```bash
|
Calendula is named after the flower whose name — like the word *calendar* —
|
||||||
# Build debug APK
|
comes from the Latin *kalendae*, the first day of the month. It lives
|
||||||
./gradlew assembleDebug
|
entirely on top of Android's `CalendarContract`: any calendar synced to your
|
||||||
|
device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply
|
||||||
|
appears, and everything you create or edit syncs back the same way. No own
|
||||||
|
database, no sync stack reinvented.
|
||||||
|
|
||||||
# Run unit tests
|
## ✨ Features
|
||||||
./gradlew test
|
|
||||||
|
|
||||||
# Run lint
|
**Calendar**
|
||||||
./gradlew lint
|
|
||||||
|
- Month, week, and day views with a one-tap view switcher
|
||||||
|
- Full event details — attendees and their responses, reminders, recurrence
|
||||||
|
(humanized), availability, visibility, foreign time zones
|
||||||
|
- Per-calendar visibility toggle, grouped by account
|
||||||
|
|
||||||
|
**Editing**
|
||||||
|
|
||||||
|
- Create, edit, and delete events — including recurring events with scoped
|
||||||
|
writes: *only this event*, *this and all following*, or *the whole series*
|
||||||
|
- Recurrence picker with one-tap presets and custom rules (interval, weekday
|
||||||
|
toggles, end conditions); rules it can't express are preserved verbatim
|
||||||
|
- Conflict-safe saves: if an event changed elsewhere while you were editing,
|
||||||
|
Calendula asks instead of silently overwriting
|
||||||
|
- Read-only calendars (WebCal, birthdays) are detected and respected
|
||||||
|
|
||||||
|
**Reminders**
|
||||||
|
|
||||||
|
- Event reminders delivered by Calendula itself as notifications —
|
||||||
|
essential when it's your only calendar app, since Android delegates
|
||||||
|
reminder delivery to calendar apps
|
||||||
|
- Tap a reminder to land on the event
|
||||||
|
|
||||||
|
**Design & privacy**
|
||||||
|
|
||||||
|
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
||||||
|
expressive motion and shapes, light/dark theme
|
||||||
|
- German and English UI, per-app language setting
|
||||||
|
- **Zero telemetry, zero analytics, no internet permission** — your data
|
||||||
|
never leaves the device
|
||||||
|
|
||||||
|
## 📦 Install
|
||||||
|
|
||||||
|
Calendula ships through a self-hosted F-Droid repository; every version tag
|
||||||
|
is built, signed, and published there automatically.
|
||||||
|
|
||||||
|
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo
|
||||||
|
Store, …).
|
||||||
|
2. Add the repository — open this link on your phone, or paste it under
|
||||||
|
*Settings → Repositories → Add*:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
|
||||||
```
|
```
|
||||||
|
|
||||||
If your default JDK is something other than 17, set `JAVA_HOME` explicitly:
|
<sub>Repo: `https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo` ·
|
||||||
|
fingerprint (SHA-256):
|
||||||
|
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
||||||
|
|
||||||
|
3. Refresh, search for **Calendula**, install. Updates arrive like any
|
||||||
|
other F-Droid app.
|
||||||
|
|
||||||
|
Alternatively, build from source — see below.
|
||||||
|
|
||||||
|
## 🛠 Building
|
||||||
|
|
||||||
|
Requires Android SDK 36+ and JDK 17. The Gradle wrapper is checked in:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
JAVA_HOME=/path/to/jdk-17 ./gradlew assembleDebug
|
./gradlew assembleDebug # debug APK
|
||||||
|
./gradlew test # JVM unit tests
|
||||||
|
./gradlew lint # Android lint
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
If your default JDK is not 17, set `JAVA_HOME` explicitly.
|
||||||
|
|
||||||
|
## 🏗 Architecture
|
||||||
|
|
||||||
|
Single-activity Compose app, layered `UI → Repository → DataSource →
|
||||||
|
CalendarContract`, observer-driven refresh, JVM-first tests. The full tour —
|
||||||
|
including the recurring-write and reminder pipelines — lives in
|
||||||
|
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||||
|
|
||||||
|
## 🗺 Roadmap
|
||||||
|
|
||||||
|
Shipped: read (v1.0), write (v1.1–v2.0), reminder delivery (v1.4).
|
||||||
|
Next up: power-user features — widget, search, tablet layouts. The living
|
||||||
|
roadmap is in [.planning/ROADMAP.md](.planning/ROADMAP.md), the release
|
||||||
|
history in [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
|
## 📜 License
|
||||||
|
|
||||||
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ android {
|
|||||||
applicationId = "de.jeanlucmakiola.calendula"
|
applicationId = "de.jeanlucmakiola.calendula"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 5
|
// The git tag is the single source of truth for released builds: at
|
||||||
versionName = "0.5.0"
|
// release time .gitea/workflows/release.yaml derives both fields from
|
||||||
|
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
||||||
|
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
||||||
|
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
||||||
|
versionCode = 20700
|
||||||
|
versionName = "2.7.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -73,6 +78,15 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
// Community translations are expected to be partial — a missing string
|
||||||
|
// falls back to the English base at runtime — so don't fail the build on
|
||||||
|
// it. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||||
|
// check_translations.py guards the same invariants with clearer,
|
||||||
|
// translator-facing messages.
|
||||||
|
informational += "MissingTranslation"
|
||||||
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests {
|
unitTests {
|
||||||
all { it.useJUnitPlatform() }
|
all { it.useJUnitPlatform() }
|
||||||
@@ -108,6 +122,9 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
|
implementation(libs.androidx.glance.appwidget)
|
||||||
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
|
|||||||
11
app/proguard-rules.pro
vendored
11
app/proguard-rules.pro
vendored
@@ -4,3 +4,14 @@
|
|||||||
|
|
||||||
# Compose Compiler may keep its own; defaults are fine
|
# Compose Compiler may keep its own; defaults are fine
|
||||||
-dontwarn org.jetbrains.annotations.**
|
-dontwarn org.jetbrains.annotations.**
|
||||||
|
|
||||||
|
# Room database implementations (pulled in transitively via
|
||||||
|
# androidx.glance:glance-appwidget → androidx.work → androidx.room).
|
||||||
|
# The widgets rely on Glance, whose WorkManager backend stores state in a Room
|
||||||
|
# database. Under R8 full mode (AGP 9 default) the generated *_Impl subclasses
|
||||||
|
# of RoomDatabase lose their usable no-arg constructor / are marked abstract,
|
||||||
|
# so Room's reflective instantiation throws InstantiationException and the app
|
||||||
|
# crashes at startup with "Failed to create an instance of ...WorkDatabase".
|
||||||
|
# Keep the generated Room database implementations fully intact.
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
|
-dontwarn androidx.room.paging.**
|
||||||
|
|||||||
@@ -3,6 +3,27 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!--
|
||||||
|
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||||
|
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||||
|
delayed by Doze). Used only to launch that dialog; falls back to the
|
||||||
|
battery-optimisation list if the OS declines the direct intent.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
|
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||||
|
intent makes launchable apps visible so we can launch whichever app owns a
|
||||||
|
calendar account's authenticator. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".CalendulaApp"
|
android:name=".CalendulaApp"
|
||||||
@@ -11,19 +32,114 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Calendula"
|
android:theme="@style/Theme.Calendula"
|
||||||
tools:targetApi="35">
|
tools:targetApi="35">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||||
|
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- Receive a .ics shared from another app. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/calendar" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||||
|
no notification itself — a calendar app must (v1.4, Etar model).
|
||||||
|
Exported: the broadcast arrives from the provider's process. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".data.reminders.EventReminderReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.EVENT_REMINDER" />
|
||||||
|
<data
|
||||||
|
android:host="com.android.calendar"
|
||||||
|
android:scheme="content" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||||
|
android:label="@string/widget_agenda_label"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/appwidget_info_agenda" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.month.MonthWidgetReceiver"
|
||||||
|
android:label="@string/widget_month_label"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/appwidget_info_month" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||||
|
PROVIDER_CHANGED on any data change (our writes and external sync),
|
||||||
|
and the system broadcasts the date/time ones at midnight / clock
|
||||||
|
changes so "today" highlighting rolls over. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.PROVIDER_CHANGED" />
|
||||||
|
<data
|
||||||
|
android:host="com.android.calendar"
|
||||||
|
android:scheme="content" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Hands .ics files we stage in the cache to other apps via a content
|
||||||
|
Uri (single-event share). Authority tracks applicationId so the
|
||||||
|
debug suffix doesn't break getUriForFile. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
||||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -1,26 +1,52 @@
|
|||||||
package de.jeanlucmakiola.calendula
|
package de.jeanlucmakiola.calendula
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
|
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.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||||
|
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||||
|
// tap into the running activity; CalendarHost consumes and clears it.
|
||||||
|
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
|
||||||
|
|
||||||
|
// A navigation a home-screen widget asked for (open a date / start a
|
||||||
|
// create). Consumed once by CalendarHost, same pattern as the detail key.
|
||||||
|
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
|
||||||
|
|
||||||
|
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
|
||||||
|
// by CalendarHost's import flow.
|
||||||
|
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
requestedDetailKey = intent.detailKeyOrNull()
|
||||||
|
requestedNav = intent.navRequestOrNull()
|
||||||
|
requestedImportUri = intent.importUriOrNull()
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
// and the Settings screen, so a theme change applies app-wide at once.
|
// and the Settings screen, so a theme change applies app-wide at once.
|
||||||
@@ -35,8 +61,105 @@ class MainActivity : ComponentActivity() {
|
|||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
) {
|
) {
|
||||||
RootScreen(modifier = Modifier.fillMaxSize())
|
RootScreen(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
requestedDetailKey = requestedDetailKey,
|
||||||
|
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||||
|
widgetNavRequest = requestedNav,
|
||||||
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = { requestedImportUri = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||||
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
||||||
|
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
||||||
|
* app's own `calendula://` deep-links never match.
|
||||||
|
*/
|
||||||
|
private fun Intent.importUriOrNull(): Uri? {
|
||||||
|
val uri = when (action) {
|
||||||
|
Intent.ACTION_VIEW -> data
|
||||||
|
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
else -> null
|
||||||
|
} ?: return null
|
||||||
|
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
||||||
|
// Launcher long-press "New event" shortcut. Static shortcut intents
|
||||||
|
// can't carry typed extras, so the action alone signals create-on-today.
|
||||||
|
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
||||||
|
getBooleanExtra(EXTRA_CREATE, false) ->
|
||||||
|
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
||||||
|
getStringExtra(EXTRA_DATE_ISO) != null ->
|
||||||
|
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent.detailKeyOrNull(): LongArray? {
|
||||||
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
|
if (eventId == -1L) return null
|
||||||
|
return longArrayOf(
|
||||||
|
eventId,
|
||||||
|
getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
|
||||||
|
getLongExtra(EXTRA_END_MILLIS, 0L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
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_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||||
|
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||||
|
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||||
|
|
||||||
|
// Fired by the launcher long-press "New event" shortcut (res/xml/
|
||||||
|
// shortcuts.xml hardcodes this string — keep the two in sync).
|
||||||
|
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intent opening the detail screen of one occurrence (reminder
|
||||||
|
* notifications). The synthetic data URI keys the intent so
|
||||||
|
* PendingIntents for different occurrences never collapse into one.
|
||||||
|
*/
|
||||||
|
fun eventDetailIntent(
|
||||||
|
context: Context,
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
): Intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://event/$eventId/$beginMillis".toUri()
|
||||||
|
putExtra(EXTRA_EVENT_ID, eventId)
|
||||||
|
putExtra(EXTRA_BEGIN_MILLIS, beginMillis)
|
||||||
|
putExtra(EXTRA_END_MILLIS, endMillis)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the day view anchored on [date] (home-screen widgets). */
|
||||||
|
fun openDateIntent(context: Context, date: LocalDate): Intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://date/$date".toUri()
|
||||||
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the create-event form prefilled for [date] (home-screen widgets). */
|
||||||
|
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://create/$date".toUri()
|
||||||
|
putExtra(EXTRA_CREATE, true)
|
||||||
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates an all-day reminder between the **semantic** lead time the UI
|
||||||
|
* speaks (whole days before the event — "1 day before") and the **raw**
|
||||||
|
* `CalendarContract.Reminders.MINUTES` offset the provider stores.
|
||||||
|
*
|
||||||
|
* Calendula schedules no alarms itself: the provider fires a reminder at
|
||||||
|
* `DTSTART − MINUTES` (the Etar model). An all-day event's DTSTART is **UTC
|
||||||
|
* midnight** (see [EventWriteTimes]), so a raw `MINUTES = 1440` ("1 day") lands
|
||||||
|
* on UTC-midnight of the previous day — 02:00 local in CEST, not the morning.
|
||||||
|
*
|
||||||
|
* To fire at a chosen wall-clock time we encode that time *into* the offset:
|
||||||
|
* `MINUTES = UTC-midnight(startDate) − (localInstant of [timeOfDayMinutes] on the
|
||||||
|
* day [semanticMinutes] before)`. The single fixed offset can only be tuned for
|
||||||
|
* the event's own date, so a recurring all-day series or a post-creation
|
||||||
|
* timezone change drifts the fire time by the offset delta (±1h across DST) —
|
||||||
|
* an inherent limit of the provider model, shared by Etar.
|
||||||
|
*/
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw provider `MINUTES` for an all-day reminder set [semanticMinutes] before the
|
||||||
|
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
|
||||||
|
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
|
||||||
|
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to −420, meaning
|
||||||
|
* the provider fires *after* DTSTART; this is valid and must not be clamped.
|
||||||
|
* A negative [semanticMinutes] is the "provider default" sentinel and passes
|
||||||
|
* through unchanged.
|
||||||
|
*/
|
||||||
|
internal fun toProviderAllDayMinutes(
|
||||||
|
semanticMinutes: Int,
|
||||||
|
startDate: LocalDate,
|
||||||
|
zone: ZoneId,
|
||||||
|
timeOfDayMinutes: Int,
|
||||||
|
): Int {
|
||||||
|
if (semanticMinutes < 0) return semanticMinutes
|
||||||
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
|
||||||
|
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
||||||
|
.atZone(zone).toInstant().toEpochMilli()
|
||||||
|
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||||
|
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||||
|
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
||||||
|
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
||||||
|
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
||||||
|
* after DTSTART) folds to day 0.
|
||||||
|
*/
|
||||||
|
internal fun fromProviderAllDayMinutes(
|
||||||
|
rawMinutes: Int,
|
||||||
|
startDate: LocalDate,
|
||||||
|
zone: ZoneId,
|
||||||
|
): Int {
|
||||||
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
||||||
|
.atZone(zone).toLocalDate()
|
||||||
|
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
||||||
|
}
|
||||||
@@ -2,17 +2,31 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
|
import android.util.Log
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
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.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -28,6 +42,116 @@ interface CalendarDataSource {
|
|||||||
fun calendars(): List<CalendarSource>
|
fun calendars(): List<CalendarSource>
|
||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
fun eventDetail(eventId: Long): EventDetail?
|
fun eventDetail(eventId: Long): EventDetail?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event-colour palette the calendar's account publishes
|
||||||
|
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
||||||
|
* account exposes no palette (most local calendars, some CalDAV) — the
|
||||||
|
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
|
||||||
|
* which a synced calendar may drop on its next sync.
|
||||||
|
*/
|
||||||
|
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every master/one-off event of the writable local calendars, mapped for a
|
||||||
|
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||||
|
* rows are excluded (see [EventExportProjection]).
|
||||||
|
*/
|
||||||
|
fun exportableEvents(): List<IcsEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||||
|
* dedup an `.ics` import so re-importing a backup doesn't double events.
|
||||||
|
*/
|
||||||
|
fun existingUids(calendarId: Long): Set<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||||
|
* minting one when absent); returns the new `Events._ID`. Reminders are
|
||||||
|
* written as the file's raw lead minutes (METHOD_ALERT).
|
||||||
|
*/
|
||||||
|
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||||
|
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
||||||
|
* provider keeps the row (a plain insert is rejected for the LOCAL account).
|
||||||
|
*/
|
||||||
|
fun createLocalCalendar(displayName: String, color: Int, description: String?): Long
|
||||||
|
|
||||||
|
/** Update name, color and description of a local calendar the app owns. */
|
||||||
|
fun updateCalendar(id: Long, displayName: String, color: Int, description: String?)
|
||||||
|
|
||||||
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
|
fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||||
|
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||||
|
* should fire at — encoded into each all-day reminder's provider offset
|
||||||
|
* (ignored for timed events).
|
||||||
|
*/
|
||||||
|
fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing event (for recurring events: the whole series) to
|
||||||
|
* match [updated]. [original] is the form as it was prefilled from the
|
||||||
|
* event, so only fields the user actually changed are written and the
|
||||||
|
* reminder rows can be diffed instead of wiped.
|
||||||
|
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
|
*/
|
||||||
|
fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
|
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||||
|
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
|
*/
|
||||||
|
fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a recurring event from the occurrence at [beginMillis] onwards
|
||||||
|
* by splitting the series: the existing RRULE ends just before the
|
||||||
|
* occurrence and a new event with [updated]'s values (and rule) starts
|
||||||
|
* there; returns the new event's `Events._ID`. From the first occurrence
|
||||||
|
* this is a plain series update. A carried-over COUNT restarts counting
|
||||||
|
* in the new series (we don't recompute the remaining occurrences).
|
||||||
|
*/
|
||||||
|
fun updateEventFromOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a recurring event from the occurrence at [beginMillis] onwards
|
||||||
|
* by ending the series RRULE just before it. Deleting from the first
|
||||||
|
* occurrence removes the whole event.
|
||||||
|
*/
|
||||||
|
fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
|
||||||
|
|
||||||
|
/** Delete the whole event (for recurring events: the entire series). */
|
||||||
|
fun deleteEvent(eventId: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a single occurrence of a recurring event by inserting a
|
||||||
|
* cancelled exception at [beginMillis] (the occurrence's `Instances.BEGIN`).
|
||||||
|
*/
|
||||||
|
fun deleteOccurrence(eventId: Long, beginMillis: Long)
|
||||||
|
|
||||||
fun registerChangeListener(listener: () -> Unit)
|
fun registerChangeListener(listener: () -> Unit)
|
||||||
fun unregisterChangeListener(listener: () -> Unit)
|
fun unregisterChangeListener(listener: () -> Unit)
|
||||||
}
|
}
|
||||||
@@ -47,6 +171,76 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
||||||
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar-row writes must address the provider as a sync adapter and name
|
||||||
|
* the account in the URI; otherwise inserts/deletes for the LOCAL account
|
||||||
|
* are silently dropped or only soft-deleted.
|
||||||
|
*/
|
||||||
|
private fun localCalendarsUri(): Uri = CalendarContract.Calendars.CONTENT_URI.buildUpon()
|
||||||
|
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||||
|
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
|
.appendQueryParameter(
|
||||||
|
CalendarContract.Calendars.ACCOUNT_TYPE,
|
||||||
|
CalendarContract.ACCOUNT_TYPE_LOCAL,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||||
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
|
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
|
||||||
|
// NAME is the sync-adapter id; DISPLAY_NAME is what the user sees.
|
||||||
|
put(CalendarContract.Calendars.NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
|
||||||
|
put(
|
||||||
|
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||||
|
CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||||
|
)
|
||||||
|
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||||
|
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||||
|
putDescription(description)
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(localCalendarsUri(), values)
|
||||||
|
?: throw WriteFailedException("create local calendar '$name'")
|
||||||
|
return ContentUris.parseId(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
||||||
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||||
|
put(CalendarContract.Calendars.NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
|
||||||
|
putDescription(description)
|
||||||
|
}
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(localCalendarsUri(), id),
|
||||||
|
values, null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) throw WriteFailedException("update calendar id=$id")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store the description in CAL_SYNC1, or clear it when blank/absent. */
|
||||||
|
private fun ContentValues.putDescription(description: String?) {
|
||||||
|
val text = description?.trim().orEmpty()
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
putNull(CalendarProjection.DESCRIPTION_COLUMN)
|
||||||
|
} else {
|
||||||
|
put(CalendarProjection.DESCRIPTION_COLUMN, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteCalendar(id: Long) {
|
||||||
|
val deleted = resolver.delete(
|
||||||
|
ContentUris.withAppendedId(localCalendarsUri(), id),
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||||
|
}
|
||||||
|
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
ContentUris.appendId(this, beginMillis)
|
ContentUris.appendId(this, beginMillis)
|
||||||
@@ -62,16 +256,462 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
override fun eventDetail(eventId: Long): EventDetail? {
|
override fun eventDetail(eventId: Long): EventDetail? {
|
||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
|
val reminders = queryReminders(eventId)
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
EventDetailProjection.COLUMNS,
|
EventDetailProjection.COLUMNS,
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
if (!c.moveToFirst()) null
|
if (!c.moveToFirst()) null
|
||||||
else CursorColumnReader(c).toEventDetailCore(attendees)
|
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> {
|
||||||
|
val account = calendarAccount(calendarId) ?: return emptyList()
|
||||||
|
return resolver.query(
|
||||||
|
CalendarContract.Colors.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Colors.COLOR_KEY, CalendarContract.Colors.COLOR),
|
||||||
|
CalendarContract.Colors.ACCOUNT_NAME + " = ? AND " +
|
||||||
|
CalendarContract.Colors.ACCOUNT_TYPE + " = ? AND " +
|
||||||
|
CalendarContract.Colors.COLOR_TYPE + " = ?",
|
||||||
|
arrayOf(
|
||||||
|
account.name,
|
||||||
|
account.type,
|
||||||
|
CalendarContract.Colors.TYPE_EVENT.toString(),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||||
|
}
|
||||||
|
?.filter { it.key.isNotEmpty() }
|
||||||
|
?.sortedBy { it.key }
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exportableEvents(): List<IcsEvent> {
|
||||||
|
// Only the local calendars the app owns and can write — synced calendars
|
||||||
|
// already have a backup (their server). Map id → display name for the
|
||||||
|
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||||
|
val names = calendars()
|
||||||
|
.filter { it.isLocal && it.canModifyContents }
|
||||||
|
.associate { it.id to it.displayName }
|
||||||
|
if (names.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val idList = names.keys.joinToString(",")
|
||||||
|
return resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
EventExportProjection.COLUMNS,
|
||||||
|
// Skip soft-deleted rows and exception rows (modified occurrences /
|
||||||
|
// cancellations) — v1 exports masters + one-offs only.
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} IN ($idList) AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.ORIGINAL_ID} IS NULL",
|
||||||
|
null,
|
||||||
|
CalendarContract.Events.DTSTART + " ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
c.mapAll {
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
val eventId = reader.getLong(EventExportProjection.IDX_ID)
|
||||||
|
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID)
|
||||||
|
reader.toIcsEvent(
|
||||||
|
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||||
|
calendarName = names[calendarId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Events.UID_2445),
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||||
|
arrayOf(calendarId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
||||||
|
} ?: emptySet()
|
||||||
|
|
||||||
|
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||||
|
val startMillis = event.start.toEpochMillis()
|
||||||
|
val endMillis = event.end.toEpochMillis()
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||||
|
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||||
|
// only when the source event carried none.
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, startMillis)
|
||||||
|
if (event.recurrenceRule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, endMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
importDuration(startMillis, endMillis, event.isAllDay),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||||
|
// timed rows keep the event's own zone.
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||||
|
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
|
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||||
|
val eventId = ContentUris.parseId(uri)
|
||||||
|
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
|
||||||
|
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
|
||||||
|
val reminder = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider DURATION for an imported recurring row: whole days / seconds. */
|
||||||
|
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||||
|
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||||
|
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||||
|
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||||
|
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||||
|
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||||
|
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.Calendars.ACCOUNT_NAME,
|
||||||
|
CalendarContract.Calendars.ACCOUNT_TYPE,
|
||||||
|
),
|
||||||
|
null, null, null,
|
||||||
|
)?.use { c ->
|
||||||
|
if (c.moveToFirst()) {
|
||||||
|
CalendarAccount(name = c.getString(0).orEmpty(), type = c.getString(1).orEmpty())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CalendarAccount(val name: String, val type: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw provider `MINUTES` to store for one of [form]'s reminders: an
|
||||||
|
* all-day reminder is shifted to fire at [allDayReminderTimeMinutes] local
|
||||||
|
* (see [toProviderAllDayMinutes]); a timed reminder is its lead time as-is.
|
||||||
|
*/
|
||||||
|
private fun providerReminderMinutes(
|
||||||
|
form: EventForm,
|
||||||
|
minutes: Int,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Int = if (form.isAllDay) {
|
||||||
|
toProviderAllDayMinutes(
|
||||||
|
semanticMinutes = minutes,
|
||||||
|
startDate = form.start.date.toJavaLocalDate(),
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [form]'s reminders as the distinct raw provider offsets to store. */
|
||||||
|
private fun encodedReminders(form: EventForm, allDayReminderTimeMinutes: Int): List<Int> =
|
||||||
|
form.reminders
|
||||||
|
.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||||
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||||
|
)
|
||||||
|
// A globally-unique UID so a later .ics backup/restore can identify
|
||||||
|
// the event and not duplicate it on re-import (the provider leaves
|
||||||
|
// this null for events it didn't sync). Older rows without one fall
|
||||||
|
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||||
|
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
|
||||||
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
|
// The provider's invariant: recurring rows carry RRULE+DURATION
|
||||||
|
// (and no DTEND), one-off rows carry DTEND.
|
||||||
|
if (form.rrule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, form.rrule)
|
||||||
|
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||||
|
}
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
|
form.location.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
form.description.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
// A null colour just leaves both columns unset (the event inherits
|
||||||
|
// its calendar's colour), so only the key/raw cases are written.
|
||||||
|
when {
|
||||||
|
form.colorKey != null ->
|
||||||
|
put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey)
|
||||||
|
form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
|
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||||
|
val eventId = ContentUris.parseId(uri)
|
||||||
|
// Best effort (spec §8): the event exists at this point — a reminder
|
||||||
|
// that fails to attach is logged, not surfaced as a failed create.
|
||||||
|
encodedReminders(form, allDayReminderTimeMinutes)
|
||||||
|
.forEach { minutes ->
|
||||||
|
val reminder = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
) {
|
||||||
|
val values = buildEventUpdateValues(
|
||||||
|
original = original,
|
||||||
|
updated = updated,
|
||||||
|
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
)
|
||||||
|
if (values.isNotEmpty()) {
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values.toContentValues(),
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
||||||
|
}
|
||||||
|
// Untouched reminder sets are left alone so unrelated edits can't
|
||||||
|
// disturb provider rows the form never knew about. The diff is on the
|
||||||
|
// form's semantic minutes; reconcile works in encoded provider minutes.
|
||||||
|
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||||
|
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
|
// The provider clones the series row and applies these values on top.
|
||||||
|
val values = buildOccurrenceExceptionValues(
|
||||||
|
form = form,
|
||||||
|
originalInstanceMillis = beginMillis,
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
)
|
||||||
|
val uri = resolver.insert(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId),
|
||||||
|
values.toContentValues(),
|
||||||
|
) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis")
|
||||||
|
val exceptionId = ContentUris.parseId(uri)
|
||||||
|
// Whether the provider copied the parent's reminder rows is its
|
||||||
|
// business — reconciling against the actual rows handles both ways.
|
||||||
|
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||||
|
return exceptionId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateEventFromOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
|
val row = querySeriesRow(eventId)
|
||||||
|
// From the first occurrence on (or with no rule to split) this is
|
||||||
|
// just a series update.
|
||||||
|
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||||
|
updateEvent(eventId, original, updated, allDayReminderTimeMinutes)
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
// Insert the new series first: if it fails, the original is untouched.
|
||||||
|
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
||||||
|
truncateSeries(eventId, row, beginMillis)
|
||||||
|
return newEventId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
|
val row = querySeriesRow(eventId)
|
||||||
|
// From the first occurrence on = the whole series; also the fallback
|
||||||
|
// when there is no RRULE to truncate.
|
||||||
|
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||||
|
deleteEvent(eventId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
truncateSeries(eventId, row, beginMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End [row]'s series just before the occurrence at [beginMillis]. The
|
||||||
|
* provider regenerates an event's cached instances only from the values
|
||||||
|
* carried by the update itself — an RRULE-only update leaves the old
|
||||||
|
* instances standing (observed on-device: the truncated occurrence kept
|
||||||
|
* showing) — so the entire time-related set travels together, with only
|
||||||
|
* the RRULE actually changing.
|
||||||
|
*/
|
||||||
|
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
|
||||||
|
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
|
||||||
|
put(CalendarContract.Events.DURATION, row.duration)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, row.allDay)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values,
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) {
|
||||||
|
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The series anchor: every time-related column of the Events row. */
|
||||||
|
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
),
|
||||||
|
null, null, null,
|
||||||
|
)?.use { c ->
|
||||||
|
if (c.moveToFirst()) {
|
||||||
|
SeriesRow(
|
||||||
|
dtStartMillis = c.getLong(0),
|
||||||
|
rrule = c.getString(1),
|
||||||
|
timezone = c.getString(2),
|
||||||
|
duration = c.getString(3),
|
||||||
|
allDay = c.getInt(4),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} ?: throw WriteFailedException("read series row of event id=$eventId")
|
||||||
|
|
||||||
|
private data class SeriesRow(
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val rrule: String?,
|
||||||
|
val timezone: String?,
|
||||||
|
val duration: String?,
|
||||||
|
val allDay: Int,
|
||||||
|
) {
|
||||||
|
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||||
|
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||||
|
beginMillis = beginMillis,
|
||||||
|
zone = runCatching { ZoneId.of(timezone ?: "UTC") }.getOrDefault(ZoneOffset.UTC),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the event's reminder rows match [targetMinutes] — the raw provider
|
||||||
|
* offsets to store (already encoded via [encodedReminders], so all-day shifts
|
||||||
|
* are baked in and the diff matches the stored rows). Rows with other offsets
|
||||||
|
* are deleted, missing ones inserted as best-effort ALERTs (like insertEvent).
|
||||||
|
* Rows whose minutes survive keep their method.
|
||||||
|
*/
|
||||||
|
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
|
||||||
|
val target = targetMinutes.toSet()
|
||||||
|
val existing = queryReminders(eventId).map { it.minutes }.toSet()
|
||||||
|
(existing - target).forEach { minutes ->
|
||||||
|
resolver.delete(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
CalendarContract.Reminders.EVENT_ID + " = ? AND " +
|
||||||
|
CalendarContract.Reminders.MINUTES + " = ?",
|
||||||
|
arrayOf(eventId.toString(), minutes.toString()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(target - existing).forEach { minutes ->
|
||||||
|
val reminder = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
||||||
|
ContentValues().also { cv ->
|
||||||
|
forEach { (column, value) ->
|
||||||
|
when (value) {
|
||||||
|
null -> cv.putNull(column)
|
||||||
|
is String -> cv.put(column, value)
|
||||||
|
is Long -> cv.put(column, value)
|
||||||
|
is Int -> cv.put(column, value)
|
||||||
|
else -> error("Unsupported value for $column: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteEvent(eventId: Long) {
|
||||||
|
val deleted = resolver.delete(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
|
// A cancelled exception row hides exactly this occurrence; the sync
|
||||||
|
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
|
||||||
|
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||||
|
}
|
||||||
|
val uri = ContentUris.withAppendedId(
|
||||||
|
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
||||||
|
)
|
||||||
|
resolver.insert(uri, values)
|
||||||
|
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
||||||
|
}
|
||||||
|
|
||||||
override fun registerChangeListener(listener: () -> Unit) {
|
override fun registerChangeListener(listener: () -> Unit) {
|
||||||
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||||
override fun onChange(selfChange: Boolean) {
|
override fun onChange(selfChange: Boolean) {
|
||||||
@@ -98,6 +738,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
null,
|
null,
|
||||||
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
|
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
|
||||||
|
|
||||||
|
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
ReminderProjection.COLUMNS,
|
||||||
|
CalendarContract.Reminders.EVENT_ID + " = ?",
|
||||||
|
arrayOf(eventId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList()
|
||||||
|
|
||||||
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
|
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
|
||||||
|
|
||||||
/** Iterate every row and map; skips nothing. */
|
/** Iterate every row and map; skips nothing. */
|
||||||
@@ -109,4 +757,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
|
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
|
||||||
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "CalendarDataSource"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared account for every app-created local calendar, so they group
|
||||||
|
* together (by account) in the filter sheet and calendar manager.
|
||||||
|
*/
|
||||||
|
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
|
||||||
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
|
internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||||
|
val accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty()
|
||||||
|
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||||
|
return CalendarSource(
|
||||||
id = getLong(CalendarProjection.IDX_ID),
|
id = getLong(CalendarProjection.IDX_ID),
|
||||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
||||||
?: Fallbacks.UNNAMED_CALENDAR,
|
?: Fallbacks.UNNAMED_CALENDAR,
|
||||||
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
||||||
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
|
accountType = accountType,
|
||||||
color = getInt(CalendarProjection.IDX_COLOR),
|
color = getInt(CalendarProjection.IDX_COLOR),
|
||||||
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
|
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
|
||||||
|
canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >=
|
||||||
|
CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR,
|
||||||
|
isLocal = isLocal,
|
||||||
|
// CAL_SYNC1 holds the sync token for synced rows, so only treat it as a
|
||||||
|
// user description on the local calendars the app owns.
|
||||||
|
description = if (isLocal) {
|
||||||
|
getString(CalendarProjection.IDX_DESCRIPTION)?.takeIf { it.isNotBlank() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -10,7 +15,74 @@ 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>>
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event-colour palette a calendar's account publishes; empty when it
|
||||||
|
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
||||||
|
*/
|
||||||
|
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
|
/** Create a device-only (LOCAL) calendar the app owns; returns its id. */
|
||||||
|
suspend fun createLocalCalendar(displayName: String, color: Int, description: String?): Long
|
||||||
|
|
||||||
|
/** Update name, color and description of a local calendar the app owns. */
|
||||||
|
suspend fun updateCalendar(id: Long, displayName: String, color: Int, description: String?)
|
||||||
|
|
||||||
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
|
suspend fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every event of the writable local calendars, ready to serialise into a
|
||||||
|
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||||
|
*/
|
||||||
|
suspend fun exportEvents(): List<IcsEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||||
|
* UID already exists in the target are skipped (idempotent restore); the
|
||||||
|
* rest are inserted. See [CalendarDataSource.insertImportedEvent].
|
||||||
|
*/
|
||||||
|
suspend fun importEvents(targetCalendarId: Long, events: List<ParsedIcsEvent>): IcsImportSummary
|
||||||
|
|
||||||
|
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||||
|
suspend fun createEvent(form: EventForm): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an event (recurring: the whole series) from a validated form.
|
||||||
|
* [original] is the prefilled form, used to write only what changed.
|
||||||
|
*/
|
||||||
|
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a single occurrence of a recurring event (exception row with the
|
||||||
|
* form's values); returns the exception's `Events._ID`.
|
||||||
|
*/
|
||||||
|
suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a recurring event from [beginMillis] onwards (series split);
|
||||||
|
* returns the new event's `Events._ID`.
|
||||||
|
*/
|
||||||
|
suspend fun updateEventFromOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
): Long
|
||||||
|
|
||||||
|
/** Delete the whole event (for recurring events: the entire series). */
|
||||||
|
suspend fun deleteEvent(eventId: Long)
|
||||||
|
|
||||||
|
/** Cancel a single occurrence of a recurring event at its `Instances.BEGIN` time. */
|
||||||
|
suspend fun deleteOccurrence(eventId: Long, beginMillis: Long)
|
||||||
|
|
||||||
|
/** Delete a recurring event from the occurrence at [beginMillis] onwards. */
|
||||||
|
suspend fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
|
||||||
}
|
}
|
||||||
|
|
||||||
class NoSuchEventException(eventId: Long) :
|
class NoSuchEventException(eventId: Long) :
|
||||||
NoSuchElementException("No event with id=$eventId")
|
NoSuchElementException("No event with id=$eventId")
|
||||||
|
|
||||||
|
/** A ContentResolver write affected no rows or returned no URI. */
|
||||||
|
class WriteFailedException(operation: String) :
|
||||||
|
RuntimeException("Calendar write failed: $operation")
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
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
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
@@ -26,9 +32,14 @@ import javax.inject.Singleton
|
|||||||
class CalendarRepositoryImpl @Inject constructor(
|
class CalendarRepositoryImpl @Inject constructor(
|
||||||
private val dataSource: CalendarDataSource,
|
private val dataSource: CalendarDataSource,
|
||||||
private val prefs: CalendarPrefs,
|
private val prefs: CalendarPrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : CalendarRepository {
|
) : CalendarRepository {
|
||||||
|
|
||||||
|
/** The configured wall-clock fire time for all-day reminders, read per write. */
|
||||||
|
private suspend fun allDayReminderTimeMinutes(): Int =
|
||||||
|
settingsPrefs.allDayReminderTimeMinutes.first()
|
||||||
|
|
||||||
private val ticks = MutableSharedFlow<Unit>(
|
private val ticks = MutableSharedFlow<Unit>(
|
||||||
replay = 0,
|
replay = 0,
|
||||||
extraBufferCapacity = 1,
|
extraBufferCapacity = 1,
|
||||||
@@ -68,6 +79,95 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
||||||
|
|
||||||
|
override suspend fun createLocalCalendar(
|
||||||
|
displayName: String,
|
||||||
|
color: Int,
|
||||||
|
description: String?,
|
||||||
|
): Long = withContext(io) {
|
||||||
|
dataSource.createLocalCalendar(displayName, color, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateCalendar(
|
||||||
|
id: Long,
|
||||||
|
displayName: String,
|
||||||
|
color: Int,
|
||||||
|
description: String?,
|
||||||
|
) = withContext(io) { dataSource.updateCalendar(id, displayName, color, description) }
|
||||||
|
|
||||||
|
override suspend fun deleteCalendar(id: Long) =
|
||||||
|
withContext(io) { dataSource.deleteCalendar(id) }
|
||||||
|
|
||||||
|
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
|
||||||
|
|
||||||
|
override suspend fun importEvents(
|
||||||
|
targetCalendarId: Long,
|
||||||
|
events: List<ParsedIcsEvent>,
|
||||||
|
): IcsImportSummary = withContext(io) {
|
||||||
|
val existing = dataSource.existingUids(targetCalendarId)
|
||||||
|
var imported = 0
|
||||||
|
var skipped = 0
|
||||||
|
for (event in events) {
|
||||||
|
// A known UID means the event is already in this calendar — skip,
|
||||||
|
// keeping a restore idempotent (no overwrite this pass).
|
||||||
|
if (event.uid != null && event.uid in existing) {
|
||||||
|
skipped++
|
||||||
|
} else {
|
||||||
|
dataSource.insertImportedEvent(event, targetCalendarId)
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
|
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
) = withContext(io) {
|
||||||
|
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||||
|
dataSource.deleteEvent(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
): Long = withContext(io) {
|
||||||
|
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateEventFromOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
): Long = withContext(io) {
|
||||||
|
dataSource.updateEventFromOccurrence(
|
||||||
|
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
||||||
|
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteEventFromOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
) = withContext(io) {
|
||||||
|
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||||
|
|||||||
@@ -2,14 +2,27 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
private const val TAG = "EventDetailMapper"
|
private const val TAG = "EventDetailMapper"
|
||||||
|
|
||||||
internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDetail? {
|
internal fun ColumnReader.toEventDetailCore(
|
||||||
|
attendees: List<Attendee>,
|
||||||
|
reminders: List<Reminder>,
|
||||||
|
): EventDetail? {
|
||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
if (begin < 0L) {
|
if (begin < 0L) {
|
||||||
@@ -32,16 +45,23 @@ internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDet
|
|||||||
rawEnd
|
rawEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
|
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
// localized placeholder, and the edit form must prefill the true value.
|
||||||
|
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
|
||||||
|
|
||||||
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
// The event's own colour (null = inherits the calendar's) is kept apart
|
||||||
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
// from the resolved display colour: the edit form needs to tell the two
|
||||||
|
// cases apart, while the instance carries the calendar fallback for display.
|
||||||
|
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
||||||
|
null
|
||||||
} else {
|
} else {
|
||||||
getInt(EventDetailProjection.IDX_EVENT_COLOR)
|
getInt(EventDetailProjection.IDX_EVENT_COLOR)
|
||||||
}
|
}
|
||||||
|
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
|
||||||
|
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||||
|
|
||||||
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
||||||
|
val isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0
|
||||||
val instance = EventInstance(
|
val instance = EventInstance(
|
||||||
instanceId = eventId,
|
instanceId = eventId,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
@@ -49,17 +69,47 @@ internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDet
|
|||||||
title = title,
|
title = title,
|
||||||
start = begin.toKotlinInstantFromEpochMillis(),
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
end = end.toKotlinInstantFromEpochMillis(),
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0,
|
isAllDay = isAllDay,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(EventDetailProjection.IDX_LOCATION),
|
location = getString(EventDetailProjection.IDX_LOCATION),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// All-day reminders are stored as a wall-clock-shifted offset (see
|
||||||
|
// AllDayReminderEncoding); decode back to the whole-day lead time the form
|
||||||
|
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
|
||||||
|
// event's date is its UTC date.
|
||||||
|
val displayReminders = if (isAllDay) {
|
||||||
|
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
|
||||||
|
} else {
|
||||||
|
reminders
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
||||||
|
// be distinguished from a present 0 — an absent status means "just confirmed".
|
||||||
|
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
||||||
|
EventStatus.Confirmed
|
||||||
|
} else {
|
||||||
|
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
|
||||||
|
}
|
||||||
|
|
||||||
return EventDetail(
|
return EventDetail(
|
||||||
instance = instance,
|
instance = instance,
|
||||||
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
||||||
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
||||||
attendees = attendees,
|
attendees = attendees,
|
||||||
rrule = getString(EventDetailProjection.IDX_RRULE),
|
rrule = getString(EventDetailProjection.IDX_RRULE),
|
||||||
|
reminders = displayReminders,
|
||||||
|
status = status,
|
||||||
|
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
||||||
|
// default these mappers already return — no isNull guard needed.
|
||||||
|
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
|
||||||
|
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
|
||||||
|
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
|
||||||
|
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
|
eventColor = eventColor,
|
||||||
|
eventColorKey = eventColorKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +117,13 @@ internal fun ColumnReader.toAttendee(): Attendee = Attendee(
|
|||||||
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
|
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
|
||||||
email = getString(AttendeeProjection.IDX_EMAIL),
|
email = getString(AttendeeProjection.IDX_EMAIL),
|
||||||
status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
|
status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
|
||||||
|
relationship = mapAttendeeRelationship(getInt(AttendeeProjection.IDX_RELATIONSHIP)),
|
||||||
|
type = mapAttendeeType(getInt(AttendeeProjection.IDX_TYPE)),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun ColumnReader.toReminder(): Reminder = Reminder(
|
||||||
|
minutes = getInt(ReminderProjection.IDX_MINUTES),
|
||||||
|
method = mapReminderMethod(getInt(ReminderProjection.IDX_METHOD)),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
||||||
@@ -76,3 +133,46 @@ internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
|||||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
|
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
|
||||||
else -> AttendeeStatus.Unknown
|
else -> AttendeeStatus.Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun mapAttendeeRelationship(raw: Int): AttendeeRelationship = when (raw) {
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_ORGANIZER -> AttendeeRelationship.Organizer
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_PERFORMER -> AttendeeRelationship.Performer
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_SPEAKER -> AttendeeRelationship.Speaker
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE -> AttendeeRelationship.Attendee
|
||||||
|
else -> AttendeeRelationship.None
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAttendeeType(raw: Int): AttendeeType = when (raw) {
|
||||||
|
CalendarContract.Attendees.TYPE_REQUIRED -> AttendeeType.Required
|
||||||
|
CalendarContract.Attendees.TYPE_OPTIONAL -> AttendeeType.Optional
|
||||||
|
CalendarContract.Attendees.TYPE_RESOURCE -> AttendeeType.Resource
|
||||||
|
else -> AttendeeType.None
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapEventStatus(raw: Int): EventStatus = when (raw) {
|
||||||
|
CalendarContract.Events.STATUS_CONFIRMED -> EventStatus.Confirmed
|
||||||
|
CalendarContract.Events.STATUS_TENTATIVE -> EventStatus.Tentative
|
||||||
|
CalendarContract.Events.STATUS_CANCELED -> EventStatus.Cancelled
|
||||||
|
else -> EventStatus.Confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAvailability(raw: Int): Availability = when (raw) {
|
||||||
|
CalendarContract.Events.AVAILABILITY_FREE -> Availability.Free
|
||||||
|
CalendarContract.Events.AVAILABILITY_TENTATIVE -> Availability.Tentative
|
||||||
|
else -> Availability.Busy
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAccessLevel(raw: Int): AccessLevel = when (raw) {
|
||||||
|
CalendarContract.Events.ACCESS_CONFIDENTIAL -> AccessLevel.Confidential
|
||||||
|
CalendarContract.Events.ACCESS_PRIVATE -> AccessLevel.Private
|
||||||
|
CalendarContract.Events.ACCESS_PUBLIC -> AccessLevel.Public
|
||||||
|
else -> AccessLevel.Default
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapReminderMethod(raw: Int): ReminderMethod = when (raw) {
|
||||||
|
CalendarContract.Reminders.METHOD_EMAIL -> ReminderMethod.Email
|
||||||
|
CalendarContract.Reminders.METHOD_SMS -> ReminderMethod.Sms
|
||||||
|
CalendarContract.Reminders.METHOD_ALARM -> ReminderMethod.Alarm
|
||||||
|
CalendarContract.Reminders.METHOD_ALERT -> ReminderMethod.Alert
|
||||||
|
else -> ReminderMethod.Default
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||||
|
internal data class EventWriteTimes(
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val dtEndMillis: Long,
|
||||||
|
val timezone: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||||
|
* CalendarContract convention — a one-day event ends at the next midnight);
|
||||||
|
* timed events resolve their wall-clock values in [zone].
|
||||||
|
*/
|
||||||
|
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||||
|
EventWriteTimes(
|
||||||
|
dtStartMillis = start.date.toJavaLocalDate()
|
||||||
|
.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
|
||||||
|
dtEndMillis = end.date.toJavaLocalDate().plusDays(1)
|
||||||
|
.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
|
||||||
|
timezone = "UTC",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EventWriteTimes(
|
||||||
|
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||||
|
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||||
|
timezone = zone.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 2445 duration for a recurring event's row (the provider requires
|
||||||
|
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||||
|
* events, seconds otherwise.
|
||||||
|
*/
|
||||||
|
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
||||||
|
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
||||||
|
} else {
|
||||||
|
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dirty-checked column values for updating an existing Events row: only what
|
||||||
|
* the user actually changed is written, so untouched fields can't stomp
|
||||||
|
* concurrent external edits. Keys are `CalendarContract.Events` columns; a
|
||||||
|
* null value means "set the column to NULL". An empty map means nothing on
|
||||||
|
* the row changed.
|
||||||
|
*
|
||||||
|
* Time fields travel together (the provider validates them as a unit):
|
||||||
|
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||||
|
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||||
|
* - recurring result → the *series* DTSTART moves by the same delta the user
|
||||||
|
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
|
||||||
|
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
|
||||||
|
* past occurrences intact when someone edits a later occurrence's time.
|
||||||
|
*/
|
||||||
|
internal fun buildEventUpdateValues(
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
seriesDtStartMillis: Long,
|
||||||
|
zone: ZoneId,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
if (updated.title.trim() != original.title.trim()) {
|
||||||
|
put(CalendarContract.Events.TITLE, updated.title.trim())
|
||||||
|
}
|
||||||
|
if (updated.location.trim() != original.location.trim()) {
|
||||||
|
put(CalendarContract.Events.EVENT_LOCATION, updated.location.trim().ifEmpty { null })
|
||||||
|
}
|
||||||
|
if (updated.description.trim() != original.description.trim()) {
|
||||||
|
put(CalendarContract.Events.DESCRIPTION, updated.description.trim().ifEmpty { null })
|
||||||
|
}
|
||||||
|
if (updated.availability != original.availability) {
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, updated.availability.toProviderValue())
|
||||||
|
}
|
||||||
|
if (updated.accessLevel != original.accessLevel) {
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
|
||||||
|
}
|
||||||
|
if (updated.colorKey != original.colorKey || updated.color != original.color) {
|
||||||
|
putAll(eventColorColumns(updated.colorKey, updated.color))
|
||||||
|
}
|
||||||
|
|
||||||
|
val timesChanged = updated.start != original.start ||
|
||||||
|
updated.end != original.end ||
|
||||||
|
updated.isAllDay != original.isAllDay ||
|
||||||
|
updated.rrule != original.rrule
|
||||||
|
if (!timesChanged) return@buildMap
|
||||||
|
|
||||||
|
val newTimes = updated.toWriteTimes(zone)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (updated.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, newTimes.timezone)
|
||||||
|
if (updated.rrule == null) {
|
||||||
|
put(CalendarContract.Events.DTSTART, newTimes.dtStartMillis)
|
||||||
|
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
||||||
|
put(CalendarContract.Events.RRULE, null)
|
||||||
|
put(CalendarContract.Events.DURATION, null)
|
||||||
|
} else {
|
||||||
|
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
|
||||||
|
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
|
||||||
|
put(CalendarContract.Events.DTEND, null)
|
||||||
|
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||||
|
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values for a modified-occurrence exception row ("edit only this
|
||||||
|
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
||||||
|
* provider clone the series row and apply these on top. Unlike the series
|
||||||
|
* update there is no dirty check — the exception is a fresh row, so every
|
||||||
|
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||||
|
* clone starts from the parent's values). An exception is a single event:
|
||||||
|
* DTEND, never RRULE/DURATION.
|
||||||
|
*/
|
||||||
|
internal fun buildOccurrenceExceptionValues(
|
||||||
|
form: EventForm,
|
||||||
|
originalInstanceMillis: Long,
|
||||||
|
zone: ZoneId,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
val times = form.toWriteTimes(zone)
|
||||||
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
|
||||||
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
|
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
|
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
||||||
|
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
||||||
|
putAll(eventColorColumns(form.colorKey, form.color))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
|
* account's palette, so the colour round-trips through sync); a raw [color]
|
||||||
|
* writes `EVENT_COLOR` and clears any key; "no colour" clears both so the event
|
||||||
|
* falls back to its calendar's colour. The two are never written together —
|
||||||
|
* the provider rejects a raw colour on a calendar that publishes a palette,
|
||||||
|
* which is exactly why palette calendars only ever go through the key.
|
||||||
|
*/
|
||||||
|
internal fun eventColorColumns(colorKey: String?, color: Int?): Map<String, Any?> = when {
|
||||||
|
colorKey != null -> mapOf(CalendarContract.Events.EVENT_COLOR_KEY to colorKey)
|
||||||
|
color != null -> mapOf(
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY to null,
|
||||||
|
CalendarContract.Events.EVENT_COLOR to color,
|
||||||
|
)
|
||||||
|
else -> mapOf(
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY to null,
|
||||||
|
CalendarContract.Events.EVENT_COLOR to null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTC millis of the last second of the local day *before* the occurrence at
|
||||||
|
* [beginMillis] in [zone] — the cutoff for truncating a series with UNTIL.
|
||||||
|
* The provider's recurrence engine applies UNTIL coarsely (observed on a
|
||||||
|
* Pixel: an occurrence one second *after* UNTIL was still generated), so the
|
||||||
|
* series must end on the previous day, not one second before the occurrence.
|
||||||
|
* With no sub-daily frequencies that is semantically the same cut.
|
||||||
|
*/
|
||||||
|
internal fun previousLocalDayEndUtcMillis(beginMillis: Long, zone: ZoneId): Long =
|
||||||
|
Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate()
|
||||||
|
.atStartOfDay(zone).toInstant().toEpochMilli() - 1_000L
|
||||||
|
|
||||||
|
private const val MILLIS_PER_DAY = 86_400_000L
|
||||||
|
|
||||||
|
internal fun Availability.toProviderValue(): Int = when (this) {
|
||||||
|
Availability.Busy -> CalendarContract.Events.AVAILABILITY_BUSY
|
||||||
|
Availability.Free -> CalendarContract.Events.AVAILABILITY_FREE
|
||||||
|
Availability.Tentative -> CalendarContract.Events.AVAILABILITY_TENTATIVE
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun AccessLevel.toProviderValue(): Int = when (this) {
|
||||||
|
AccessLevel.Default -> CalendarContract.Events.ACCESS_DEFAULT
|
||||||
|
AccessLevel.Confidential -> CalendarContract.Events.ACCESS_CONFIDENTIAL
|
||||||
|
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
||||||
|
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||||
|
* for backup. [reminderMinutes] are the row's raw provider reminder offsets and
|
||||||
|
* [calendarName] the display name of its calendar (emitted as
|
||||||
|
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
||||||
|
* MapColumnReader.
|
||||||
|
*/
|
||||||
|
internal fun ColumnReader.toIcsEvent(
|
||||||
|
reminderMinutes: List<Int>,
|
||||||
|
calendarName: String?,
|
||||||
|
): IcsEvent {
|
||||||
|
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||||
|
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
||||||
|
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
||||||
|
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
||||||
|
val end = when {
|
||||||
|
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
|
||||||
|
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
|
||||||
|
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
|
||||||
|
EventStatus.Confirmed
|
||||||
|
} else {
|
||||||
|
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
|
||||||
|
}
|
||||||
|
|
||||||
|
return IcsEvent(
|
||||||
|
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
|
||||||
|
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
||||||
|
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||||
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
|
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
|
||||||
|
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||||
|
?: "UTC",
|
||||||
|
recurrenceRule = rrule,
|
||||||
|
location = getString(EventExportProjection.IDX_LOCATION),
|
||||||
|
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||||
|
reminderMinutes = reminderMinutes,
|
||||||
|
status = status,
|
||||||
|
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||||
|
calendarName = calendarName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,14 +10,23 @@ internal object CalendarProjection {
|
|||||||
CalendarContract.Calendars.ACCOUNT_TYPE,
|
CalendarContract.Calendars.ACCOUNT_TYPE,
|
||||||
CalendarContract.Calendars.CALENDAR_COLOR,
|
CalendarContract.Calendars.CALENDAR_COLOR,
|
||||||
CalendarContract.Calendars.VISIBLE,
|
CalendarContract.Calendars.VISIBLE,
|
||||||
|
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||||
|
// CalendarContract has no description column; for the local calendars we
|
||||||
|
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
|
||||||
|
// so the mapper only reads it for local calendars).
|
||||||
|
DESCRIPTION_COLUMN,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
const val IDX_DISPLAY_NAME = 1
|
const val IDX_DISPLAY_NAME = 1
|
||||||
const val IDX_ACCOUNT_NAME = 2
|
const val IDX_ACCOUNT_NAME = 2
|
||||||
const val IDX_ACCOUNT_TYPE = 3
|
const val IDX_ACCOUNT_TYPE = 3
|
||||||
const val IDX_COLOR = 4
|
const val IDX_COLOR = 4
|
||||||
const val IDX_VISIBLE = 5
|
const val IDX_VISIBLE = 5
|
||||||
|
const val IDX_ACCESS_LEVEL = 6
|
||||||
|
const val IDX_DESCRIPTION = 7
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object InstanceProjection {
|
internal object InstanceProjection {
|
||||||
@@ -60,6 +69,12 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
CalendarContract.Events.EVENT_LOCATION,
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
CalendarContract.Events.CALENDAR_ID,
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
CalendarContract.Events.STATUS,
|
||||||
|
CalendarContract.Events.AVAILABILITY,
|
||||||
|
CalendarContract.Events.ACCESS_LEVEL,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -74,6 +89,54 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_ALL_DAY = 9
|
const val IDX_ALL_DAY = 9
|
||||||
const val IDX_LOCATION = 10
|
const val IDX_LOCATION = 10
|
||||||
const val IDX_CALENDAR_ID = 11
|
const val IDX_CALENDAR_ID = 11
|
||||||
|
const val IDX_STATUS = 12
|
||||||
|
const val IDX_AVAILABILITY = 13
|
||||||
|
const val IDX_ACCESS_LEVEL = 14
|
||||||
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
||||||
|
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
||||||
|
* backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
|
||||||
|
* occurrence and cancelled-exception rows are filtered out by the query
|
||||||
|
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
|
||||||
|
* exported yet — a documented v1 limit (import skips them too).
|
||||||
|
*/
|
||||||
|
internal object EventExportProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Events._ID,
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.DTEND,
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
|
CalendarContract.Events.DESCRIPTION,
|
||||||
|
CalendarContract.Events.STATUS,
|
||||||
|
CalendarContract.Events.AVAILABILITY,
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_ID = 0
|
||||||
|
const val IDX_UID = 1
|
||||||
|
const val IDX_TITLE = 2
|
||||||
|
const val IDX_DTSTART = 3
|
||||||
|
const val IDX_DTEND = 4
|
||||||
|
const val IDX_DURATION = 5
|
||||||
|
const val IDX_ALL_DAY = 6
|
||||||
|
const val IDX_EVENT_TIMEZONE = 7
|
||||||
|
const val IDX_RRULE = 8
|
||||||
|
const val IDX_LOCATION = 9
|
||||||
|
const val IDX_DESCRIPTION = 10
|
||||||
|
const val IDX_STATUS = 11
|
||||||
|
const val IDX_AVAILABILITY = 12
|
||||||
|
const val IDX_CALENDAR_ID = 13
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
@@ -81,11 +144,25 @@ internal object AttendeeProjection {
|
|||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||||
CalendarContract.Attendees.ATTENDEE_STATUS,
|
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_TYPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_NAME = 0
|
const val IDX_NAME = 0
|
||||||
const val IDX_EMAIL = 1
|
const val IDX_EMAIL = 1
|
||||||
const val IDX_STATUS = 2
|
const val IDX_STATUS = 2
|
||||||
|
const val IDX_RELATIONSHIP = 3
|
||||||
|
const val IDX_TYPE = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ReminderProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Reminders.MINUTES,
|
||||||
|
CalendarContract.Reminders.METHOD,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_MINUTES = 0
|
||||||
|
const val IDX_METHOD = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object Fallbacks {
|
internal object Fallbacks {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -37,6 +39,12 @@ abstract class DataBindModule {
|
|||||||
abstract fun bindCalendarRepository(
|
abstract fun bindCalendarRepository(
|
||||||
impl: CalendarRepositoryImpl,
|
impl: CalendarRepositoryImpl,
|
||||||
): CalendarRepository
|
): CalendarRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindReminderAlertStore(
|
||||||
|
impl: AndroidReminderAlertStore,
|
||||||
|
): ReminderAlertStore
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.ics
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Android IO edge of `.ics` export: writes a serialised calendar to a
|
||||||
|
* SAF document (whole-calendar backup) or stages it in a cache file behind a
|
||||||
|
* `FileProvider` content Uri (single-event share). The serialisation itself is
|
||||||
|
* the pure `domain.ics.IcsWriter`; this class only moves bytes.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class IcsExporter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Write [content] to the SAF document at [uri] as UTF-8. Throws on failure. */
|
||||||
|
fun writeDocument(uri: Uri, content: String) {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||||
|
out.write(content.toByteArray(Charsets.UTF_8))
|
||||||
|
} ?: throw IOException("Could not open $uri for writing")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage [content] in a private cache file and return a shareable content
|
||||||
|
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
||||||
|
* the receiving app. The authority mirrors the manifest's `FileProvider`.
|
||||||
|
*/
|
||||||
|
fun stageShareFile(fileName: String, content: String): Uri {
|
||||||
|
val dir = File(context.cacheDir, SHARE_DIR).apply { mkdirs() }
|
||||||
|
val file = File(dir, fileName)
|
||||||
|
file.writeText(content, Charsets.UTF_8)
|
||||||
|
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SHARE_DIR = "shared_ics"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.ics
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android IO edge of `.ics` import: reads the text of a received/opened
|
||||||
|
* document [Uri]. Parsing is the pure `domain.ics.IcsParser`; this class only
|
||||||
|
* pulls bytes off the ContentResolver. Returns null on any read failure.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class IcsImporter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
fun readText(uri: Uri): String? = runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.prefs
|
|||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -38,7 +39,20 @@ class CalendarPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar the user last created an event in; preselected in the
|
||||||
|
* event form. Null until the first event is created.
|
||||||
|
*/
|
||||||
|
val lastUsedCalendarId: Flow<Long?> = store.data.map { prefs ->
|
||||||
|
prefs[LAST_USED_CALENDAR_KEY]
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setLastUsedCalendarId(id: Long) {
|
||||||
|
store.edit { prefs -> prefs[LAST_USED_CALENDAR_KEY] = id }
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||||
|
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import androidx.datastore.core.DataStore
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -67,12 +69,256 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[WEEK_START_KEY] = pref.name }
|
store.edit { it[WEEK_START_KEY] = pref.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional event-form fields shown by default (the rest hide behind
|
||||||
|
* "more fields"). Stored comma-joined by enum name: an absent key means
|
||||||
|
* the factory default, an empty string means "none". Unknown names are
|
||||||
|
* dropped defensively, like the other enum prefs.
|
||||||
|
*/
|
||||||
|
val defaultFormFields: Flow<Set<EventFormField>> = store.data.map { prefs ->
|
||||||
|
parseFormFields(prefs[FORM_FIELDS_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = parseFormFields(prefs[FORM_FIELDS_KEY])
|
||||||
|
val updated = if (enabled) current + field else current - field
|
||||||
|
prefs[FORM_FIELDS_KEY] = updated.joinToString(",") { it.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||||
|
* for users whose only calendar app this is, reminders are essential; the
|
||||||
|
* onboarding step and Settings warn about duplicates from a second app.
|
||||||
|
*/
|
||||||
|
val remindersEnabled: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[REMINDERS_ENABLED_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setRemindersEnabled(enabled: Boolean) {
|
||||||
|
store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to offer a custom event colour even on calendars that publish no
|
||||||
|
* colour palette (most local calendars handle it fine; synced calendars
|
||||||
|
* without a palette — some CalDAV — may drop or overwrite a raw colour on
|
||||||
|
* their next sync). Defaults to OFF: such calendars hide the colour picker
|
||||||
|
* until the user opts in, accepting the limitation. Local calendars and
|
||||||
|
* palette-backed calendars (Google, …) are unaffected by this flag.
|
||||||
|
*/
|
||||||
|
val allowColorOnUnsupportedCalendars: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ALLOW_COLOR_UNSUPPORTED_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
||||||
|
store.edit { it[ALLOW_COLOR_UNSUPPORTED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the one-time reminder onboarding step (after the calendar
|
||||||
|
* grant) has been shown — also true for users who tapped "not now".
|
||||||
|
*/
|
||||||
|
val reminderOnboardingDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setReminderOnboardingDone() {
|
||||||
|
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default reminder lead time (minutes before start) prefilled on new
|
||||||
|
* **timed** events. `null` = no default reminder — the prior behaviour, kept
|
||||||
|
* as the factory default so existing users aren't surprised by reminders they
|
||||||
|
* never asked for. Stored as a string so "none" is distinct from a numeric
|
||||||
|
* value (and from an unset key, which is also "none"). Per-calendar overrides
|
||||||
|
* in [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||||
|
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
||||||
|
*/
|
||||||
|
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||||
|
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
||||||
|
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default reminder lead time prefilled on new **all-day** events, in
|
||||||
|
* minutes before the start of the day. All-day events want day-scale lead
|
||||||
|
* times ("1 day before"), so they have their own default rather than reusing
|
||||||
|
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
|
||||||
|
* to all-day events — they always use this global value.
|
||||||
|
*/
|
||||||
|
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||||
|
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||||
|
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock time, as minutes from local midnight, at which **all-day**
|
||||||
|
* reminders fire. All-day events live at UTC midnight, so a raw "1 day
|
||||||
|
* before" would fire at an off hour (02:00 local in CEST); this time is
|
||||||
|
* encoded into the provider offset so the reminder lands at, e.g., 09:00 the
|
||||||
|
* day before instead. Global for every all-day reminder; default 09:00.
|
||||||
|
* Stored/clamped to a valid 0..1439 minute-of-day.
|
||||||
|
*/
|
||||||
|
val allDayReminderTimeMinutes: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[ALLDAY_REMINDER_TIME_KEY] ?: DEFAULT_ALLDAY_REMINDER_TIME)
|
||||||
|
.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||||
|
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||||
|
* keyed by calendar id. A calendar **present** in the map overrides the global
|
||||||
|
* timed default for its new events: a `null` value means "no reminder", an int
|
||||||
|
* means that lead time. A calendar **absent** from the map inherits the global
|
||||||
|
* default. Serialised as `id=value;id=value`, with `none` for an explicit
|
||||||
|
* no-reminder override. (All-day events ignore this and use
|
||||||
|
* [defaultAllDayReminderMinutes].)
|
||||||
|
*/
|
||||||
|
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
|
current.applyOverride(calendarId, override)
|
||||||
|
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultAllDayReminderMinutes] for **all-day**
|
||||||
|
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
||||||
|
* inherit the global all-day default; present null = no reminder).
|
||||||
|
*/
|
||||||
|
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCalendarAllDayReminderOverride(
|
||||||
|
calendarId: Long,
|
||||||
|
override: CalendarReminderOverride,
|
||||||
|
) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current =
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
|
current.applyOverride(calendarId, override)
|
||||||
|
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
|
null -> DEFAULT_FORM_FIELDS
|
||||||
|
else -> stored.split(',')
|
||||||
|
.mapNotNull { name -> EventFormField.entries.firstOrNull { it.name == name.trim() } }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
|
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||||
|
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||||
|
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||||
|
internal val DEFAULT_ALLDAY_REMINDER_KEY =
|
||||||
|
stringPreferencesKey("default_allday_reminder_minutes")
|
||||||
|
internal val ALLDAY_REMINDER_TIME_KEY =
|
||||||
|
intPreferencesKey("allday_reminder_time_minutes")
|
||||||
|
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
||||||
|
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||||
|
stringPreferencesKey("per_calendar_reminder_override")
|
||||||
|
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||||
|
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||||
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
||||||
|
sealed interface CalendarReminderOverride {
|
||||||
|
/** No override — the calendar uses the global default. */
|
||||||
|
data object Inherit : CalendarReminderOverride
|
||||||
|
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||||
|
data object None : CalendarReminderOverride
|
||||||
|
/** A specific lead time in minutes before the event start. */
|
||||||
|
data class Minutes(val minutes: Int) : CalendarReminderOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lead time to prefill on a new event: the matching per-calendar override
|
||||||
|
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||||
|
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
||||||
|
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
|
||||||
|
* it can be unit-tested.
|
||||||
|
*/
|
||||||
|
fun resolveDefaultReminder(
|
||||||
|
timedGlobal: Int?,
|
||||||
|
allDayGlobal: Int?,
|
||||||
|
timedOverrides: Map<Long, Int?>,
|
||||||
|
allDayOverrides: Map<Long, Int?>,
|
||||||
|
calendarId: Long?,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
): Int? {
|
||||||
|
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||||
|
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||||
|
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||||
|
overrides[calendarId]
|
||||||
|
} else {
|
||||||
|
global
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
|
||||||
|
private fun MutableMap<Long, Int?>.applyOverride(
|
||||||
|
calendarId: Long,
|
||||||
|
override: CalendarReminderOverride,
|
||||||
|
) {
|
||||||
|
when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||||
|
CalendarReminderOverride.None -> put(calendarId, null)
|
||||||
|
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NONE = "none"
|
||||||
|
private const val ENTRY_SEP = ";"
|
||||||
|
private const val KEY_VALUE_SEP = "="
|
||||||
|
|
||||||
|
private fun String?.toReminderMinutes(): Int? = when (this) {
|
||||||
|
null, "", NONE -> null
|
||||||
|
else -> toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
||||||
|
if (stored.isNullOrBlank()) return emptyMap()
|
||||||
|
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||||
|
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||||
|
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||||
|
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||||
|
id to value
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
||||||
|
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
|
||||||
|
|
||||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||||
|
* visible notifications (the Etar model — the provider broadcasts
|
||||||
|
* `EVENT_REMINDER` at reminder time but posts nothing itself).
|
||||||
|
*
|
||||||
|
* The broadcast's data URI only carries the alarm time, so it is ignored:
|
||||||
|
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
|
||||||
|
* them, and mark them fired. Posting happens before marking — a crash in
|
||||||
|
* between re-posts silently (same tag) rather than losing the reminder.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class EventReminderReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var alertStore: ReminderAlertStore
|
||||||
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||||
|
val readGranted = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.READ_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!readGranted || !notifier.canPost()) return
|
||||||
|
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
if (settingsPrefs.remindersEnabled.first()) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val due = alertStore.dueAlerts(now)
|
||||||
|
due.forEach(notifier::post)
|
||||||
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import android.util.Log
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One due row of the provider's `CalendarAlerts` table (a join with Events).
|
||||||
|
* Stays in the data layer: alerts feed the notification path only and never
|
||||||
|
* reach a screen, so there is no domain model for them.
|
||||||
|
*/
|
||||||
|
data class ReminderAlert(
|
||||||
|
val alertId: Long,
|
||||||
|
val eventId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||||
|
val title: String,
|
||||||
|
val location: String?,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
|
||||||
|
* without a ContentResolver. The provider creates these rows itself — only
|
||||||
|
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
|
||||||
|
* email reminders never show up here.
|
||||||
|
*/
|
||||||
|
interface ReminderAlertStore {
|
||||||
|
|
||||||
|
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
|
||||||
|
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
|
||||||
|
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
|
||||||
|
* which the user may have declined — then re-broadcasts silently replace
|
||||||
|
* the already-posted notifications instead (same tag, alert-once).
|
||||||
|
*/
|
||||||
|
fun markFired(alertIds: List<Long>, nowMillis: Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AndroidReminderAlertStore @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ReminderAlertStore {
|
||||||
|
|
||||||
|
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
|
||||||
|
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||||
|
PROJECTION,
|
||||||
|
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
|
||||||
|
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
|
||||||
|
nowMillis.toString(),
|
||||||
|
),
|
||||||
|
CalendarContract.CalendarAlerts.BEGIN + " ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
add(
|
||||||
|
ReminderAlert(
|
||||||
|
alertId = c.getLong(0),
|
||||||
|
eventId = c.getLong(1),
|
||||||
|
beginMillis = c.getLong(2),
|
||||||
|
endMillis = c.getLong(3),
|
||||||
|
title = c.getString(4).orEmpty(),
|
||||||
|
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||||
|
isAllDay = c.getInt(6) == 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
|
||||||
|
if (alertIds.isEmpty()) return
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
|
||||||
|
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
|
||||||
|
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.contentResolver.update(
|
||||||
|
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||||
|
values,
|
||||||
|
CalendarContract.CalendarAlerts._ID +
|
||||||
|
" IN (" + alertIds.joinToString(",") + ")",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderAlertStore"
|
||||||
|
val PROJECTION = arrayOf(
|
||||||
|
CalendarContract.CalendarAlerts._ID,
|
||||||
|
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||||
|
CalendarContract.CalendarAlerts.BEGIN,
|
||||||
|
CalendarContract.CalendarAlerts.END,
|
||||||
|
CalendarContract.CalendarAlerts.TITLE,
|
||||||
|
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
||||||
|
CalendarContract.CalendarAlerts.ALL_DAY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts one notification per due reminder alert on a dedicated channel.
|
||||||
|
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
||||||
|
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
||||||
|
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
||||||
|
* duplicating it.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderNotifier @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
|
fun canPost(): Boolean {
|
||||||
|
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun post(alert: ReminderAlert) {
|
||||||
|
ensureChannel()
|
||||||
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
|
val time = reminderTimeText(
|
||||||
|
beginMillis = alert.beginMillis,
|
||||||
|
endMillis = alert.endMillis,
|
||||||
|
isAllDay = alert.isAllDay,
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
locale = Locale.getDefault(),
|
||||||
|
)
|
||||||
|
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setWhen(alert.beginMillis)
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setContentIntent(detailIntent(alert))
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(context)
|
||||||
|
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||||
|
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
/* requestCode = */ alert.alertId.toInt(),
|
||||||
|
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Channel creation is idempotent; re-running refreshes the localized name. */
|
||||||
|
private fun ensureChannel() {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.reminder_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply {
|
||||||
|
description = context.getString(R.string.reminder_channel_description)
|
||||||
|
}
|
||||||
|
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderNotifier"
|
||||||
|
const val CHANNEL_ID = "reminders"
|
||||||
|
|
||||||
|
// One id, distinct tags: the tag (alert id) already keys the
|
||||||
|
// notification, so a fixed id keeps cancellation/replacement simple.
|
||||||
|
const val NOTIFICATION_ID = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one line of time context in a reminder notification. Pure so it can be
|
||||||
|
* JVM-tested:
|
||||||
|
*
|
||||||
|
* - timed, same day: "09:30 – 10:00"
|
||||||
|
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
|
||||||
|
* - all-day, one day: "11 Jun 2026"
|
||||||
|
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||||
|
*
|
||||||
|
* All-day instances store UTC midnights with an exclusive end, so they are
|
||||||
|
* read in UTC and the end day is the last *covered* day.
|
||||||
|
*/
|
||||||
|
fun reminderTimeText(
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
zone: ZoneId,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
if (isAllDay) {
|
||||||
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
|
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
|
||||||
|
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
.minusDays(1)
|
||||||
|
.coerceAtLeast(firstDay)
|
||||||
|
return if (lastDay == firstDay) {
|
||||||
|
dateFormat.format(firstDay)
|
||||||
|
} else {
|
||||||
|
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||||
|
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||||
|
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||||
|
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||||
|
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||||
|
} else {
|
||||||
|
val dateTimeFormat = DateTimeFormatter
|
||||||
|
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||||
|
.withLocale(locale)
|
||||||
|
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val RANGE = " – "
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||||
|
* wall-clock values in the device zone; the data layer translates them to
|
||||||
|
* provider millis (all-day events normalise to UTC midnights there).
|
||||||
|
*/
|
||||||
|
data class EventForm(
|
||||||
|
val calendarId: Long?,
|
||||||
|
val title: String = "",
|
||||||
|
val isAllDay: Boolean = false,
|
||||||
|
val start: LocalDateTime,
|
||||||
|
val end: LocalDateTime,
|
||||||
|
val location: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||||
|
val reminders: List<Int> = emptyList(),
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||||
|
/**
|
||||||
|
* Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null
|
||||||
|
* means a one-off event. May hold rules the simple picker can't express —
|
||||||
|
* those are kept verbatim until the user picks something else.
|
||||||
|
*/
|
||||||
|
val rrule: String? = null,
|
||||||
|
/**
|
||||||
|
* The event's own colour, or null to inherit the calendar's colour.
|
||||||
|
* [colorKey] is a `Colors.COLOR_KEY` from the calendar account's published
|
||||||
|
* event palette (Google, some CalDAV) — written as `EVENT_COLOR_KEY` so it
|
||||||
|
* round-trips through sync. When it is null but [color] is set, [color] is
|
||||||
|
* a raw ARGB written as `EVENT_COLOR` (local calendars, or synced ones the
|
||||||
|
* user opted into despite no palette). [color] mirrors the key's swatch when
|
||||||
|
* [colorKey] is set, so the picker can highlight it.
|
||||||
|
*/
|
||||||
|
val colorKey: String? = null,
|
||||||
|
val color: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form's optional sections. Which ones show by default is a user setting;
|
||||||
|
* the rest unfold behind a "more fields" button.
|
||||||
|
*/
|
||||||
|
enum class EventFormField {
|
||||||
|
Location,
|
||||||
|
Description,
|
||||||
|
Reminders,
|
||||||
|
Recurrence,
|
||||||
|
Availability,
|
||||||
|
Visibility,
|
||||||
|
Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class EventFormProblem {
|
||||||
|
/** No target calendar — none picked and no writable calendar exists. */
|
||||||
|
NoCalendar,
|
||||||
|
EndBeforeStart,
|
||||||
|
/** The recurrence's UNTIL date lies before the event's first day. */
|
||||||
|
RecurrenceEndsBeforeStart,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation; an empty set means the form can be saved. A blank title is
|
||||||
|
* allowed (display falls back to "(No title)", matching the provider), and a
|
||||||
|
* zero-length timed event is allowed (spec §8: instant events exist).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Prefill the edit form from a loaded event. [beginMillis]/[endMillis] are the
|
||||||
|
* tapped occurrence's own times (`Instances.BEGIN`/`END`), not the series
|
||||||
|
* start — the data layer later turns a time edit into a delta on the series.
|
||||||
|
*
|
||||||
|
* All-day provider times are UTC midnights with an exclusive end; the form
|
||||||
|
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||||
|
* the user switches the event to timed.
|
||||||
|
*/
|
||||||
|
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||||
|
val (start, end) = if (instance.isAllDay) {
|
||||||
|
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||||
|
.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
|
||||||
|
.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
|
} else {
|
||||||
|
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
||||||
|
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
||||||
|
}
|
||||||
|
return EventForm(
|
||||||
|
calendarId = instance.calendarId,
|
||||||
|
title = instance.title,
|
||||||
|
isAllDay = instance.isAllDay,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
location = instance.location.orEmpty(),
|
||||||
|
description = description.orEmpty(),
|
||||||
|
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||||
|
availability = availability,
|
||||||
|
accessLevel = accessLevel,
|
||||||
|
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
|
// The provider fills EVENT_COLOR from the key, so [color] is the
|
||||||
|
// swatch either way; a null colour means the event inherits its
|
||||||
|
// calendar's colour.
|
||||||
|
colorKey = eventColorKey,
|
||||||
|
color = eventColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the edit form saw when it loaded — compared against a fresh read at
|
||||||
|
* save time to detect external changes (sync, another device) that landed
|
||||||
|
* while the form was open. The raw row times ride along because
|
||||||
|
* [toEditForm] derives the form's times from the *tapped occurrence*, so
|
||||||
|
* re-deriving with the same occurrence would mask an externally moved
|
||||||
|
* event. Not covered (the form can't write them, and the dirty-checked
|
||||||
|
* write can't clobber them): attendees, status, the user's own response,
|
||||||
|
* reminder methods, and a recurring event's duration.
|
||||||
|
*/
|
||||||
|
data class EditSnapshot(
|
||||||
|
val form: EventForm,
|
||||||
|
/** The raw Events-row times (for recurring events: the series anchor). */
|
||||||
|
val rowStart: Instant,
|
||||||
|
val rowEnd: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZone): EditSnapshot =
|
||||||
|
EditSnapshot(
|
||||||
|
form = toEditForm(beginMillis, endMillis, zone),
|
||||||
|
rowStart = instance.start,
|
||||||
|
rowEnd = instance.end,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The optional sections that hold a value in [form] — when editing, these
|
||||||
|
* must be visible regardless of the user's default-fields setting, or the
|
||||||
|
* data they carry would be invisible (though still preserved).
|
||||||
|
*/
|
||||||
|
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||||
|
if (location.isNotBlank()) add(EventFormField.Location)
|
||||||
|
if (description.isNotBlank()) add(EventFormField.Description)
|
||||||
|
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||||
|
if (rrule != null) add(EventFormField.Recurrence)
|
||||||
|
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||||
|
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||||
|
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||||
|
if (calendarId == null) add(EventFormProblem.NoCalendar)
|
||||||
|
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
|
||||||
|
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
|
||||||
|
// An UNTIL before the first day would make the provider generate zero
|
||||||
|
// occurrences — the event would silently vanish from every view.
|
||||||
|
val recurrenceEnd = rrule?.let(::parseSimpleRecurrence)?.end
|
||||||
|
if (recurrenceEnd is RecurrenceEnd.Until && recurrenceEnd.date < start.date) {
|
||||||
|
add(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,23 @@ data class CalendarSource(
|
|||||||
val accountType: String,
|
val accountType: String,
|
||||||
val color: Int,
|
val color: Int,
|
||||||
val isVisibleInSystem: Boolean,
|
val isVisibleInSystem: Boolean,
|
||||||
|
/**
|
||||||
|
* Whether events in this calendar can be created/edited/deleted
|
||||||
|
* (`Calendars.CALENDAR_ACCESS_LEVEL` >= contributor). False for WebCal
|
||||||
|
* subscriptions, birthday calendars and other read-only sources.
|
||||||
|
*/
|
||||||
|
val canModifyContents: Boolean = false,
|
||||||
|
/**
|
||||||
|
* A device-only calendar the app itself owns (`ACCOUNT_TYPE_LOCAL`): it has
|
||||||
|
* no sync backend, so the app can rename / recolor / delete it. Synced
|
||||||
|
* calendars (Google, DAVx5, …) are managed in their own source app instead.
|
||||||
|
*/
|
||||||
|
val isLocal: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Free-text note for a local calendar (stored in `CAL_SYNC1`, which the app
|
||||||
|
* owns for its own calendars). Always null for synced calendars.
|
||||||
|
*/
|
||||||
|
val description: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EventInstance(
|
data class EventInstance(
|
||||||
@@ -29,12 +46,51 @@ data class EventDetail(
|
|||||||
val organizer: String?,
|
val organizer: String?,
|
||||||
val attendees: List<Attendee>,
|
val attendees: List<Attendee>,
|
||||||
val rrule: String?,
|
val rrule: String?,
|
||||||
|
/** Reminders (VALARM) configured on the event, ascending lead time. */
|
||||||
|
val reminders: List<Reminder> = emptyList(),
|
||||||
|
/** Confirmed / Tentative / Cancelled (`Events.STATUS`). */
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
/** Busy / Free (`Events.AVAILABILITY`, the iCal TRANSP field). */
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
/** Default / Private / Confidential / Public (`Events.ACCESS_LEVEL`). */
|
||||||
|
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||||
|
/** Raw zone id (`Events.EVENT_TIMEZONE`), e.g. "Europe/Berlin"; null if unset. */
|
||||||
|
val eventTimezone: String? = null,
|
||||||
|
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
|
||||||
|
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
|
||||||
|
/**
|
||||||
|
* The event's own raw colour (`Events.EVENT_COLOR`), null when the event
|
||||||
|
* inherits its calendar's colour. Unlike [EventInstance.color] (which
|
||||||
|
* already folds in the calendar fallback for display) this stays null so
|
||||||
|
* the edit form can tell "has own colour" from "inherits".
|
||||||
|
*/
|
||||||
|
val eventColor: Int? = null,
|
||||||
|
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
||||||
|
val eventColorKey: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One selectable event colour published by a calendar's account
|
||||||
|
* (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped
|
||||||
|
* `COLOR_KEY` written as `EVENT_COLOR_KEY` (so the colour survives sync),
|
||||||
|
* [argb] is the swatch it renders as.
|
||||||
|
*/
|
||||||
|
data class EventColorOption(val key: String, val argb: Int)
|
||||||
|
|
||||||
data class Attendee(
|
data class Attendee(
|
||||||
val name: String,
|
val name: String,
|
||||||
val email: String?,
|
val email: String?,
|
||||||
val status: AttendeeStatus,
|
val status: AttendeeStatus,
|
||||||
|
/** Organizer / performer / speaker / plain attendee (`ATTENDEE_RELATIONSHIP`). */
|
||||||
|
val relationship: AttendeeRelationship = AttendeeRelationship.None,
|
||||||
|
/** Required / optional / resource (`ATTENDEE_TYPE`). */
|
||||||
|
val type: AttendeeType = AttendeeType.None,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Reminder(
|
||||||
|
/** Lead time before the event start, in minutes. `-1` means the provider default. */
|
||||||
|
val minutes: Int,
|
||||||
|
val method: ReminderMethod,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class AttendeeStatus {
|
enum class AttendeeStatus {
|
||||||
@@ -45,6 +101,58 @@ enum class AttendeeStatus {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class AttendeeRelationship {
|
||||||
|
Organizer,
|
||||||
|
Attendee,
|
||||||
|
Performer,
|
||||||
|
Speaker,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AttendeeType {
|
||||||
|
Required,
|
||||||
|
Optional,
|
||||||
|
Resource,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ReminderMethod {
|
||||||
|
Alert,
|
||||||
|
Email,
|
||||||
|
Sms,
|
||||||
|
Alarm,
|
||||||
|
Default,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class EventStatus {
|
||||||
|
Confirmed,
|
||||||
|
Tentative,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Availability {
|
||||||
|
Busy,
|
||||||
|
Free,
|
||||||
|
Tentative,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AccessLevel {
|
||||||
|
Default,
|
||||||
|
Public,
|
||||||
|
Private,
|
||||||
|
Confidential,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a write to a recurring event reaches. Non-recurring events always
|
||||||
|
* use [AllEvents] (there is only one).
|
||||||
|
*/
|
||||||
|
enum class RecurringWriteScope {
|
||||||
|
ThisEvent,
|
||||||
|
ThisAndFollowing,
|
||||||
|
AllEvents,
|
||||||
|
}
|
||||||
|
|
||||||
enum class FailureReason {
|
enum class FailureReason {
|
||||||
PermissionRevoked,
|
PermissionRevoked,
|
||||||
NoCalendarsConfigured,
|
NoCalendarsConfigured,
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.isoDayNumber
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recurrence shapes the simple picker can express (v1.3): a frequency,
|
||||||
|
* an interval, weekly weekday picks, and an optional end. Anything beyond
|
||||||
|
* that (ordinal BYDAY like "2TH", BYMONTHDAY, EXDATE rules, …) stays a raw
|
||||||
|
* RRULE string the picker shows as "custom" and leaves untouched unless the
|
||||||
|
* user replaces it.
|
||||||
|
*/
|
||||||
|
data class SimpleRecurrence(
|
||||||
|
val freq: RecurrenceFreq,
|
||||||
|
val interval: Int = 1,
|
||||||
|
val end: RecurrenceEnd = RecurrenceEnd.Never,
|
||||||
|
/**
|
||||||
|
* Weekly only: the weekdays the rule fires on (RRULE BYDAY). Empty means
|
||||||
|
* no BYDAY part — the provider derives the day from DTSTART.
|
||||||
|
*/
|
||||||
|
val byDays: Set<DayOfWeek> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class RecurrenceFreq {
|
||||||
|
Daily,
|
||||||
|
Weekly,
|
||||||
|
Monthly,
|
||||||
|
Yearly,
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface RecurrenceEnd {
|
||||||
|
data object Never : RecurrenceEnd
|
||||||
|
|
||||||
|
/** Last day on which an occurrence may fall (inclusive). */
|
||||||
|
data class Until(val date: LocalDate) : RecurrenceEnd
|
||||||
|
|
||||||
|
/** Total number of occurrences, counting the first. */
|
||||||
|
data class Count(val times: Int) : RecurrenceEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an RRULE into the picker's simple shape, or null when the rule uses
|
||||||
|
* parts the picker can't represent (so the UI preserves the original string).
|
||||||
|
* Accepts an optional leading "RRULE:" and an ignored WKST part. A datetime
|
||||||
|
* UNTIL is converted from UTC into [zone] before its date is taken, mirroring
|
||||||
|
* [toRRule].
|
||||||
|
*/
|
||||||
|
fun parseSimpleRecurrence(
|
||||||
|
rrule: String,
|
||||||
|
zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||||
|
): SimpleRecurrence? {
|
||||||
|
val parts = rrule.removePrefix("RRULE:").split(';')
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.associate { token ->
|
||||||
|
val eq = token.indexOf('=')
|
||||||
|
if (eq <= 0) return null
|
||||||
|
token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||||
|
}
|
||||||
|
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val freq = when (parts["FREQ"]?.uppercase()) {
|
||||||
|
"DAILY" -> RecurrenceFreq.Daily
|
||||||
|
"WEEKLY" -> RecurrenceFreq.Weekly
|
||||||
|
"MONTHLY" -> RecurrenceFreq.Monthly
|
||||||
|
"YEARLY" -> RecurrenceFreq.Yearly
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
|
||||||
|
|
||||||
|
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
|
||||||
|
// forms ("2TH" = second Thursday) and BYDAY on other frequencies are not.
|
||||||
|
val byDays = parts["BYDAY"]?.let { raw ->
|
||||||
|
if (freq != RecurrenceFreq.Weekly) return null
|
||||||
|
raw.split(',').map { token -> rruleDay(token.trim()) ?: return null }.toSet()
|
||||||
|
} ?: emptySet()
|
||||||
|
|
||||||
|
val until = parts["UNTIL"]
|
||||||
|
val count = parts["COUNT"]
|
||||||
|
if (until != null && count != null) return null
|
||||||
|
val end = when {
|
||||||
|
until != null -> RecurrenceEnd.Until(parseUntilDate(until, zone) ?: return null)
|
||||||
|
count != null -> RecurrenceEnd.Count(count.toIntOrNull()?.takeIf { it >= 1 } ?: return null)
|
||||||
|
else -> RecurrenceEnd.Never
|
||||||
|
}
|
||||||
|
return SimpleRecurrence(freq, interval, end, byDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render as a provider-ready RRULE value (no "RRULE:" prefix —
|
||||||
|
* `CalendarContract.Events.RRULE` stores the bare value). UNTIL is written as
|
||||||
|
* the end of the chosen day *in [zone]*, expressed in UTC: the recurrence
|
||||||
|
* engine has been observed applying UNTIL coarsely after converting it into
|
||||||
|
* the event's timezone, so a plain `T235959Z` can leak one extra day for
|
||||||
|
* zones ahead of UTC.
|
||||||
|
*/
|
||||||
|
fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()): String = buildString {
|
||||||
|
append("FREQ=")
|
||||||
|
append(
|
||||||
|
when (freq) {
|
||||||
|
RecurrenceFreq.Daily -> "DAILY"
|
||||||
|
RecurrenceFreq.Weekly -> "WEEKLY"
|
||||||
|
RecurrenceFreq.Monthly -> "MONTHLY"
|
||||||
|
RecurrenceFreq.Yearly -> "YEARLY"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (interval > 1) append(";INTERVAL=$interval")
|
||||||
|
if (freq == RecurrenceFreq.Weekly && byDays.isNotEmpty()) {
|
||||||
|
append(";BYDAY=")
|
||||||
|
append(
|
||||||
|
byDays.sortedBy { it.isoDayNumber }
|
||||||
|
.joinToString(",") { RRULE_DAY_CODES.getValue(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (val e = end) {
|
||||||
|
RecurrenceEnd.Never -> Unit
|
||||||
|
is RecurrenceEnd.Until -> {
|
||||||
|
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59))
|
||||||
|
.toInstant(zone)
|
||||||
|
.toLocalDateTime(TimeZone.UTC)
|
||||||
|
append(
|
||||||
|
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
|
||||||
|
utc.year, utc.month.number, utc.day,
|
||||||
|
utc.hour, utc.minute, utc.second,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||||
|
DayOfWeek.MONDAY to "MO",
|
||||||
|
DayOfWeek.TUESDAY to "TU",
|
||||||
|
DayOfWeek.WEDNESDAY to "WE",
|
||||||
|
DayOfWeek.THURSDAY to "TH",
|
||||||
|
DayOfWeek.FRIDAY to "FR",
|
||||||
|
DayOfWeek.SATURDAY to "SA",
|
||||||
|
DayOfWeek.SUNDAY to "SU",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Exact two-letter BYDAY token → weekday; ordinal forms ("2TH") return null. */
|
||||||
|
private fun rruleDay(token: String): DayOfWeek? =
|
||||||
|
RRULE_DAY_CODES.entries.firstOrNull { it.value == token.uppercase() }?.key
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End an arbitrary RRULE (simple or not) at [untilUtcMillis]: any existing
|
||||||
|
* UNTIL/COUNT is dropped, every other part (BYDAY, INTERVAL, …) survives.
|
||||||
|
* Used for "delete this and all following occurrences" — the caller passes a
|
||||||
|
* moment just before the first occurrence to remove.
|
||||||
|
*/
|
||||||
|
fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
|
||||||
|
val kept = rrule.removePrefix("RRULE:").split(';')
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.filterNot { part ->
|
||||||
|
val key = part.substringBefore('=').trim().uppercase()
|
||||||
|
key == "UNTIL" || key == "COUNT"
|
||||||
|
}
|
||||||
|
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC)
|
||||||
|
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
|
||||||
|
until.year, until.month.number, until.day,
|
||||||
|
until.hour, until.minute, until.second,
|
||||||
|
)
|
||||||
|
return (kept + untilPart).joinToString(";")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date of an RRULE UNTIL value ("20260801" or "20260801T215959Z"). Datetime
|
||||||
|
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so
|
||||||
|
* a [toRRule]-rendered value round-trips to the day the user picked.
|
||||||
|
*/
|
||||||
|
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching {
|
||||||
|
val date = LocalDate(
|
||||||
|
raw.substring(0, 4).toInt(),
|
||||||
|
raw.substring(4, 6).toInt(),
|
||||||
|
raw.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
if (raw.length >= 15 && raw[8] == 'T') {
|
||||||
|
val time = LocalTime(
|
||||||
|
raw.substring(9, 11).toInt(),
|
||||||
|
raw.substring(11, 13).toInt(),
|
||||||
|
raw.substring(13, 15).toInt(),
|
||||||
|
)
|
||||||
|
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
|
||||||
|
} else {
|
||||||
|
date
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the [IcsEvent] for sharing a single event. We export the event the user
|
||||||
|
* is looking at as a **one-off** VEVENT (no RRULE): the detail screen shows one
|
||||||
|
* occurrence, so "share this event" should hand off exactly that instance, not
|
||||||
|
* a whole series anchored to a possibly-different DTSTART. Reminders are the
|
||||||
|
* already-decoded semantic lead times the detail screen holds.
|
||||||
|
*/
|
||||||
|
fun EventDetail.toShareIcsEvent(): IcsEvent {
|
||||||
|
val startMillis = instance.start.toEpochMilliseconds()
|
||||||
|
return IcsEvent(
|
||||||
|
uid = deriveIcsUid(existingUid = null, eventId = instance.eventId, dtStartMillis = startMillis),
|
||||||
|
summary = instance.title,
|
||||||
|
start = instance.start,
|
||||||
|
end = instance.end,
|
||||||
|
isAllDay = instance.isAllDay,
|
||||||
|
zoneId = eventTimezone?.takeIf { it.isNotBlank() } ?: "UTC",
|
||||||
|
recurrenceRule = null,
|
||||||
|
location = instance.location,
|
||||||
|
description = description,
|
||||||
|
reminderMinutes = reminders.map { it.minutes },
|
||||||
|
status = status,
|
||||||
|
availability = availability,
|
||||||
|
calendarName = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
// Android's calendar provider (and Calendula's own writes) use the non-standard
|
||||||
|
// single-unit forms P<n>S / P<n>D / P<n>W — seconds without the RFC-required
|
||||||
|
// leading T. Matched first; anything else falls through to the general grammar.
|
||||||
|
private val DURATION_SINGLE_UNIT = Regex("""([+-]?)P(\d+)([WDS])""")
|
||||||
|
private val DURATION_GENERAL =
|
||||||
|
Regex("""([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds of a DURATION (`P1D`, `P3600S`, `PT1H30M`, `-PT15M`, `P1W`, …),
|
||||||
|
* sign-aware. Calendula writes `P<days>D` / `P<seconds>S`, but the parser also
|
||||||
|
* accepts the general RFC 5545 grammar so backup `DURATION` rows and foreign
|
||||||
|
* `VALARM` triggers round-trip. Unparseable input is treated as zero.
|
||||||
|
*/
|
||||||
|
fun parseRfc2445DurationMillis(duration: String?): Long {
|
||||||
|
if (duration.isNullOrBlank()) return 0L
|
||||||
|
val s = duration.trim()
|
||||||
|
|
||||||
|
DURATION_SINGLE_UNIT.matchEntire(s)?.let { m ->
|
||||||
|
val unitSeconds = when (m.groupValues[3]) {
|
||||||
|
"W" -> 7L * 24 * 60 * 60
|
||||||
|
"D" -> 24L * 60 * 60
|
||||||
|
else -> 1L // S
|
||||||
|
}
|
||||||
|
return m.signum() * m.groupValues[2].toLong() * unitSeconds * 1_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
val m = DURATION_GENERAL.matchEntire(s) ?: return 0L
|
||||||
|
val weeks = m.groupValues[2].toLongOrNull() ?: 0L
|
||||||
|
val days = m.groupValues[3].toLongOrNull() ?: 0L
|
||||||
|
val hours = m.groupValues[4].toLongOrNull() ?: 0L
|
||||||
|
val minutes = m.groupValues[5].toLongOrNull() ?: 0L
|
||||||
|
val seconds = m.groupValues[6].toLongOrNull() ?: 0L
|
||||||
|
val totalSeconds = ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds)
|
||||||
|
return m.signum() * totalSeconds * 1_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign carried by group 1 (`-` → -1, otherwise +1) of a duration match. */
|
||||||
|
private fun MatchResult.signum(): Long = if (groupValues[1] == "-") -1L else 1L
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single event ready to be serialised to a `VEVENT`, decoupled from the
|
||||||
|
* provider so [IcsWriter] stays pure-Kotlin and JVM-testable. [start]/[end] are
|
||||||
|
* absolute instants; [isAllDay] and [recurrenceRule] decide how they are
|
||||||
|
* rendered (see [IcsWriter]'s timezone rule).
|
||||||
|
*/
|
||||||
|
data class IcsEvent(
|
||||||
|
/** RFC 5545 UID — globally stable across exports (see [deriveIcsUid]). */
|
||||||
|
val uid: String,
|
||||||
|
val summary: String,
|
||||||
|
val start: Instant,
|
||||||
|
/** Exclusive end (for all-day: the next-day midnight, as the provider stores it). */
|
||||||
|
val end: Instant,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
/** IANA zone id (`Events.EVENT_TIMEZONE`); only used for recurring timed events. */
|
||||||
|
val zoneId: String,
|
||||||
|
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
||||||
|
val recurrenceRule: String? = null,
|
||||||
|
val location: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
/** Reminder lead times in minutes before start (raw provider offsets). */
|
||||||
|
val reminderMinutes: List<Int> = emptyList(),
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
/** Source calendar name, emitted as `X-CALENDULA-CALENDAR` so a combined backup can fan back out. */
|
||||||
|
val calendarName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UID to export for a provider event. A row that already carries a UID
|
||||||
|
* (`Events.UID_2445`) keeps it; otherwise we synthesise a **stable** one from
|
||||||
|
* the event id and its DTSTART so the same legacy event yields the same UID
|
||||||
|
* across repeated backups — which keeps a later restore from duplicating it.
|
||||||
|
*/
|
||||||
|
fun deriveIcsUid(existingUid: String?, eventId: Long, dtStartMillis: Long): String =
|
||||||
|
existingUid?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: "$eventId-$dtStartMillis@calendula"
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||||
|
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||||
|
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||||
|
* how the writer encoded them.
|
||||||
|
*/
|
||||||
|
data class ParsedIcsEvent(
|
||||||
|
val uid: String?,
|
||||||
|
val summary: String,
|
||||||
|
val start: Instant,
|
||||||
|
val end: Instant,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val zoneId: String,
|
||||||
|
val recurrenceRule: String? = null,
|
||||||
|
val location: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val reminderMinutes: List<Int> = emptyList(),
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
val calendarName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||||
|
enum class IcsParseWarning {
|
||||||
|
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||||
|
ModifiedOccurrenceSkipped,
|
||||||
|
|
||||||
|
/** A `VEVENT` with no parseable `DTSTART`. */
|
||||||
|
EventWithoutStartSkipped,
|
||||||
|
|
||||||
|
/** `ATTENDEE` rows were present; Calendula doesn't import attendees. */
|
||||||
|
AttendeesIgnored,
|
||||||
|
|
||||||
|
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||||
|
UnknownTimezone,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class IcsParseResult(
|
||||||
|
val events: List<ParsedIcsEvent>,
|
||||||
|
val warnings: Set<IcsParseWarning>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Outcome of a bulk `.ics` import into one calendar. */
|
||||||
|
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||||
|
* JVM-testable. Liberal-in/strict-out: unknown properties are ignored, a single
|
||||||
|
* malformed `VEVENT` is skipped (not fatal), and unsupported constructs
|
||||||
|
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||||
|
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||||
|
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||||
|
*/
|
||||||
|
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||||
|
|
||||||
|
fun parse(text: String): IcsParseResult {
|
||||||
|
val lines = unfoldLines(text)
|
||||||
|
val events = mutableListOf<ParsedIcsEvent>()
|
||||||
|
val warnings = mutableSetOf<IcsParseWarning>()
|
||||||
|
var calendarName: String? = null
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line == null) { i++; continue }
|
||||||
|
when {
|
||||||
|
line.isBegin("VEVENT") -> {
|
||||||
|
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
||||||
|
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
||||||
|
?.let(events::add)
|
||||||
|
i = end + 1
|
||||||
|
}
|
||||||
|
line.isBegin("VTIMEZONE") -> {
|
||||||
|
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||||
|
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||||
|
}
|
||||||
|
line.name == "X-WR-CALNAME" -> {
|
||||||
|
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
else -> i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IcsParseResult(events, warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseVevent(
|
||||||
|
body: List<String>,
|
||||||
|
fileCalendarName: String?,
|
||||||
|
warnings: MutableSet<IcsParseWarning>,
|
||||||
|
): ParsedIcsEvent? {
|
||||||
|
var uid: String? = null
|
||||||
|
var summary = ""
|
||||||
|
var dtStart: IcsDateTime? = null
|
||||||
|
var dtEnd: IcsDateTime? = null
|
||||||
|
var duration: String? = null
|
||||||
|
var rrule: String? = null
|
||||||
|
var location: String? = null
|
||||||
|
var description: String? = null
|
||||||
|
var status = EventStatus.Confirmed
|
||||||
|
var availability = Availability.Busy
|
||||||
|
var calendarName = fileCalendarName
|
||||||
|
val reminders = mutableListOf<Int>()
|
||||||
|
var skipAsOverride = false
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < body.size) {
|
||||||
|
val line = parseContentLine(body[i])
|
||||||
|
if (line == null) { i++; continue }
|
||||||
|
when (line.name) {
|
||||||
|
"BEGIN" -> if (line.value.trim().equals("VALARM", true)) {
|
||||||
|
val end = indexOfEnd(body, i + 1, "VALARM")
|
||||||
|
parseAlarmMinutes(body.subList(i + 1, end))?.let(reminders::add)
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||||
|
"SUMMARY" -> summary = unescapeText(line.value)
|
||||||
|
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||||
|
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||||
|
"DURATION" -> duration = line.value.trim()
|
||||||
|
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||||
|
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||||
|
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||||
|
"STATUS" -> status = mapIcsStatus(line.value)
|
||||||
|
"TRANSP" -> availability =
|
||||||
|
if (line.value.trim().equals("TRANSPARENT", true)) Availability.Free
|
||||||
|
else Availability.Busy
|
||||||
|
"RECURRENCE-ID" -> skipAsOverride = true
|
||||||
|
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||||
|
"X-CALENDULA-CALENDAR" ->
|
||||||
|
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipAsOverride) {
|
||||||
|
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val start = dtStart ?: run {
|
||||||
|
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val end = dtEnd
|
||||||
|
?: duration?.let {
|
||||||
|
start.copy(
|
||||||
|
instant = Instant.fromEpochMilliseconds(
|
||||||
|
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: start
|
||||||
|
return ParsedIcsEvent(
|
||||||
|
uid = uid,
|
||||||
|
summary = summary,
|
||||||
|
start = start.instant,
|
||||||
|
end = end.instant,
|
||||||
|
isAllDay = start.isAllDay,
|
||||||
|
zoneId = start.zoneId,
|
||||||
|
recurrenceRule = rrule,
|
||||||
|
location = location,
|
||||||
|
description = description,
|
||||||
|
reminderMinutes = reminders.distinct(),
|
||||||
|
status = status,
|
||||||
|
availability = availability,
|
||||||
|
calendarName = calendarName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
||||||
|
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||||
|
val trigger = body.asSequence()
|
||||||
|
.mapNotNull { parseContentLine(it) }
|
||||||
|
.firstOrNull { it.name == "TRIGGER" }
|
||||||
|
?: return null
|
||||||
|
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||||
|
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||||
|
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||||
|
// Negative = before start (the normal case) → positive lead minutes.
|
||||||
|
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||||
|
val raw = line.value.trim()
|
||||||
|
val isDate = line.params["VALUE"].equals("DATE", true) ||
|
||||||
|
(raw.length == 8 && !raw.contains('T'))
|
||||||
|
if (isDate) {
|
||||||
|
val date = parseBasicDate(raw) ?: return null
|
||||||
|
return IcsDateTime(date.atStartOfDayIn(TimeZone.UTC), isAllDay = true, zoneId = "UTC")
|
||||||
|
}
|
||||||
|
val isUtc = raw.endsWith("Z")
|
||||||
|
val ldt = parseBasicDateTime(raw.removeSuffix("Z")) ?: return null
|
||||||
|
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
|
||||||
|
|
||||||
|
val tzid = line.params["TZID"]
|
||||||
|
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
|
||||||
|
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
|
||||||
|
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
|
||||||
|
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class IcsDateTime(val instant: Instant, val isAllDay: Boolean, val zoneId: String)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun IcsContentLine.isBegin(component: String) =
|
||||||
|
name == "BEGIN" && value.trim().equals(component, true)
|
||||||
|
|
||||||
|
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||||
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
|
var i = from
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line != null && line.name == "END" &&
|
||||||
|
line.value.trim().equals(component, true)
|
||||||
|
) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return lines.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
|
||||||
|
"TENTATIVE" -> EventStatus.Tentative
|
||||||
|
"CANCELLED" -> EventStatus.Cancelled
|
||||||
|
else -> EventStatus.Confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseBasicDate(s: String): LocalDate? = runCatching {
|
||||||
|
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
|
||||||
|
val date = LocalDate(
|
||||||
|
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
|
||||||
|
val time = LocalTime(
|
||||||
|
s.substring(9, 11).toInt(),
|
||||||
|
s.substring(11, 13).toInt(),
|
||||||
|
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
|
||||||
|
)
|
||||||
|
LocalDateTime(date, time)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level RFC 5545 text mechanics, kept separate from [IcsWriter] so the
|
||||||
|
* escaping and folding rules can be tested in isolation. Pure Kotlin — no
|
||||||
|
* Android, no time handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** iCalendar mandates CRLF line breaks, not the platform separator. */
|
||||||
|
const val ICS_CRLF: String = "\r\n"
|
||||||
|
|
||||||
|
/** RFC 5545 §3.1: content lines SHOULD be folded at 75 octets (excluding the break). */
|
||||||
|
private const val MAX_OCTETS = 75
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a TEXT value (SUMMARY, DESCRIPTION, LOCATION, …) per RFC 5545 §3.3.11:
|
||||||
|
* backslash, semicolon and comma are escaped, newlines become the literal `\n`.
|
||||||
|
* Backslash is handled first so it doesn't double-escape the others' markers.
|
||||||
|
*/
|
||||||
|
fun escapeText(value: String): String = buildString(value.length) {
|
||||||
|
for (ch in value) {
|
||||||
|
when (ch) {
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
';' -> append("\\;")
|
||||||
|
',' -> append("\\,")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> Unit // CR is dropped; a lone CRLF in source text folds to one \n
|
||||||
|
else -> append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold a single content line to ≤75 octets per physical line, inserting
|
||||||
|
* `CRLF + space` between segments (the space is part of the 75-octet budget of
|
||||||
|
* the continuation line, so its content caps at 74). Folding counts UTF-8
|
||||||
|
* octets, never splitting a multi-byte character across a boundary.
|
||||||
|
*/
|
||||||
|
fun foldLine(line: String): String {
|
||||||
|
if (line.toByteArray(Charsets.UTF_8).size <= MAX_OCTETS) return line
|
||||||
|
val out = StringBuilder()
|
||||||
|
var octetsThisLine = 0
|
||||||
|
var first = true
|
||||||
|
var i = 0
|
||||||
|
while (i < line.length) {
|
||||||
|
val cp = line.codePointAt(i)
|
||||||
|
val width = Character.charCount(cp)
|
||||||
|
val piece = line.substring(i, i + width)
|
||||||
|
val pieceOctets = piece.toByteArray(Charsets.UTF_8).size
|
||||||
|
// Continuation lines spend one octet on the leading space.
|
||||||
|
val budget = if (first) MAX_OCTETS else MAX_OCTETS - 1
|
||||||
|
if (octetsThisLine + pieceOctets > budget) {
|
||||||
|
out.append(ICS_CRLF).append(' ')
|
||||||
|
octetsThisLine = 0
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
out.append(piece)
|
||||||
|
octetsThisLine += pieceOctets
|
||||||
|
i += width
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse of [escapeText]: turn `\n`/`\N` into newlines and unescape `\\`, `\;`,
|
||||||
|
* `\,`. A backslash before any other character is dropped, keeping the
|
||||||
|
* character (lenient — foreign files escape liberally).
|
||||||
|
*/
|
||||||
|
fun unescapeText(value: String): String = buildString(value.length) {
|
||||||
|
var i = 0
|
||||||
|
while (i < value.length) {
|
||||||
|
val c = value[i]
|
||||||
|
if (c == '\\' && i + 1 < value.length) {
|
||||||
|
when (val next = value[i + 1]) {
|
||||||
|
'n', 'N' -> append('\n')
|
||||||
|
else -> append(next) // \\, \;, \, and any other escaped char
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
append(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse of [foldLine] across a whole document: split into physical lines on
|
||||||
|
* CRLF/LF/CR, then re-join any line that begins with a single space or tab onto
|
||||||
|
* the previous one (RFC 5545 unfolding). Returns the logical content lines.
|
||||||
|
*/
|
||||||
|
fun unfoldLines(text: String): List<String> {
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
for (physical in text.split("\r\n", "\n", "\r")) {
|
||||||
|
if (physical.isEmpty()) continue
|
||||||
|
val isContinuation = physical[0] == ' ' || physical[0] == '\t'
|
||||||
|
if (isContinuation && out.isNotEmpty()) {
|
||||||
|
out[out.lastIndex] = out.last() + physical.substring(1)
|
||||||
|
} else {
|
||||||
|
out.add(physical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One unfolded content line split into its property name, parameters and value:
|
||||||
|
* `SUMMARY;LANGUAGE=en:Lunch` → name `SUMMARY`, params `{LANGUAGE=en}`, value
|
||||||
|
* `Lunch`. The value is everything after the first colon that isn't inside a
|
||||||
|
* quoted parameter; param keys are upper-cased, quoted param values unquoted.
|
||||||
|
* Returns null for a line with no colon.
|
||||||
|
*/
|
||||||
|
data class IcsContentLine(val name: String, val params: Map<String, String>, val value: String)
|
||||||
|
|
||||||
|
fun parseContentLine(line: String): IcsContentLine? {
|
||||||
|
var inQuote = false
|
||||||
|
var colon = -1
|
||||||
|
for (i in line.indices) {
|
||||||
|
when (line[i]) {
|
||||||
|
'"' -> inQuote = !inQuote
|
||||||
|
':' -> if (!inQuote) { colon = i; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colon < 0) return null
|
||||||
|
val head = splitUnquoted(line.substring(0, colon), ';')
|
||||||
|
val name = head.firstOrNull()?.trim()?.uppercase().orEmpty()
|
||||||
|
if (name.isEmpty()) return null
|
||||||
|
val params = buildMap {
|
||||||
|
for (part in head.drop(1)) {
|
||||||
|
val eq = part.indexOf('=')
|
||||||
|
if (eq > 0) {
|
||||||
|
put(
|
||||||
|
part.substring(0, eq).trim().uppercase(),
|
||||||
|
part.substring(eq + 1).trim().removeSurrounding("\""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IcsContentLine(name, params, line.substring(colon + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split on [delimiter] except where it falls inside a double-quoted run. */
|
||||||
|
private fun splitUnquoted(text: String, delimiter: Char): List<String> {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
val current = StringBuilder()
|
||||||
|
var inQuote = false
|
||||||
|
for (c in text) {
|
||||||
|
when {
|
||||||
|
c == '"' -> { inQuote = !inQuote; current.append(c) }
|
||||||
|
c == delimiter && !inQuote -> { parts.add(current.toString()); current.clear() }
|
||||||
|
else -> current.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.add(current.toString())
|
||||||
|
return parts
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Default `PRODID` advertising the writer that produced the file. */
|
||||||
|
const val ICS_PROD_ID: String = "-//Jean-Luc Makiola//Calendula//EN"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-rolled RFC 5545 serialiser for the subset Calendula models. No iCal
|
||||||
|
* library: we stay on `kotlinx-datetime` and own the output, exactly as
|
||||||
|
* `domain/Recurrence.kt` owns RRULE. Pure and JVM-testable — the caller
|
||||||
|
* supplies [dtStamp] (the export moment) so the writer never reads a clock.
|
||||||
|
*
|
||||||
|
* Timezone rule (see plan 05, decision 1):
|
||||||
|
* - all-day → `VALUE=DATE`, no zone;
|
||||||
|
* - timed one-off → UTC instant with a `Z` suffix (an instant is an instant);
|
||||||
|
* - timed recurring → `TZID`-labelled local wall time, so the series stays
|
||||||
|
* anchored to wall-clock across DST. No `VTIMEZONE` block is emitted; import
|
||||||
|
* resolves the `TZID` against the OS tz database.
|
||||||
|
*/
|
||||||
|
class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||||
|
|
||||||
|
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant): String {
|
||||||
|
val lines = buildList {
|
||||||
|
add("BEGIN:VCALENDAR")
|
||||||
|
add("VERSION:2.0")
|
||||||
|
add("PRODID:$prodId")
|
||||||
|
add("CALSCALE:GREGORIAN")
|
||||||
|
events.forEach { appendEvent(it, dtStamp) }
|
||||||
|
add("END:VCALENDAR")
|
||||||
|
}
|
||||||
|
return lines.joinToString(ICS_CRLF, postfix = ICS_CRLF) { foldLine(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
|
||||||
|
add("BEGIN:VEVENT")
|
||||||
|
add("UID:${event.uid}")
|
||||||
|
add("DTSTAMP:${utcStamp(dtStamp)}")
|
||||||
|
add("SUMMARY:${escapeText(event.summary)}")
|
||||||
|
appendTimes(event)
|
||||||
|
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
||||||
|
event.location?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("LOCATION:${escapeText(it)}") }
|
||||||
|
event.description?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("DESCRIPTION:${escapeText(it)}") }
|
||||||
|
add("STATUS:${statusValue(event.status)}")
|
||||||
|
add("TRANSP:${transpValue(event.availability)}")
|
||||||
|
event.calendarName?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("X-CALENDULA-CALENDAR:${escapeText(it)}") }
|
||||||
|
event.reminderMinutes.filter { it >= 0 }.distinct().forEach { minutes ->
|
||||||
|
appendAlarm(minutes, event.summary)
|
||||||
|
}
|
||||||
|
add("END:VEVENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
|
||||||
|
event.isAllDay -> {
|
||||||
|
add("DTSTART;VALUE=DATE:${utcDate(event.start)}")
|
||||||
|
add("DTEND;VALUE=DATE:${utcDate(event.end)}")
|
||||||
|
}
|
||||||
|
// Recurring: anchor to wall-clock in the event's own zone.
|
||||||
|
event.recurrenceRule?.isNotBlank() == true -> {
|
||||||
|
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull()
|
||||||
|
if (zone != null) {
|
||||||
|
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}")
|
||||||
|
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}")
|
||||||
|
} else {
|
||||||
|
// Unknown zone id → fall back to plain UTC instants.
|
||||||
|
add("DTSTART:${utcStamp(event.start)}")
|
||||||
|
add("DTEND:${utcStamp(event.end)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
add("DTSTART:${utcStamp(event.start)}")
|
||||||
|
add("DTEND:${utcStamp(event.end)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
|
||||||
|
add("BEGIN:VALARM")
|
||||||
|
add("ACTION:DISPLAY")
|
||||||
|
add("DESCRIPTION:${escapeText(summary.ifBlank { "Reminder" })}")
|
||||||
|
add("TRIGGER:${triggerValue(minutes)}")
|
||||||
|
add("END:VALARM")
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun statusValue(status: EventStatus): String = when (status) {
|
||||||
|
EventStatus.Confirmed -> "CONFIRMED"
|
||||||
|
EventStatus.Tentative -> "TENTATIVE"
|
||||||
|
EventStatus.Cancelled -> "CANCELLED"
|
||||||
|
}
|
||||||
|
|
||||||
|
// iCal TRANSP is binary; only an explicitly free event is TRANSPARENT.
|
||||||
|
fun transpValue(availability: Availability): String =
|
||||||
|
if (availability == Availability.Free) "TRANSPARENT" else "OPAQUE"
|
||||||
|
|
||||||
|
// A lead time of 0 fires at start (PT0M); anything positive is "before".
|
||||||
|
fun triggerValue(minutes: Int): String =
|
||||||
|
if (minutes <= 0) "PT0M" else "-PT${minutes}M"
|
||||||
|
|
||||||
|
fun utcStamp(instant: Instant): String =
|
||||||
|
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
|
||||||
|
|
||||||
|
fun localStamp(instant: Instant, zone: TimeZone): String =
|
||||||
|
basic(instant.toLocalDateTime(zone))
|
||||||
|
|
||||||
|
fun utcDate(instant: Instant): String {
|
||||||
|
val dt = instant.toLocalDateTime(TimeZone.UTC)
|
||||||
|
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
|
||||||
|
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill the create form from a single parsed `.ics` event (the "open one
|
||||||
|
* event" path). [calendarId] is left null so the form preselects the last-used
|
||||||
|
* calendar, exactly like a fresh create — the user confirms the target and
|
||||||
|
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||||
|
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||||
|
*/
|
||||||
|
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||||
|
val (start, end) = if (isAllDay) {
|
||||||
|
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
|
} else {
|
||||||
|
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
|
||||||
|
}
|
||||||
|
return EventForm(
|
||||||
|
calendarId = null,
|
||||||
|
title = summary,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
location = location.orEmpty(),
|
||||||
|
description = description.orEmpty(),
|
||||||
|
reminders = reminderMinutes.distinct().sorted(),
|
||||||
|
availability = availability,
|
||||||
|
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,29 +8,51 @@ import androidx.compose.animation.slideOutHorizontally
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||||
* pill in their top bars writes back here via [onSelectView].
|
* pill in their top bars writes back here via [onSelectView].
|
||||||
|
*
|
||||||
|
* [requestedDetailKey] is an externally requested occurrence (a tapped
|
||||||
|
* reminder notification routed through MainActivity): it opens the detail
|
||||||
|
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
|
||||||
|
* so a later recomposition can't re-open it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarHost(modifier: Modifier = Modifier) {
|
fun CalendarHost(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
requestedDetailKey: LongArray? = null,
|
||||||
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
|
widgetNavRequest: WidgetNavRequest? = null,
|
||||||
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
|
requestedImportUri: android.net.Uri? = null,
|
||||||
|
onImportConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||||
|
|
||||||
@@ -60,12 +82,83 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
detailKey = key
|
detailKey = key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A tapped reminder notification asks for a specific occurrence.
|
||||||
|
LaunchedEffect(requestedDetailKey) {
|
||||||
|
if (requestedDetailKey != null) {
|
||||||
|
heldKey = requestedDetailKey
|
||||||
|
detailKey = requestedDetailKey
|
||||||
|
onDetailKeyConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Settings (M4) is hoisted here so it overlays whichever calendar view is
|
// Settings (M4) is hoisted here so it overlays whichever calendar view is
|
||||||
// active and survives view switches. (The calendar filter now lives inline
|
// active and survives view switches. (The calendar filter now lives inline
|
||||||
// in the navigation drawer, so no overlay state is needed for it.)
|
// in the navigation drawer, so no overlay state is needed for it.)
|
||||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
val onOpenSettings = { showSettings = true }
|
val onOpenSettings = { showSettings = true }
|
||||||
|
|
||||||
|
// Calendar manager (reached from Settings) — its own overlay so it slides
|
||||||
|
// over Settings and survives view switches.
|
||||||
|
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||||
|
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||||
|
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
||||||
|
// when the form is opened from a day/week grid tap; null from the FAB.
|
||||||
|
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var heldCreateIso by remember { mutableStateOf<String?>(null) }
|
||||||
|
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||||
|
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
|
||||||
|
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
|
||||||
|
heldCreateIso = date.toString()
|
||||||
|
createDateIso = date.toString()
|
||||||
|
heldCreateMinutes = startMinutes
|
||||||
|
createStartMinutes = startMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
|
||||||
|
// recurring events the form itself asks for the write scope at save
|
||||||
|
// time. A saved edit closes the detail screen too: the occurrence the
|
||||||
|
// user tapped may not exist anymore (time moved, recurrence changed), so
|
||||||
|
// falling back to the auto-refreshing calendar is the only honest
|
||||||
|
// destination.
|
||||||
|
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||||
|
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
||||||
|
|
||||||
|
// An opened/received .ics file. [ImportScreen] parses it and either opens
|
||||||
|
// the prefilled create form (one event → [importForm]) or its own bulk
|
||||||
|
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||||
|
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
|
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||||
|
LaunchedEffect(requestedImportUri) {
|
||||||
|
if (requestedImportUri != null) {
|
||||||
|
importUri = requestedImportUri
|
||||||
|
onImportConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A home-screen widget launch asks to open a date (→ day view) or start a
|
||||||
|
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
||||||
|
LaunchedEffect(widgetNavRequest) {
|
||||||
|
when (val req = widgetNavRequest) {
|
||||||
|
is WidgetNavRequest.OpenDate -> {
|
||||||
|
pendingDayIso = req.dateIso
|
||||||
|
view = CalendarView.Day
|
||||||
|
onWidgetNavConsumed()
|
||||||
|
}
|
||||||
|
is WidgetNavRequest.Create -> {
|
||||||
|
val iso = req.dateIso ?: Clock.System.now()
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
|
||||||
|
heldCreateIso = iso
|
||||||
|
createDateIso = iso
|
||||||
|
heldCreateMinutes = null
|
||||||
|
createStartMinutes = null
|
||||||
|
onWidgetNavConsumed()
|
||||||
|
}
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
@@ -75,12 +168,14 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = view,
|
selectedView = view,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
@@ -88,6 +183,14 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
)
|
||||||
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
|
selectedView = view,
|
||||||
|
onSelectView = onSelectView,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +207,45 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
beginMillis = key[1],
|
beginMillis = key[1],
|
||||||
endMillis = key[2],
|
endMillis = key[2],
|
||||||
onBack = { detailKey = null },
|
onBack = { detailKey = null },
|
||||||
|
onEdit = {
|
||||||
|
heldEditKey = key
|
||||||
|
editKey = key
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event form (v1.2) — full-screen destination, slides over the calendar.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = createDateIso != null,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||||
|
EventEditScreen(
|
||||||
|
initialDateIso = iso,
|
||||||
|
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||||
|
onClose = { createDateIso = null },
|
||||||
|
onSaved = { createDateIso = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit form (v1.3) — slides over the detail screen.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = editKey != null,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
(editKey ?: heldEditKey)?.let { key ->
|
||||||
|
EventEditScreen(
|
||||||
|
initialDateIso = null,
|
||||||
|
editKey = key,
|
||||||
|
onClose = { editKey = null },
|
||||||
|
onSaved = {
|
||||||
|
editKey = null
|
||||||
|
detailKey = null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +256,40 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
SettingsScreen(onBack = { showSettings = false })
|
SettingsScreen(
|
||||||
|
onBack = { showSettings = false },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar manager — slides over Settings.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showCalendars,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
CalendarsScreen(onBack = { showCalendars = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import flow for an opened/received .ics file. A single event routes
|
||||||
|
// into the create form (prefilled, for review); many open the picker.
|
||||||
|
importUri?.let { uri ->
|
||||||
|
ImportScreen(
|
||||||
|
uri = uri,
|
||||||
|
onClose = { importUri = null },
|
||||||
|
onOpenSingle = { form ->
|
||||||
|
importUri = null
|
||||||
|
importForm = form
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
importForm?.let { form ->
|
||||||
|
EventEditScreen(
|
||||||
|
initialDateIso = null,
|
||||||
|
initialForm = form,
|
||||||
|
onClose = { importForm = null },
|
||||||
|
onSaved = { importForm = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,26 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RootScreen(modifier: Modifier = Modifier) {
|
fun RootScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
requestedDetailKey: LongArray? = null,
|
||||||
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
|
widgetNavRequest: WidgetNavRequest? = null,
|
||||||
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
|
requestedImportUri: android.net.Uri? = null,
|
||||||
|
onImportConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -40,7 +52,27 @@ fun RootScreen(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasPermission) {
|
if (hasPermission) {
|
||||||
CalendarHost(modifier = modifier)
|
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||||
|
// Null until DataStore's first emission — render nothing for that
|
||||||
|
// frame instead of flashing the wrong screen.
|
||||||
|
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||||
|
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||||
|
when (onboardingDone) {
|
||||||
|
true -> CalendarHost(
|
||||||
|
modifier = modifier,
|
||||||
|
requestedDetailKey = requestedDetailKey,
|
||||||
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||||
|
widgetNavRequest = widgetNavRequest,
|
||||||
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = onImportConsumed,
|
||||||
|
)
|
||||||
|
false -> ReminderOnboardingScreen(
|
||||||
|
onFinished = reminderOnboarding::finish,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
PermissionScreen(
|
PermissionScreen(
|
||||||
onGranted = { hasPermission = true },
|
onGranted = { hasPermission = true },
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A navigation a home-screen widget asked the app to perform when launched.
|
||||||
|
* Parsed from the launch intent in MainActivity and consumed once by
|
||||||
|
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
|
||||||
|
* they are not modelled here).
|
||||||
|
*/
|
||||||
|
sealed interface WidgetNavRequest {
|
||||||
|
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
|
||||||
|
data class OpenDate(val dateIso: String) : WidgetNavRequest
|
||||||
|
|
||||||
|
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||||
|
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.EventAvailable
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AgendaScreen(
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val isOnToday = when (val s = state) {
|
||||||
|
is AgendaUiState.Success -> s.anchor == s.today
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContent = {
|
||||||
|
CalendarDrawer(
|
||||||
|
currentView = selectedView,
|
||||||
|
currentDate = anchor,
|
||||||
|
onSelectView = { view ->
|
||||||
|
onSelectView(view)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
viewModel.goToDate(target)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onSettings = {
|
||||||
|
onOpenSettings()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
AgendaTopBar(
|
||||||
|
selectedView = selectedView,
|
||||||
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
CalendarFabColumn(
|
||||||
|
todayVisible = !isOnToday,
|
||||||
|
todayText = stringResource(R.string.agenda_today_action),
|
||||||
|
onToday = viewModel::goToToday,
|
||||||
|
onCreate = { onCreateEvent(anchor, null) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
AgendaContent(
|
||||||
|
state = state,
|
||||||
|
onRetry = viewModel::goToToday,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaContent(
|
||||||
|
state: AgendaUiState,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
AgendaUiState.Loading -> Box(modifier)
|
||||||
|
is AgendaUiState.Failure -> Box(modifier) {
|
||||||
|
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||||
|
}
|
||||||
|
is AgendaUiState.Success ->
|
||||||
|
if (state.days.isEmpty()) {
|
||||||
|
AgendaEmpty(modifier)
|
||||||
|
} else {
|
||||||
|
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun AgendaList(
|
||||||
|
state: AgendaUiState.Success,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier,
|
||||||
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
|
) {
|
||||||
|
state.days.forEach { day ->
|
||||||
|
stickyHeader(key = "header-${day.date}") {
|
||||||
|
AgendaDayHeader(date = day.date, today = state.today)
|
||||||
|
}
|
||||||
|
itemsIndexed(
|
||||||
|
items = day.events,
|
||||||
|
key = { _, event -> event.instanceId },
|
||||||
|
) { index, event ->
|
||||||
|
AgendaEventRow(
|
||||||
|
event = event,
|
||||||
|
position = positionOf(index, day.events.size),
|
||||||
|
onClick = { onEventClick(event) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = agendaDayLabel(date, today),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = if (date == today) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaEventRow(
|
||||||
|
event: EventInstance,
|
||||||
|
position: Position,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
GroupedRow(
|
||||||
|
title = title,
|
||||||
|
summary = agendaTimeSummary(event),
|
||||||
|
position = position,
|
||||||
|
minHeight = 64.dp,
|
||||||
|
leading = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 6.dp, height = 36.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(pastelize(event.color, dark)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.EventAvailable,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.agenda_empty_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.agenda_empty_subtitle),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun AgendaTopBar(
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onCycleView: () -> Unit,
|
||||||
|
onOpenDrawer: () -> Unit,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.view_agenda),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onOpenDrawer) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Menu,
|
||||||
|
contentDescription = stringResource(R.string.month_open_menu),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
ViewSwitcherPill(
|
||||||
|
current = selectedView,
|
||||||
|
onCycle = onCycleView,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||||
|
@Composable
|
||||||
|
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||||
|
val relative = when (date) {
|
||||||
|
today -> stringResource(R.string.agenda_header_today)
|
||||||
|
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val formatted = formatAgendaDate(date)
|
||||||
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */
|
||||||
|
@Composable
|
||||||
|
private fun agendaTimeSummary(event: EventInstance): String {
|
||||||
|
val time = if (event.isAllDay) {
|
||||||
|
stringResource(R.string.event_detail_all_day)
|
||||||
|
} else {
|
||||||
|
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||||
|
}
|
||||||
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
|
return if (location != null) "$time · $location" else time
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(instant: Instant): String {
|
||||||
|
val t = instant.toLocalDateTime(zone).time
|
||||||
|
return "%02d:%02d".format(t.hour, t.minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
|
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
/** One calendar day with at least one event, for the agenda list. */
|
||||||
|
data class AgendaDay(
|
||||||
|
val date: LocalDate,
|
||||||
|
/** Events on this day, all-day first then ascending by start time. */
|
||||||
|
val events: List<EventInstance>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||||
|
* actually carry events). An event that began before [anchor] (ongoing or
|
||||||
|
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
||||||
|
* day, all-day events sort first, then ascending by start time, then title.
|
||||||
|
*
|
||||||
|
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||||
|
* and order identically.
|
||||||
|
*/
|
||||||
|
fun groupAgendaDays(
|
||||||
|
anchor: LocalDate,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<AgendaDay> =
|
||||||
|
instances
|
||||||
|
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
||||||
|
.toSortedMap()
|
||||||
|
.map { (date, dayEvents) ->
|
||||||
|
AgendaDay(
|
||||||
|
date = date,
|
||||||
|
events = dayEvents.sortedWith(
|
||||||
|
compareByDescending<EventInstance> { it.isAllDay }
|
||||||
|
.thenBy { it.start }
|
||||||
|
.thenBy { it.title },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||||
|
* grouped by day (only days that actually have events appear).
|
||||||
|
*/
|
||||||
|
sealed interface AgendaUiState {
|
||||||
|
data object Loading : AgendaUiState
|
||||||
|
data class Failure(val reason: FailureReason) : AgendaUiState
|
||||||
|
data class Success(
|
||||||
|
/** First day of the loaded window (today, or a jumped-to date). */
|
||||||
|
val anchor: LocalDate,
|
||||||
|
val today: LocalDate,
|
||||||
|
val days: List<AgendaDay>,
|
||||||
|
) : AgendaUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
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.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** How far ahead the agenda loads events from its anchor day. */
|
||||||
|
internal const val AGENDA_WINDOW_DAYS = 60
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class AgendaViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
private val todayDate: LocalDate
|
||||||
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
private val _anchor = MutableStateFlow(todayDate)
|
||||||
|
val anchor: StateFlow<LocalDate> = _anchor
|
||||||
|
|
||||||
|
val state: StateFlow<AgendaUiState> = _anchor
|
||||||
|
.flatMapLatest { anchor ->
|
||||||
|
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(range),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildState(anchor, calendars, instances)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = AgendaUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun goToToday() {
|
||||||
|
_anchor.value = todayDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */
|
||||||
|
fun goToDate(date: LocalDate) {
|
||||||
|
_anchor.value = date
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildState(
|
||||||
|
anchor: LocalDate,
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
): AgendaUiState {
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
|
val days = groupAgendaDays(anchor, instances, zone)
|
||||||
|
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
||||||
|
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
|
||||||
|
val from = anchor.atStartOfDayIn(zone)
|
||||||
|
val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||||
|
return from..to
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import android.accounts.AccountManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.FileDownload
|
||||||
|
import androidx.compose.material.icons.filled.OpenInNew
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||||
|
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
||||||
|
* and lists synced calendars read-only with a per-account "manage in the source
|
||||||
|
* app" deep-link — the app never touches a synced calendar's server. A
|
||||||
|
* full-screen destination; [onBack] pops it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||||
|
// [editorSession] bumps on every open so the editor's field state resets for
|
||||||
|
// a fresh open while still surviving configuration changes within one open.
|
||||||
|
var editorId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||||
|
var editorSession by rememberSaveable { mutableStateOf(0) }
|
||||||
|
|
||||||
|
if (editorId != null) {
|
||||||
|
val editing = calendars.firstOrNull { it.id == editorId }
|
||||||
|
CalendarEditor(
|
||||||
|
sessionKey = editorSession,
|
||||||
|
isNew = editorId == NEW_CALENDAR_ID,
|
||||||
|
initialName = editing?.displayName.orEmpty(),
|
||||||
|
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
|
||||||
|
initialDescription = editing?.description.orEmpty(),
|
||||||
|
onSave = { name, color, description ->
|
||||||
|
val id = editorId
|
||||||
|
if (id == null || id == NEW_CALENDAR_ID) {
|
||||||
|
viewModel.createCalendar(name, color, description)
|
||||||
|
} else {
|
||||||
|
viewModel.updateCalendar(id, name, color, description)
|
||||||
|
}
|
||||||
|
editorId = null
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
editorId?.takeIf { it != NEW_CALENDAR_ID }?.let(viewModel::deleteCalendar)
|
||||||
|
editorId = null
|
||||||
|
},
|
||||||
|
onClose = { editorId = null },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CalendarsList(
|
||||||
|
local = calendars.filter { it.isLocal },
|
||||||
|
synced = calendars.filterNot { it.isLocal },
|
||||||
|
error = error,
|
||||||
|
onConsumeError = viewModel::consumeError,
|
||||||
|
backupResult = backupResult,
|
||||||
|
onExportBackup = viewModel::exportBackup,
|
||||||
|
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||||
|
onBack = onBack,
|
||||||
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CalendarsList(
|
||||||
|
local: List<CalendarSource>,
|
||||||
|
synced: List<CalendarSource>,
|
||||||
|
error: Boolean,
|
||||||
|
onConsumeError: () -> Unit,
|
||||||
|
backupResult: BackupResult?,
|
||||||
|
onExportBackup: (android.net.Uri) -> Unit,
|
||||||
|
onConsumeBackupResult: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
onEdit: (CalendarSource) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||||
|
|
||||||
|
LaunchedEffect(error) {
|
||||||
|
if (error) {
|
||||||
|
snackbarHostState.showSnackbar(writeErrorText)
|
||||||
|
onConsumeError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||||
|
// to the VM to stream the .ics into.
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri -> uri?.let(onExportBackup) }
|
||||||
|
|
||||||
|
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||||
|
LaunchedEffect(backupResult) {
|
||||||
|
when (val r = backupResult) {
|
||||||
|
is BackupResult.Success -> {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
context.resources.getQuantityString(
|
||||||
|
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
onConsumeBackupResult()
|
||||||
|
}
|
||||||
|
BackupResult.Failure -> {
|
||||||
|
snackbarHostState.showSnackbar(backupFailedText)
|
||||||
|
onConsumeBackupResult()
|
||||||
|
}
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.calendars_title),
|
||||||
|
onBack = onBack,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
) {
|
||||||
|
// Local (device-only) calendars — the calendars the app owns. The
|
||||||
|
// "Add calendar" entry closes the group as its final row.
|
||||||
|
SectionHeader(stringResource(R.string.calendars_local_header))
|
||||||
|
if (local.isEmpty()) {
|
||||||
|
HintText(stringResource(R.string.calendars_local_empty))
|
||||||
|
}
|
||||||
|
val localCount = local.size + 1
|
||||||
|
local.forEachIndexed { index, calendar ->
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = calendar.description,
|
||||||
|
position = positionOf(index, localCount),
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = stringResource(R.string.calendars_edit_title),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { onEdit(calendar) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_add),
|
||||||
|
position = positionOf(local.size, localCount),
|
||||||
|
leading = { AddAvatar() },
|
||||||
|
onClick = onAdd,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Backup — local calendars have no sync, so a .ics export is their only
|
||||||
|
// safety net. Offered only when there is something to back up.
|
||||||
|
if (local.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||||
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
|
onClick = {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Synced calendars — read-only, grouped by account, each with a
|
||||||
|
// per-account "manage in source app" link.
|
||||||
|
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||||
|
HintText(stringResource(R.string.calendars_synced_hint))
|
||||||
|
synced
|
||||||
|
.groupBy { it.accountName.ifBlank { it.accountType } }
|
||||||
|
.forEach { (account, cals) ->
|
||||||
|
AccountHeader(account = account, accountType = cals.first().accountType)
|
||||||
|
cals.forEachIndexed { index, calendar ->
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
position = positionOf(index, cals.size),
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_add_account),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { AddAvatar() },
|
||||||
|
onClick = {
|
||||||
|
runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun CalendarEditor(
|
||||||
|
sessionKey: Int,
|
||||||
|
isNew: Boolean,
|
||||||
|
initialName: String,
|
||||||
|
initialColor: Int,
|
||||||
|
initialDescription: String,
|
||||||
|
onSave: (name: String, color: Int, description: String?) -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
||||||
|
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||||
|
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||||
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
BackHandler(onBack = onClose)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (isNew) R.string.calendars_new_title
|
||||||
|
else R.string.calendars_edit_title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.event_edit_close),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (!isNew) {
|
||||||
|
IconButton(onClick = { confirmDelete = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_delete),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filled save button, matching the event editor's top bar.
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSave(name.trim(), color, description.trim().ifEmpty { null })
|
||||||
|
},
|
||||||
|
enabled = name.isNotBlank(),
|
||||||
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.event_edit_save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) {
|
||||||
|
InlineTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
placeholder = stringResource(R.string.calendars_name_label),
|
||||||
|
textStyle = MaterialTheme.typography.titleLarge,
|
||||||
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
EditorCard(
|
||||||
|
icon = Icons.Default.Palette,
|
||||||
|
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
iconAtTop = true,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_color_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
ColorSwatchRow(
|
||||||
|
colors = CALENDAR_COLOR_PALETTE,
|
||||||
|
selected = color,
|
||||||
|
onSelect = { color = it },
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
EditorCard(
|
||||||
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
|
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
iconAtTop = true,
|
||||||
|
) {
|
||||||
|
InlineTextField(
|
||||||
|
value = description,
|
||||||
|
onValueChange = { description = it },
|
||||||
|
placeholder = stringResource(R.string.calendars_description_hint),
|
||||||
|
singleLine = false,
|
||||||
|
minLines = 2,
|
||||||
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmDelete) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmDelete = false },
|
||||||
|
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
confirmDelete = false
|
||||||
|
onDelete()
|
||||||
|
}) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.event_detail_delete),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmDelete = false }) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tonal field card matching the event editor's design (icon + content). */
|
||||||
|
@Composable
|
||||||
|
private fun EditorCard(
|
||||||
|
icon: ImageVector,
|
||||||
|
iconTint: Color,
|
||||||
|
iconAtTop: Boolean = false,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = if (iconAtTop) 2.dp else 0.dp)
|
||||||
|
.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccountHeader(account: String, accountType: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = account,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(stringResource(R.string.calendars_manage_in_app))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */
|
||||||
|
@Composable
|
||||||
|
private fun LeadingAvatar(icon: ImageVector) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
||||||
|
@Composable
|
||||||
|
private fun AddAvatar() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HintText(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the app to open for managing a synced calendar's account. The account's
|
||||||
|
* own authenticator package (resolved from [AccountManager], no permission
|
||||||
|
* needed) handles any sync provider — DAVx5, ICSx5, Nextcloud, … — and a small
|
||||||
|
* curated map redirects the few cases where the authenticator isn't the app to
|
||||||
|
* open (Google's authenticator is Play Services, but users want the Calendar
|
||||||
|
* app). Falls back to the system account settings when nothing launchable is
|
||||||
|
* found, so the button always lands somewhere sensible.
|
||||||
|
*/
|
||||||
|
private fun sourceAppIntent(context: Context, accountType: String): Intent {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val candidates = buildList {
|
||||||
|
AccountManager.get(context).authenticatorTypes
|
||||||
|
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||||
|
?.packageName
|
||||||
|
?.let { add(it) }
|
||||||
|
curatedSourcePackage(accountType)?.let { add(it) }
|
||||||
|
}
|
||||||
|
for (pkg in candidates) {
|
||||||
|
pm.getLaunchIntentForPackage(pkg)?.let { return it }
|
||||||
|
}
|
||||||
|
return Intent(Settings.ACTION_SYNC_SETTINGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||||
|
private fun curatedSourcePackage(accountType: String): String? = when {
|
||||||
|
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
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.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
|
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.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs the calendar manager: lists every calendar (the screen splits them into
|
||||||
|
* the app's own local calendars and read-only/synced ones) and creates,
|
||||||
|
* renames, recolors or deletes the local calendars the app owns. Write failures
|
||||||
|
* flip [error] so the screen can surface a one-shot message.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class CalendarsViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val icsExporter: IcsExporter,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val calendars: StateFlow<List<CalendarSource>> =
|
||||||
|
repository.calendars()
|
||||||
|
.catch { emit(emptyList()) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _error = MutableStateFlow(false)
|
||||||
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
|
fun consumeError() { _error.value = false }
|
||||||
|
|
||||||
|
private val _backupResult = MutableStateFlow<BackupResult?>(null)
|
||||||
|
val backupResult: StateFlow<BackupResult?> = _backupResult.asStateFlow()
|
||||||
|
|
||||||
|
fun consumeBackupResult() { _backupResult.value = null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise every event of the writable local calendars into the chosen SAF
|
||||||
|
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||||
|
* in [backupResult] for a one-shot message.
|
||||||
|
*/
|
||||||
|
fun exportBackup(uri: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_backupResult.value = try {
|
||||||
|
val count = withContext(io) {
|
||||||
|
val events = repository.exportEvents()
|
||||||
|
icsExporter.writeDocument(
|
||||||
|
uri = uri,
|
||||||
|
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||||
|
)
|
||||||
|
events.size
|
||||||
|
}
|
||||||
|
BackupResult.Success(count)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
BackupResult.Failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createCalendar(displayName: String, color: Int, description: String?) = write {
|
||||||
|
repository.createLocalCalendar(displayName, color, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) = write {
|
||||||
|
repository.updateCalendar(id, displayName, color, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteCalendar(id: Long) = write {
|
||||||
|
repository.deleteCalendar(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun write(crossinline block: suspend () -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||||
|
sealed interface BackupResult {
|
||||||
|
data class Success(val eventCount: Int) : BackupResult
|
||||||
|
data object Failure : BackupResult
|
||||||
|
}
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soften a raw calendar color toward a pastel that fits the active theme.
|
* Soften a raw calendar color toward a pastel that fits the active theme.
|
||||||
@@ -15,3 +29,27 @@ fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
|||||||
hsv[2] = if (dark) 0.82f else 0.72f
|
hsv[2] = if (dark) 0.82f else 0.72f
|
||||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
||||||
|
* in the calendar's (pastelised) colour. Shared by the calendar manager and the
|
||||||
|
* visibility filter so they read identically.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.CalendarMonth,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = pastelize(color, dark),
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/** One UTC day in milliseconds — the unit the M3 [DatePicker] speaks. */
|
||||||
|
const val MILLIS_PER_DAY: Long = 86_400_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's standard Material 3 date picker, opened on [initial] and reporting
|
||||||
|
* the chosen day through [onConfirm]. Shared by the event form (start/end date,
|
||||||
|
* RRULE until) and the drawer's jump-to-date action.
|
||||||
|
*
|
||||||
|
* DatePicker speaks UTC-midnight millis; epoch-day arithmetic keeps the
|
||||||
|
* conversion zone-proof in both directions.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarDatePickerDialog(
|
||||||
|
initial: LocalDate,
|
||||||
|
onConfirm: (LocalDate) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
|
||||||
|
)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
state.selectedDateMillis?.let { millis ->
|
||||||
|
onConfirm(LocalDate.fromEpochDays((millis / MILLIS_PER_DAY).toInt()))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.dialog_ok)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
DatePicker(state = state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,80 +1,155 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Today
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
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
|
||||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation drawer shared by every top-level calendar screen.
|
* Navigation drawer shared by every top-level calendar screen.
|
||||||
*
|
*
|
||||||
* Visual language (kept deliberately small so sizes don't drift):
|
* Uses the app's grouped-card design system (see [GroupedRow]): a branded
|
||||||
* - Drawer title — `titleLarge`
|
* header, the View switcher as a grouped card (the active view highlighted),
|
||||||
* - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only
|
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
||||||
* - Nav items (Today / Settings) — Material `NavigationDrawerItem`
|
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
||||||
* (`labelLarge` label + a single 24dp leading icon)
|
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
||||||
|
* The host screen owns the drawer state.
|
||||||
*
|
*
|
||||||
* Hosts the per-calendar visibility filter (M3) inline — the calendar list with
|
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
||||||
* its checkboxes lives here rather than in a separate sheet — plus the "today"
|
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
||||||
* jump and a Settings entry (M4). The host screen owns the drawer state.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarDrawer(
|
fun CalendarDrawer(
|
||||||
onToday: () -> Unit,
|
currentView: CalendarView,
|
||||||
|
currentDate: LocalDate,
|
||||||
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
Column(Modifier.fillMaxHeight()) {
|
// The whole sidebar scrolls as one — header, views, the calendar filter
|
||||||
Text(
|
// and Settings all flow in a single scroll container.
|
||||||
text = stringResource(R.string.app_name),
|
Column(
|
||||||
style = MaterialTheme.typography.titleLarge,
|
Modifier
|
||||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
|
.fillMaxHeight()
|
||||||
)
|
.verticalScroll(rememberScrollState()),
|
||||||
HorizontalDivider()
|
) {
|
||||||
Spacer(Modifier.height(8.dp))
|
DrawerHeader()
|
||||||
NavigationDrawerItem(
|
|
||||||
icon = { Icon(Icons.Filled.Today, contentDescription = null) },
|
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||||
label = { Text(stringResource(R.string.month_today_action)) },
|
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||||
selected = false,
|
GroupedRow(
|
||||||
onClick = onToday,
|
title = stringResource(view.labelRes),
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||||
)
|
selected = view == currentView,
|
||||||
Spacer(Modifier.height(8.dp))
|
minHeight = 56.dp,
|
||||||
HorizontalDivider()
|
leading = { Icon(view.icon, contentDescription = null) },
|
||||||
|
onClick = { onSelectView(view) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.drawer_jump_to_date),
|
||||||
|
position = Position.Alone,
|
||||||
|
minHeight = 56.dp,
|
||||||
|
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
|
||||||
|
onClick = { showDatePicker = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Calendars (M3) — visibility checkboxes, scrollable, takes the slack
|
|
||||||
// between the top actions and the pinned Settings entry.
|
|
||||||
DrawerSectionHeader(stringResource(R.string.filter_title))
|
DrawerSectionHeader(stringResource(R.string.filter_title))
|
||||||
CalendarFilterList(modifier = Modifier.weight(1f))
|
CalendarFilterList()
|
||||||
|
|
||||||
HorizontalDivider()
|
Spacer(Modifier.height(16.dp))
|
||||||
Spacer(Modifier.height(8.dp))
|
GroupedRow(
|
||||||
NavigationDrawerItem(
|
title = stringResource(R.string.month_action_settings),
|
||||||
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
position = Position.Alone,
|
||||||
label = { Text(stringResource(R.string.month_action_settings)) },
|
minHeight = 56.dp,
|
||||||
selected = false,
|
leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||||
onClick = onSettings,
|
onClick = onSettings,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDatePicker) {
|
||||||
|
CalendarDatePickerDialog(
|
||||||
|
initial = currentDate,
|
||||||
|
onConfirm = {
|
||||||
|
showDatePicker = false
|
||||||
|
onJumpToDate(it)
|
||||||
|
},
|
||||||
|
onDismiss = { showDatePicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branded header: the app-icon chip beside the app name. */
|
||||||
|
@Composable
|
||||||
|
private fun DrawerHeader() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.requiredSize(66.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-level grouping label in the drawer. Text only, so it never reads as a
|
/** Top-level grouping label in the drawer. Text only, so it never reads as a
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The FAB stack shared by the three calendar views: a persistent "+" to
|
||||||
|
* create an event, with the jump-to-today pill appearing above it whenever
|
||||||
|
* the view isn't anchored on today.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarFabColumn(
|
||||||
|
todayVisible: Boolean,
|
||||||
|
todayText: String,
|
||||||
|
onToday: () -> Unit,
|
||||||
|
onCreate: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = todayVisible,
|
||||||
|
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||||
|
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = onToday,
|
||||||
|
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||||
|
text = { Text(todayText) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FloatingActionButton(onClick = onCreate) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(R.string.event_edit_new_title),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,39 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
/**
|
import androidx.annotation.StringRes
|
||||||
* The top-level calendar views the user can switch between (spec M1).
|
import androidx.compose.material.icons.Icons
|
||||||
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
|
import androidx.compose.material.icons.filled.CalendarViewDay
|
||||||
*/
|
import androidx.compose.material.icons.filled.CalendarViewMonth
|
||||||
|
import androidx.compose.material.icons.filled.CalendarViewWeek
|
||||||
|
import androidx.compose.material.icons.filled.ViewAgenda
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** The top-level calendar views the user can switch between (spec M1). */
|
||||||
enum class CalendarView {
|
enum class CalendarView {
|
||||||
Month,
|
Month,
|
||||||
Week,
|
Week,
|
||||||
Day,
|
Day,
|
||||||
|
Agenda,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
|
||||||
|
@get:StringRes
|
||||||
|
val CalendarView.labelRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
CalendarView.Month -> R.string.view_month
|
||||||
|
CalendarView.Week -> R.string.view_week
|
||||||
|
CalendarView.Day -> R.string.view_day
|
||||||
|
CalendarView.Agenda -> R.string.view_agenda
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leading icon for the view in the drawer's View section. */
|
||||||
|
val CalendarView.icon: ImageVector
|
||||||
|
get() = when (this) {
|
||||||
|
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
||||||
|
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
||||||
|
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
||||||
|
CalendarView.Agenda -> Icons.Filled.ViewAgenda
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +41,7 @@ enum class CalendarView {
|
|||||||
* through these in order.
|
* through these in order.
|
||||||
*/
|
*/
|
||||||
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||||
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day)
|
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
|
||||||
|
|
||||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapping row of round colour swatches; the one matching [selected] is
|
||||||
|
* ringed and checked. Shared by the calendar editor and the event-colour
|
||||||
|
* picker so both pick a colour the same way. Swatches render through
|
||||||
|
* [pastelize] — the softened colour the app actually paints, not the raw hue.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ColorSwatchRow(
|
||||||
|
colors: List<Int>,
|
||||||
|
selected: Int?,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
dark: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
colors.forEach { argb ->
|
||||||
|
val isSelected = argb == selected
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(pastelize(argb, dark))
|
||||||
|
.then(
|
||||||
|
if (isSelected) {
|
||||||
|
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.clickable { onSelect(argb) },
|
||||||
|
) {
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Black.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */
|
||||||
|
val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
|
||||||
|
0xFFD50000, // red
|
||||||
|
0xFFE67C00, // orange
|
||||||
|
0xFFF6BF26, // amber
|
||||||
|
0xFF33B679, // green
|
||||||
|
0xFF0B8043, // dark green
|
||||||
|
0xFF039BE5, // blue
|
||||||
|
0xFF3F51B5, // indigo
|
||||||
|
0xFF8E24AA, // purple
|
||||||
|
0xFF616161, // graphite
|
||||||
|
).map { it.toInt() }
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
|
||||||
|
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
|
||||||
|
* matches the card/grouped-row design language (not Material's outlined field).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DialogAmountField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
placeholder: String,
|
||||||
|
) {
|
||||||
|
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
|
||||||
|
// so anything lower vanishes.
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
InlineTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = { text ->
|
||||||
|
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
|
||||||
|
},
|
||||||
|
placeholder = placeholder,
|
||||||
|
textStyle = MaterialTheme.typography.titleMedium,
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(72.dp)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
|
||||||
|
@Composable
|
||||||
|
fun DialogUnitDropdown(
|
||||||
|
label: String,
|
||||||
|
entries: List<String>,
|
||||||
|
onPick: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
onClick = { open = true },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(text = label, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
entries.forEachIndexed { index, entry ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(entry) },
|
||||||
|
onClick = {
|
||||||
|
onPick(index)
|
||||||
|
open = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LargeTopAppBar
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position of a row within a grouped list, after the Android-15 settings
|
||||||
|
* pattern: a run of rows shares one rounded container, with full corners at the
|
||||||
|
* group's outer edges and small corners between, separated by small gaps.
|
||||||
|
*/
|
||||||
|
enum class Position { Top, Middle, Bottom, Alone }
|
||||||
|
|
||||||
|
/** Maps an index within a group of [count] rows to its [Position]. */
|
||||||
|
fun positionOf(index: Int, count: Int): Position = when {
|
||||||
|
count <= 1 -> Position.Alone
|
||||||
|
index == 0 -> Position.Top
|
||||||
|
index == count - 1 -> Position.Bottom
|
||||||
|
else -> Position.Middle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
|
||||||
|
* whose title shrinks into the bar (next to the back button) as the content
|
||||||
|
* scrolls. Content is a scrollable column that feeds the toolbar via nested
|
||||||
|
* scroll. Used by Settings and the calendar manager so they share one shell.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CollapsingScaffold(
|
||||||
|
title: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
snackbarHost: @Composable () -> Unit = {},
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
val scrollBehavior =
|
||||||
|
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
LargeTopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.settings_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = actions,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = snackbarHost,
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
// Mark the scaffold's system-bar insets as consumed so the
|
||||||
|
// imePadding below adds only the keyboard height beyond them
|
||||||
|
// (max, not sum) — otherwise the nav-bar inset double-counts and
|
||||||
|
// leaves an empty strip above the keyboard.
|
||||||
|
.consumeWindowInsets(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
// Paint the surface across the full area before imePadding carves
|
||||||
|
// into it, so any sliver above the keyboard reads as surface — not
|
||||||
|
// the dialog window's black — during the IME animation.
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
// Shrink the scroll viewport by the keyboard inset so a focused
|
||||||
|
// field (e.g. the custom-reminder amount) can scroll into view.
|
||||||
|
.imePadding()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(top = 8.dp, bottom = 24.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
||||||
|
* corner radii come from its [position] (so a run of rows reads as a single
|
||||||
|
* rounded card). Corners round further on press. A null [onClick] makes the
|
||||||
|
* row non-interactive (e.g. read-only entries).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun GroupedRow(
|
||||||
|
title: String,
|
||||||
|
position: Position,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
summary: String? = null,
|
||||||
|
selected: Boolean = false,
|
||||||
|
minHeight: Dp = 72.dp,
|
||||||
|
leading: @Composable (() -> Unit)? = null,
|
||||||
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val interaction = remember { MutableInteractionSource() }
|
||||||
|
val pressed by interaction.collectIsPressedAsState()
|
||||||
|
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
||||||
|
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
||||||
|
val shape = when (position) {
|
||||||
|
Position.Alone -> RoundedCornerShape(full)
|
||||||
|
Position.Top -> RoundedCornerShape(
|
||||||
|
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
||||||
|
)
|
||||||
|
Position.Middle -> RoundedCornerShape(small)
|
||||||
|
Position.Bottom -> RoundedCornerShape(
|
||||||
|
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val gap = when (position) {
|
||||||
|
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||||
|
Position.Bottom, Position.Alone -> Modifier
|
||||||
|
}
|
||||||
|
val itemColors = if (selected) {
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||||
|
}
|
||||||
|
val item: @Composable () -> Unit = {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(title) },
|
||||||
|
supportingContent = summary?.let { text -> { Text(text) } },
|
||||||
|
leadingContent = leading,
|
||||||
|
trailingContent = trailing,
|
||||||
|
colors = itemColors,
|
||||||
|
modifier = Modifier.heightIn(min = minHeight),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val base = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.then(gap)
|
||||||
|
val containerColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
if (onClick != null) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
color = containerColor,
|
||||||
|
shape = shape,
|
||||||
|
interactionSource = interaction,
|
||||||
|
modifier = base,
|
||||||
|
) { item() }
|
||||||
|
} else {
|
||||||
|
Surface(color = containerColor, shape = shape, modifier = base) { item() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's borderless text input: no underline, no outline, just the tonal
|
||||||
|
* card behind it. This is the standard input across the app — we deliberately
|
||||||
|
* don't use Material's outlined/filled text fields, so anything that takes text
|
||||||
|
* (the event form, the calendar manager, dialogs) uses this inside a tonal
|
||||||
|
* [androidx.compose.material3.Surface].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InlineTextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
placeholder: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||||
|
singleLine: Boolean = true,
|
||||||
|
minLines: Int = 1,
|
||||||
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||||
|
) {
|
||||||
|
val resolvedStyle = textStyle.copy(
|
||||||
|
color = if (textStyle.color.isSpecified) {
|
||||||
|
textStyle.color
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
)
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
textStyle = resolvedStyle,
|
||||||
|
singleLine = singleLine,
|
||||||
|
minLines = minLines,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = keyboardType,
|
||||||
|
capitalization = capitalization,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box {
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
// Clearly fainter than typed text, so a hint never reads as
|
||||||
|
// prefilled content.
|
||||||
|
Text(
|
||||||
|
text = placeholder,
|
||||||
|
style = resolvedStyle,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's standard pick in a selection dialog: a full-width tonal card,
|
||||||
|
* optionally with a leading icon and a supporting line; the selected option
|
||||||
|
* is highlighted. Stack with 8dp gaps inside an AlertDialog — this is the
|
||||||
|
* only sanctioned selection-modal style (no radio rows, no bare text lists).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OptionCard(
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
|
||||||
|
iconTint: Color = Color.Unspecified,
|
||||||
|
supportingText: String? = null,
|
||||||
|
selected: Boolean = false,
|
||||||
|
/** Label colour override, e.g. primary for an emphasised "Custom" entry. */
|
||||||
|
labelColor: Color = Color.Unspecified,
|
||||||
|
) {
|
||||||
|
val contentColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
color = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = when {
|
||||||
|
iconTint.isSpecified -> iconTint
|
||||||
|
selected -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = if (labelColor.isSpecified) labelColor else contentColor,
|
||||||
|
)
|
||||||
|
if (supportingText != null) {
|
||||||
|
Text(
|
||||||
|
text = supportingText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||||
|
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
|
||||||
|
* picker is visually identical to a Settings sub-page and uses the full width.
|
||||||
|
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FullScreenPicker(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
// The dialog window pans by default when the keyboard opens, which —
|
||||||
|
// combined with the content's own imePadding — leaves a fixed black gap
|
||||||
|
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
||||||
|
// full-screen and imePadding alone lifts the focused field.
|
||||||
|
val view = LocalView.current
|
||||||
|
SideEffect {
|
||||||
|
(view.parent as? DialogWindowProvider)?.window
|
||||||
|
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
|
}
|
||||||
|
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General single-select picker, full-screen: each option is a connected grouped
|
||||||
|
* row and the current one carries a check. Drop-in for the former dialog
|
||||||
|
* (theme, week start, language, …).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> OptionPicker(
|
||||||
|
title: String,
|
||||||
|
options: List<T>,
|
||||||
|
selected: T,
|
||||||
|
label: @Composable (T) -> String,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = label(option),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
||||||
|
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
||||||
|
* a "Custom" row that expands an inline number field plus a segmented unit
|
||||||
|
* selector. Returns the choice as a [CalendarReminderOverride].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderDefaultPicker(
|
||||||
|
title: String,
|
||||||
|
presets: List<Int>,
|
||||||
|
selected: CalendarReminderOverride,
|
||||||
|
allowInherit: Boolean,
|
||||||
|
onSelect: (CalendarReminderOverride) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
|
||||||
|
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
|
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
||||||
|
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
||||||
|
|
||||||
|
val options = buildList {
|
||||||
|
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
||||||
|
add(CalendarReminderOverride.None)
|
||||||
|
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
||||||
|
}
|
||||||
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = reminderOverrideLabel(option),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// When expanded, the Custom row connects downward into the editor card
|
||||||
|
// so the two read as one grouped container (the per-calendar pattern).
|
||||||
|
GroupedRow(
|
||||||
|
title = if (customSelected) {
|
||||||
|
stringResource(
|
||||||
|
R.string.reminder_custom_with_value,
|
||||||
|
reminderLeadTimeLabel(selectedMinutes!!),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.event_edit_reminder_custom)
|
||||||
|
},
|
||||||
|
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
||||||
|
selected = customSelected,
|
||||||
|
trailing = if (customSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { customExpanded = !customExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(visible = customExpanded) {
|
||||||
|
CustomReminderEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
unit = unit,
|
||||||
|
onUnitChange = { unit = it },
|
||||||
|
onConfirm = { minutes ->
|
||||||
|
onSelect(CalendarReminderOverride.Minutes(minutes))
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||||
|
* row above it (matching the grouped-row system, so the two read as one
|
||||||
|
* container). An amount field with a live preview of the resulting lead time, a
|
||||||
|
* single-choice unit toggle, and a tonal confirm enabled only for a valid
|
||||||
|
* 1–999 amount. [onConfirm] receives the final lead time in minutes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CustomReminderEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
unit: ReminderUnit,
|
||||||
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
|
onConfirm: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||||
|
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
// Unit toggle first so it stays visible above the keyboard once the
|
||||||
|
// amount field (the bottom row) is focused and scrolled into view.
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ReminderUnit.entries.forEachIndexed { index, entry ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = unit == entry,
|
||||||
|
onClick = { onUnitChange(entry) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
||||||
|
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Amount, a live preview of the lead time it resolves to, and Set —
|
||||||
|
// all on one row, sitting just above the keyboard.
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = onAmountChange,
|
||||||
|
placeholder = "10",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
|
enabled = amount != null,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_custom_set))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectedCheck() {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
|
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
||||||
|
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
||||||
|
minutes == null -> "" to ReminderUnit.Minutes
|
||||||
|
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
||||||
|
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
||||||
|
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
||||||
|
else -> minutes.toString() to ReminderUnit.Minutes
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.icu.text.ListFormatter
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
||||||
|
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
||||||
|
* Falls back to a generic label for rules we don't render in full (ordinal
|
||||||
|
* monthly/yearly BYDAY, etc.). Shared by the detail screen and the edit
|
||||||
|
* form's repeat card.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||||
|
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
||||||
|
val eq = token.indexOf('=')
|
||||||
|
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
val freq = parts["FREQ"]?.uppercase()
|
||||||
|
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
||||||
|
val base = when (freq) {
|
||||||
|
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
|
||||||
|
else stringResource(R.string.recurrence_every_n_days, interval)
|
||||||
|
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
|
||||||
|
else stringResource(R.string.recurrence_every_n_weeks, interval)
|
||||||
|
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
|
||||||
|
else stringResource(R.string.recurrence_every_n_months, interval)
|
||||||
|
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
|
||||||
|
else stringResource(R.string.recurrence_every_n_years, interval)
|
||||||
|
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
|
||||||
|
// The day names + their joined block are tracked so only the names (not the
|
||||||
|
// commas/conjunction) can be italicised in the final string.
|
||||||
|
val byDay = parts["BYDAY"]
|
||||||
|
var dayNames: List<String>? = null
|
||||||
|
var joinedDays: String? = null
|
||||||
|
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
|
||||||
|
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
|
||||||
|
if (days.isNotEmpty()) {
|
||||||
|
val joined = ListFormatter.getInstance(locale).format(days)
|
||||||
|
dayNames = days
|
||||||
|
joinedDays = joined
|
||||||
|
stringResource(R.string.recurrence_on_days, base, joined)
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
|
||||||
|
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
|
||||||
|
val count = parts["COUNT"]?.toIntOrNull()
|
||||||
|
val full = when {
|
||||||
|
until != null -> stringResource(R.string.recurrence_with_until, main, until)
|
||||||
|
count != null -> stringResource(R.string.recurrence_with_count, main, count)
|
||||||
|
else -> main
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildAnnotatedString {
|
||||||
|
append(full)
|
||||||
|
val names = dayNames
|
||||||
|
val joined = joinedDays
|
||||||
|
if (names != null && joined != null) {
|
||||||
|
// Italicise each day name within the joined block only — leaving the
|
||||||
|
// separators and conjunction ("und"/"and") in the regular style.
|
||||||
|
val regionStart = full.indexOf(joined)
|
||||||
|
if (regionStart >= 0) {
|
||||||
|
val regionEnd = regionStart + joined.length
|
||||||
|
var cursor = regionStart
|
||||||
|
for (name in names) {
|
||||||
|
val at = full.indexOf(name, cursor)
|
||||||
|
if (at in regionStart until regionEnd) {
|
||||||
|
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
|
||||||
|
cursor = at + name.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||||
|
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||||
|
val dow = when (token.takeLast(2).uppercase()) {
|
||||||
|
"MO" -> DayOfWeek.MONDAY
|
||||||
|
"TU" -> DayOfWeek.TUESDAY
|
||||||
|
"WE" -> DayOfWeek.WEDNESDAY
|
||||||
|
"TH" -> DayOfWeek.THURSDAY
|
||||||
|
"FR" -> DayOfWeek.FRIDAY
|
||||||
|
"SA" -> DayOfWeek.SATURDAY
|
||||||
|
"SU" -> DayOfWeek.SUNDAY
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||||
|
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||||
|
val digits = raw.takeWhile { it.isDigit() }
|
||||||
|
if (digits.length < 8) return null
|
||||||
|
return try {
|
||||||
|
val date = java.time.LocalDate.of(
|
||||||
|
digits.substring(0, 4).toInt(),
|
||||||
|
digits.substring(4, 6).toInt(),
|
||||||
|
digits.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||||
|
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
||||||
|
|
||||||
|
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
|
||||||
|
enum class ReminderUnit(val minutesFactor: Int) {
|
||||||
|
Minutes(1),
|
||||||
|
Hours(60),
|
||||||
|
Days(1_440),
|
||||||
|
Weeks(10_080),
|
||||||
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
|
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||||
|
ReminderUnit.Hours -> R.string.reminder_unit_hours
|
||||||
|
ReminderUnit.Days -> R.string.reminder_unit_days
|
||||||
|
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanise a reminder lead time (minutes before the event start) into one
|
||||||
|
* line: "Default reminder" (negative = the provider default), "At time of
|
||||||
|
* event" (0), "10 minutes before", "1 hour before", … Shared by the detail
|
||||||
|
* screen, the event form and the default-reminder settings so the wording
|
||||||
|
* never drifts.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun reminderLeadTimeLabel(minutes: Int): String = when {
|
||||||
|
minutes < 0 -> stringResource(R.string.reminder_default)
|
||||||
|
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
||||||
|
minutes % 10_080 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
||||||
|
minutes % 1_440 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
||||||
|
minutes % 60 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||||
|
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.text.format.DateFormat
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M3 time picker in an alert dialog, seeded with [initial]. Shared by the event
|
||||||
|
* form (start/end times) and Settings (the all-day reminder fire time).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TimePickerAlert(
|
||||||
|
initial: LocalTime,
|
||||||
|
onConfirm: (LocalTime) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = rememberTimePickerState(
|
||||||
|
initialHour = initial.hour,
|
||||||
|
initialMinute = initial.minute,
|
||||||
|
is24Hour = deviceUses24HourClock(LocalContext.current),
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onConfirm(LocalTime(state.hour, state.minute)) }) {
|
||||||
|
Text(stringResource(R.string.dialog_ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
text = { TimePicker(state = state) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the clock should read 24-hour, matching the rest of the device.
|
||||||
|
*
|
||||||
|
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
|
||||||
|
* the *app's* context locale — and this app applies a per-app language
|
||||||
|
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
|
||||||
|
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
|
||||||
|
* system 12/24 override, and otherwise fall back to the **device** locale
|
||||||
|
* (Resources.getSystem), not the app's.
|
||||||
|
*/
|
||||||
|
private fun deviceUses24HourClock(context: Context): Boolean =
|
||||||
|
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
|
||||||
|
"24" -> true
|
||||||
|
"12" -> false
|
||||||
|
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
|
||||||
|
else -> {
|
||||||
|
val deviceLocale = Resources.getSystem().configuration.locales[0]
|
||||||
|
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-bar pill that shows the current view and cycles to the next one on tap
|
* Top-bar pill that shows the current view and cycles to the next one on tap
|
||||||
@@ -18,16 +17,11 @@ fun ViewSwitcherPill(
|
|||||||
onCycle: () -> Unit,
|
onCycle: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val labelRes = when (current) {
|
|
||||||
CalendarView.Month -> R.string.view_month
|
|
||||||
CalendarView.Week -> R.string.view_week
|
|
||||||
CalendarView.Day -> R.string.view_day
|
|
||||||
}
|
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = onCycle,
|
onClick = onCycle,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Text(stringResource(labelRes))
|
Text(stringResource(current.labelRes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
@@ -28,12 +26,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -72,6 +68,7 @@ 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.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
@@ -108,6 +105,7 @@ fun DayScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -144,7 +142,20 @@ fun DayScreen(
|
|||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
||||||
val jumpToToday = { slideDir = 0; viewModel.goToToday() }
|
// Slide toward today: viewing the future → today comes in from the left
|
||||||
|
// (back), viewing the past → from the right (forward).
|
||||||
|
val jumpToToday = {
|
||||||
|
slideDir = when (val s = state) {
|
||||||
|
is DayUiState.Success -> if (s.today < s.date) -1 else 1
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
viewModel.goToToday()
|
||||||
|
}
|
||||||
|
// Drawer jump-to-date: slide from the side the target lies on.
|
||||||
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
|
slideDir = if (target < date) -1 else 1
|
||||||
|
viewModel.goToDate(target)
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -152,7 +163,16 @@ fun DayScreen(
|
|||||||
gesturesEnabled = drawerState.isOpen,
|
gesturesEnabled = drawerState.isOpen,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
currentView = selectedView,
|
||||||
|
currentDate = date,
|
||||||
|
onSelectView = { view ->
|
||||||
|
onSelectView(view)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
jumpToDate(target)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
onSettings = {
|
onSettings = {
|
||||||
onOpenSettings()
|
onOpenSettings()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -172,17 +192,12 @@ fun DayScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
CalendarFabColumn(
|
||||||
visible = !isOnToday,
|
todayVisible = !isOnToday,
|
||||||
enter = scaleIn(),
|
todayText = stringResource(R.string.day_today_action),
|
||||||
exit = scaleOut(),
|
onToday = jumpToToday,
|
||||||
) {
|
onCreate = { onCreateEvent(date, null) },
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
onClick = jumpToToday,
|
|
||||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
|
||||||
text = { Text(stringResource(R.string.day_today_action)) },
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
DayContent(
|
DayContent(
|
||||||
@@ -193,6 +208,7 @@ fun DayScreen(
|
|||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
@@ -210,6 +226,7 @@ private fun DayContent(
|
|||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -278,6 +295,7 @@ private fun DayContent(
|
|||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +308,7 @@ private fun DaySuccess(
|
|||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// All-day strip collapses to nothing when the day has no all-day events,
|
// All-day strip collapses to nothing when the day has no all-day events,
|
||||||
@@ -305,7 +324,12 @@ private fun DaySuccess(
|
|||||||
// Breathing room between the (colour-shifting) top section and the
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
// scrolling timeline below.
|
// scrolling timeline below.
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
|
Timeline(
|
||||||
|
state = state,
|
||||||
|
scrollState = scrollState,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +447,7 @@ private fun Timeline(
|
|||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -470,7 +495,9 @@ private fun Timeline(
|
|||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
blocks = state.timed,
|
blocks = state.timed,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
date = state.date,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(totalHeight),
|
.height(totalHeight),
|
||||||
@@ -484,9 +511,12 @@ private fun Timeline(
|
|||||||
private fun DayColumnCard(
|
private fun DayColumnCard(
|
||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
date: LocalDate,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular column — the soft corners come from the outer
|
// Plain rectangular column — 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.
|
||||||
@@ -496,7 +526,19 @@ private fun DayColumnCard(
|
|||||||
),
|
),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// Tap an empty slot to create an event there. Taps on event
|
||||||
|
// blocks are consumed by their own click handler first, so this
|
||||||
|
// only fires on the column background. Snaps to the tapped hour.
|
||||||
|
.pointerInput(date) {
|
||||||
|
detectTapGestures { offset ->
|
||||||
|
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
||||||
|
onCreateAt(date, hour * 60)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
val colWidth = maxWidth
|
val colWidth = maxWidth
|
||||||
blocks.forEach { block ->
|
blocks.forEach { block ->
|
||||||
val laneWidth = colWidth / block.laneCount
|
val laneWidth = colWidth / block.laneCount
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.detail
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.icu.text.ListFormatter
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -13,6 +16,8 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -30,46 +35,71 @@ import androidx.compose.material.icons.automirrored.filled.Notes
|
|||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.People
|
import androidx.compose.material.icons.filled.People
|
||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
|
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.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
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
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
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.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.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
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 androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.time.DayOfWeek
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
@@ -79,10 +109,12 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only full-screen event detail (spec S4, realised as a navigation
|
* Full-screen event detail (spec S4, realised as a navigation destination
|
||||||
* destination rather than a bottom sheet — MD3 list→detail pattern). Back
|
* rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the
|
||||||
* gesture and the top-bar arrow both return to the calendar. The only action is
|
* top-bar arrow both return to the calendar. Events in writable calendars can
|
||||||
* tapping the location to open a maps intent.
|
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
||||||
|
* event form for this occurrence — for recurring events the form asks how
|
||||||
|
* far the change reaches when saving.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -91,16 +123,98 @@ fun EventDetailScreen(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
endMillis: Long,
|
endMillis: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onEdit: () -> Unit,
|
||||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||||
viewModel.open(eventId, beginMillis, endMillis)
|
viewModel.open(eventId, beginMillis, endMillis)
|
||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
||||||
|
// an .ics in the cache and hands back a content Uri for the chooser.
|
||||||
|
val shareFailedMessage = stringResource(R.string.event_share_failed)
|
||||||
|
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||||
|
val onShareClick = {
|
||||||
|
scope.launch {
|
||||||
|
val uri = viewModel.shareUri()
|
||||||
|
val sent = uri != null && runCatching {
|
||||||
|
val send = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/calendar"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(send, shareChooserTitle))
|
||||||
|
}.isSuccess
|
||||||
|
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
|
// upgrade in place. Granting continues straight into the tapped action.
|
||||||
|
var pendingEdit by remember { mutableStateOf(false) }
|
||||||
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
if (pendingEdit) onEdit() else showDeleteDialog = true
|
||||||
|
}
|
||||||
|
pendingEdit = false
|
||||||
|
}
|
||||||
|
val hasWritePermission = {
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
val onDeleteClick = {
|
||||||
|
if (hasWritePermission()) {
|
||||||
|
showDeleteDialog = true
|
||||||
|
} else {
|
||||||
|
pendingEdit = false
|
||||||
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val onEditClick = {
|
||||||
|
if (hasWritePermission()) {
|
||||||
|
onEdit()
|
||||||
|
} else {
|
||||||
|
pendingEdit = true
|
||||||
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||||
|
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
|
||||||
|
LaunchedEffect(deleteState) {
|
||||||
|
when (deleteState) {
|
||||||
|
DeleteUiState.Deleted -> {
|
||||||
|
viewModel.consumeDeleteResult()
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
DeleteUiState.Failed -> {
|
||||||
|
viewModel.consumeDeleteResult()
|
||||||
|
snackbarHostState.showSnackbar(deleteFailedMessage)
|
||||||
|
}
|
||||||
|
DeleteUiState.NeedsPermission -> {
|
||||||
|
viewModel.consumeDeleteResult()
|
||||||
|
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||||
|
}
|
||||||
|
DeleteUiState.Idle, DeleteUiState.Deleting -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {},
|
title = {},
|
||||||
@@ -113,18 +227,38 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { /* TODO: edit event (V2) */ }) {
|
val s = state
|
||||||
|
// Share works for any loaded event — it only reads the event.
|
||||||
|
if (s is EventDetailUiState.Success) {
|
||||||
|
IconButton(onClick = onShareClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Share,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_share),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||||
|
// birthday calendars etc. are read-only at the provider level.
|
||||||
|
if (s is EventDetailUiState.Success && s.canModify) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onEditClick,
|
||||||
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Edit,
|
imageVector = Icons.Default.Edit,
|
||||||
contentDescription = stringResource(R.string.event_detail_edit),
|
contentDescription = stringResource(R.string.event_detail_edit),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { /* TODO: delete event (V2) */ }) {
|
IconButton(
|
||||||
|
onClick = onDeleteClick,
|
||||||
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Delete,
|
imageVector = Icons.Default.Delete,
|
||||||
contentDescription = stringResource(R.string.event_detail_delete),
|
contentDescription = stringResource(R.string.event_detail_delete),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
@@ -144,7 +278,83 @@ fun EventDetailScreen(
|
|||||||
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val loaded = state
|
||||||
|
if (showDeleteDialog && loaded is EventDetailUiState.Success) {
|
||||||
|
DeleteEventDialog(
|
||||||
|
isRecurring = !loaded.detail.rrule.isNullOrBlank(),
|
||||||
|
onConfirm = { scope ->
|
||||||
|
showDeleteDialog = false
|
||||||
|
viewModel.delete(scope)
|
||||||
|
},
|
||||||
|
onDismiss = { showDeleteDialog = false },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
|
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
|
||||||
@@ -159,12 +369,30 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||||
) {
|
) {
|
||||||
// Title with a short accent line in the calendar colour underneath.
|
// Title row: title on the left, a "Free" pill pinned top-right when the
|
||||||
|
// event doesn't block your time. Busy is the default for nearly every
|
||||||
|
// event, so it's left implicit — only Free is worth surfacing. A
|
||||||
|
// cancelled event strikes through its title.
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
Text(
|
Text(
|
||||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||||
|
TextDecoration.LineThrough
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
if (detail.availability == Availability.Free) {
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
InfoChip(
|
||||||
|
text = stringResource(R.string.event_availability_free),
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -173,6 +401,16 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
.background(accent, RoundedCornerShape(2.dp)),
|
.background(accent, RoundedCornerShape(2.dp)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Status / access chips — shown only when noteworthy (Confirmed status
|
||||||
|
// and Default/Public access are the silent norm).
|
||||||
|
val hasStatusChips = detail.status != EventStatus.Confirmed ||
|
||||||
|
detail.accessLevel == AccessLevel.Private ||
|
||||||
|
detail.accessLevel == AccessLevel.Confidential
|
||||||
|
if (hasStatusChips) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
StatusChips(detail.status, detail.accessLevel)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
// Every piece of info shares one card design: a tonal container with a
|
// Every piece of info shares one card design: a tonal container with a
|
||||||
@@ -194,6 +432,18 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Time zone — only when the event is timed and pinned to a zone other
|
||||||
|
// than the device's, so cross-zone events read unambiguously.
|
||||||
|
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.Public,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||||
|
) {
|
||||||
|
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
||||||
// separate colour dot is needed.
|
// separate colour dot is needed.
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
@@ -228,28 +478,63 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description (conditional).
|
// Description (conditional). URLs are auto-linked.
|
||||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.AutoMirrored.Filled.Notes,
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_description),
|
iconContentDescription = stringResource(R.string.event_detail_description),
|
||||||
) {
|
) {
|
||||||
Text(text = description, style = MaterialTheme.typography.bodyMedium)
|
Text(
|
||||||
|
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attendees (conditional).
|
// Attendees (conditional). The user's own response leads the list, then
|
||||||
|
// each attendee with their role and reply.
|
||||||
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.People,
|
icon = Icons.Default.People,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
||||||
) {
|
) {
|
||||||
|
if (detail.selfStatus != AttendeeStatus.Unknown) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.event_detail_self_response,
|
||||||
|
stringResource(attendeeStatusLabel(detail.selfStatus)),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
attendees.forEach { AttendeeRow(it) }
|
attendees.forEach { AttendeeRow(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reminders (conditional) — list each lead time before the event.
|
||||||
|
detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.Notifications,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_reminders),
|
||||||
|
) {
|
||||||
|
reminders
|
||||||
|
.distinctBy { it.minutes }
|
||||||
|
.sortedBy { it.minutes }
|
||||||
|
.forEach { reminder ->
|
||||||
|
Text(
|
||||||
|
text = reminderLeadText(reminder),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Recurrence (conditional) — humanised from the RRULE.
|
// Recurrence (conditional) — humanised from the RRULE.
|
||||||
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
@@ -304,10 +589,20 @@ private fun AttendeeRow(attendee: Attendee) {
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
|
attendeeRoleLabel(attendee)?.let { roleRes ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(roleRes),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(attendeeStatusLabel(attendee.status)),
|
text = stringResource(attendeeStatusLabel(attendee.status)),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -316,6 +611,54 @@ private fun AttendeeRow(attendee: Attendee) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Status / access pills shown directly under the title accent. */
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun StatusChips(status: EventStatus, accessLevel: AccessLevel) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
when (status) {
|
||||||
|
EventStatus.Cancelled -> InfoChip(
|
||||||
|
text = stringResource(R.string.event_status_cancelled),
|
||||||
|
container = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
content = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
EventStatus.Tentative -> InfoChip(
|
||||||
|
text = stringResource(R.string.event_status_tentative),
|
||||||
|
container = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
content = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
)
|
||||||
|
EventStatus.Confirmed -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
when (accessLevel) {
|
||||||
|
AccessLevel.Private -> InfoChip(text = stringResource(R.string.event_access_private))
|
||||||
|
AccessLevel.Confidential ->
|
||||||
|
InfoChip(text = stringResource(R.string.event_access_confidential))
|
||||||
|
AccessLevel.Default, AccessLevel.Public -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoChip(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
container: Color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
content: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
) {
|
||||||
|
Surface(color = container, shape = RoundedCornerShape(8.dp), modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = content,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
||||||
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
||||||
@@ -362,112 +705,54 @@ private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
* The role badge shown under an attendee's name. Organizer wins over type;
|
||||||
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
* required attendees (the common case) get no badge to keep the list quiet.
|
||||||
* Falls back to a generic label for rules we don't render in full (ordinal
|
|
||||||
* monthly/yearly BYDAY, etc.).
|
|
||||||
*/
|
*/
|
||||||
|
private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
||||||
|
attendee.relationship == AttendeeRelationship.Organizer -> R.string.event_attendee_organizer
|
||||||
|
attendee.type == AttendeeType.Optional -> R.string.event_attendee_optional
|
||||||
|
attendee.type == AttendeeType.Resource -> R.string.event_attendee_resource
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
|
||||||
@Composable
|
@Composable
|
||||||
private fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||||
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
|
||||||
val eq = token.indexOf('=')
|
|
||||||
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
|
||||||
}.toMap()
|
|
||||||
|
|
||||||
val freq = parts["FREQ"]?.uppercase()
|
/**
|
||||||
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
||||||
val base = when (freq) {
|
* but only when the event is timed and pinned to a zone different from the
|
||||||
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
|
* device's. Returns null when there's nothing worth showing.
|
||||||
else stringResource(R.string.recurrence_every_n_days, interval)
|
*/
|
||||||
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
|
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? {
|
||||||
else stringResource(R.string.recurrence_every_n_weeks, interval)
|
if (isAllDay || tz.isNullOrBlank()) return null
|
||||||
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
|
val deviceZone = ZoneId.systemDefault().id
|
||||||
else stringResource(R.string.recurrence_every_n_months, interval)
|
if (tz == deviceZone) return null
|
||||||
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
|
|
||||||
else stringResource(R.string.recurrence_every_n_years, interval)
|
|
||||||
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
|
|
||||||
// The day names + their joined block are tracked so only the names (not the
|
|
||||||
// commas/conjunction) can be italicised in the final string.
|
|
||||||
val byDay = parts["BYDAY"]
|
|
||||||
var dayNames: List<String>? = null
|
|
||||||
var joinedDays: String? = null
|
|
||||||
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
|
|
||||||
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
|
|
||||||
if (days.isNotEmpty()) {
|
|
||||||
val joined = ListFormatter.getInstance(locale).format(days)
|
|
||||||
dayNames = days
|
|
||||||
joinedDays = joined
|
|
||||||
stringResource(R.string.recurrence_on_days, base, joined)
|
|
||||||
} else {
|
|
||||||
base
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
base
|
|
||||||
}
|
|
||||||
|
|
||||||
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
|
|
||||||
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
|
|
||||||
val count = parts["COUNT"]?.toIntOrNull()
|
|
||||||
val full = when {
|
|
||||||
until != null -> stringResource(R.string.recurrence_with_until, main, until)
|
|
||||||
count != null -> stringResource(R.string.recurrence_with_count, main, count)
|
|
||||||
else -> main
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildAnnotatedString {
|
|
||||||
append(full)
|
|
||||||
val names = dayNames
|
|
||||||
val joined = joinedDays
|
|
||||||
if (names != null && joined != null) {
|
|
||||||
// Italicise each day name within the joined block only — leaving the
|
|
||||||
// separators and conjunction ("und"/"and") in the regular style.
|
|
||||||
val regionStart = full.indexOf(joined)
|
|
||||||
if (regionStart >= 0) {
|
|
||||||
val regionEnd = regionStart + joined.length
|
|
||||||
var cursor = regionStart
|
|
||||||
for (name in names) {
|
|
||||||
val at = full.indexOf(name, cursor)
|
|
||||||
if (at in regionStart until regionEnd) {
|
|
||||||
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
|
|
||||||
cursor = at + name.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
|
||||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
|
||||||
val dow = when (token.takeLast(2).uppercase()) {
|
|
||||||
"MO" -> DayOfWeek.MONDAY
|
|
||||||
"TU" -> DayOfWeek.TUESDAY
|
|
||||||
"WE" -> DayOfWeek.WEDNESDAY
|
|
||||||
"TH" -> DayOfWeek.THURSDAY
|
|
||||||
"FR" -> DayOfWeek.FRIDAY
|
|
||||||
"SA" -> DayOfWeek.SATURDAY
|
|
||||||
"SU" -> DayOfWeek.SUNDAY
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
|
||||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
|
||||||
val digits = raw.takeWhile { it.isDigit() }
|
|
||||||
if (digits.length < 8) return null
|
|
||||||
return try {
|
return try {
|
||||||
val date = java.time.LocalDate.of(
|
val zone = ZoneId.of(tz)
|
||||||
digits.substring(0, 4).toInt(),
|
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
|
||||||
digits.substring(4, 6).toInt(),
|
if (name == tz) tz else "$name ($tz)"
|
||||||
digits.substring(6, 8).toInt(),
|
|
||||||
)
|
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
tz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
||||||
|
@Composable
|
||||||
|
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remember(text, linkColor) {
|
||||||
|
val regex = Regex("""https?://\S+""")
|
||||||
|
val styles = TextLinkStyles(
|
||||||
|
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
|
||||||
|
)
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
for (match in regex.findAll(text)) {
|
||||||
|
// Trim trailing punctuation that commonly abuts a URL in prose.
|
||||||
|
val raw = match.value
|
||||||
|
val url = raw.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '"', '\'')
|
||||||
|
val end = match.range.first + url.length
|
||||||
|
addLink(LinkAnnotation.Url(url, styles), match.range.first, end)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,14 +776,19 @@ private fun formatWhen(
|
|||||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||||
|
|
||||||
if (instance.isAllDay) {
|
if (instance.isAllDay) {
|
||||||
// All-day end is the exclusive next midnight; step back to the last
|
// All-day events live at UTC midnights with an exclusive end. Resolve
|
||||||
// covered day so a one-day event reads as a single date.
|
// the covered dates in UTC — not the device zone, which would shift the
|
||||||
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
|
// midnight boundaries off the intended date (east of UTC pushes the
|
||||||
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
|
// end past the last day; west of UTC pulls the start back) — and step
|
||||||
allDayLabel to dateFull.format(startLdt.toLocalDate())
|
// the end back to the last covered day so a one-day event reads as a
|
||||||
|
// single date.
|
||||||
|
val utc = ZoneId.of("UTC")
|
||||||
|
val startDate = instance.start.toJavaLocalDateTime(utc).toLocalDate()
|
||||||
|
val lastDate = (instance.end - 1.seconds).toJavaLocalDateTime(utc).toLocalDate()
|
||||||
|
return if (startDate == lastDate) {
|
||||||
|
allDayLabel to dateFull.format(startDate)
|
||||||
} else {
|
} else {
|
||||||
allDayLabel to
|
allDayLabel to "${dateMedium.format(startDate)} – ${dateMedium.format(lastDate)}"
|
||||||
"${dateMedium.format(startLdt.toLocalDate())} – ${dateMedium.format(lastLdt.toLocalDate())}"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
|||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI state for the event-detail bottom sheet (spec S4). Read-only in V1.
|
* UI state for the event-detail screen (spec S4).
|
||||||
*/
|
*/
|
||||||
sealed interface EventDetailUiState {
|
sealed interface EventDetailUiState {
|
||||||
data object Loading : EventDetailUiState
|
data object Loading : EventDetailUiState
|
||||||
@@ -13,5 +13,20 @@ sealed interface EventDetailUiState {
|
|||||||
val detail: EventDetail,
|
val detail: EventDetail,
|
||||||
/** Display name of the owning calendar, null if it can't be resolved. */
|
/** Display name of the owning calendar, null if it can't be resolved. */
|
||||||
val calendarName: String?,
|
val calendarName: String?,
|
||||||
|
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
||||||
|
val canModify: Boolean = false,
|
||||||
) : EventDetailUiState
|
) : EventDetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot state of a delete request, separate from the screen state so a
|
||||||
|
* failed delete leaves the loaded detail visible.
|
||||||
|
*/
|
||||||
|
sealed interface DeleteUiState {
|
||||||
|
data object Idle : DeleteUiState
|
||||||
|
data object Deleting : DeleteUiState
|
||||||
|
data object Deleted : DeleteUiState
|
||||||
|
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||||
|
data object NeedsPermission : DeleteUiState
|
||||||
|
data object Failed : DeleteUiState
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.detail
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
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.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.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
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
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -19,6 +27,7 @@ import kotlinx.coroutines.flow.flow
|
|||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -31,6 +40,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class EventDetailViewModel @Inject constructor(
|
class EventDetailViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
|
private val icsExporter: IcsExporter,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -38,6 +48,9 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
// Bumped by retry() to re-run the load for the same target.
|
// Bumped by retry() to re-run the load for the same target.
|
||||||
private val _reload = MutableStateFlow(0)
|
private val _reload = MutableStateFlow(0)
|
||||||
|
|
||||||
|
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
|
||||||
|
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
|
||||||
|
|
||||||
val state: StateFlow<EventDetailUiState> =
|
val state: StateFlow<EventDetailUiState> =
|
||||||
combine(_target, _reload) { target, _ -> target }
|
combine(_target, _reload) { target, _ -> target }
|
||||||
.flatMapLatest { target ->
|
.flatMapLatest { target ->
|
||||||
@@ -72,6 +85,59 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
_reload.value += 1
|
_reload.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the open event. [scope] is meaningful only for recurring events
|
||||||
|
* (one-off events always pass [RecurringWriteScope.AllEvents]). Result
|
||||||
|
* lands in [deleteState]; the screen consumes it via [consumeDeleteResult].
|
||||||
|
*/
|
||||||
|
fun delete(scope: RecurringWriteScope) {
|
||||||
|
val target = _target.value ?: return
|
||||||
|
if (_deleteState.value == DeleteUiState.Deleting) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_deleteState.value = DeleteUiState.Deleting
|
||||||
|
_deleteState.value = try {
|
||||||
|
when (scope) {
|
||||||
|
RecurringWriteScope.AllEvents ->
|
||||||
|
repository.deleteEvent(target.eventId)
|
||||||
|
RecurringWriteScope.ThisEvent ->
|
||||||
|
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
||||||
|
RecurringWriteScope.ThisAndFollowing ->
|
||||||
|
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
|
||||||
|
}
|
||||||
|
DeleteUiState.Deleted
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
DeleteUiState.NeedsPermission
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DeleteUiState.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset [deleteState] after the screen handled a terminal result. */
|
||||||
|
fun consumeDeleteResult() {
|
||||||
|
_deleteState.value = DeleteUiState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise the open event to a `.ics` cache file and return a shareable
|
||||||
|
* content Uri (for an ACTION_SEND), or null when nothing is loaded or the
|
||||||
|
* write fails. The event is exported as a one-off (see [toShareIcsEvent]).
|
||||||
|
*/
|
||||||
|
suspend fun shareUri(): Uri? {
|
||||||
|
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
|
||||||
|
return runCatching {
|
||||||
|
withContext(io) {
|
||||||
|
val ics = IcsWriter().writeCalendar(
|
||||||
|
events = listOf(detail.toShareIcsEvent()),
|
||||||
|
dtStamp = Clock.System.now(),
|
||||||
|
)
|
||||||
|
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
val detail = repository.eventDetail(target.eventId)
|
val detail = repository.eventDetail(target.eventId)
|
||||||
// The Events row holds the series start; replace it with this
|
// The Events row holds the series start; replace it with this
|
||||||
@@ -82,10 +148,13 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val calendarName = repository.calendars().first()
|
val calendar = repository.calendars().first()
|
||||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||||
?.displayName
|
EventDetailUiState.Success(
|
||||||
EventDetailUiState.Success(corrected, calendarName)
|
detail = corrected,
|
||||||
|
calendarName = calendar?.displayName,
|
||||||
|
canModify = calendar?.canModifyContents == true,
|
||||||
|
)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: NoSuchEventException) {
|
} catch (e: NoSuchEventException) {
|
||||||
@@ -99,3 +168,13 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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"
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI state for the event form (v1.2: create; v1.3 reuses it for edit). Null
|
||||||
|
* form means the screen hasn't been opened yet.
|
||||||
|
*/
|
||||||
|
data class EventEditUiState(
|
||||||
|
/** The form with its calendar id resolved (picked > last used > first writable). */
|
||||||
|
val form: EventForm,
|
||||||
|
/** Calendars that accept writes — the only valid targets. */
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
/** Validation problems; empty until a save was attempted. */
|
||||||
|
val problems: Set<EventFormProblem>,
|
||||||
|
val saveState: SaveUiState,
|
||||||
|
/** Optional sections currently rendered (settings defaults ∪ revealed). */
|
||||||
|
val visibleFields: Set<EventFormField> = emptySet(),
|
||||||
|
/**
|
||||||
|
* Optional sections behind "more fields". Sections the current mode can't
|
||||||
|
* offer at all (recurrence while editing a single occurrence) appear in
|
||||||
|
* neither list.
|
||||||
|
*/
|
||||||
|
val hiddenFields: List<EventFormField> = emptyList(),
|
||||||
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
|
val isEditing: Boolean = false,
|
||||||
|
/**
|
||||||
|
* True while an edit changed the recurrence rule — the save-scope dialog
|
||||||
|
* then drops "only this event" (an exception row can't carry a rule).
|
||||||
|
*/
|
||||||
|
val recurrenceChanged: Boolean = false,
|
||||||
|
/**
|
||||||
|
* The event-colour palette the resolved target calendar publishes; empty
|
||||||
|
* when it exposes none. Non-empty → the colour picker offers these swatches
|
||||||
|
* (written as a key, sync-safe); empty → see [colorMode].
|
||||||
|
*/
|
||||||
|
val colorPalette: List<EventColorOption> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Whether the user has opted into custom colours on calendars that publish
|
||||||
|
* no palette (a synced one may then drop the colour on sync). Mirrors the
|
||||||
|
* settings flag; ignored for local and palette-backed calendars.
|
||||||
|
*/
|
||||||
|
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
|
||||||
|
sealed interface SaveUiState {
|
||||||
|
data object Idle : SaveUiState
|
||||||
|
/** A dirty recurring event waits for the user to pick the write scope. */
|
||||||
|
data object AwaitingScope : SaveUiState
|
||||||
|
/**
|
||||||
|
* The event changed externally (sync) while the form was open; the save
|
||||||
|
* is parked with its chosen [scope] until the user picks overwrite,
|
||||||
|
* discard, or cancel.
|
||||||
|
*/
|
||||||
|
data class AwaitingConflict(val scope: RecurringWriteScope) : SaveUiState
|
||||||
|
/** The event was deleted externally while the form was open. */
|
||||||
|
data object Gone : SaveUiState
|
||||||
|
data object Saving : SaveUiState
|
||||||
|
data object Saved : SaveUiState
|
||||||
|
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||||
|
data object NeedsPermission : SaveUiState
|
||||||
|
data object Failed : SaveUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
|
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.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the event form being composed. The form's calendar id resolves to
|
||||||
|
* (user pick > last used > first writable); the resolved value is what the UI
|
||||||
|
* shows and what gets saved.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class EventEditViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _form = MutableStateFlow<EventForm?>(null)
|
||||||
|
private val _saveState = MutableStateFlow<SaveUiState>(SaveUiState.Idle)
|
||||||
|
// Problems stay hidden until the first save attempt, so a half-filled
|
||||||
|
// form isn't already shouting errors.
|
||||||
|
private val _showProblems = MutableStateFlow(false)
|
||||||
|
// Fields added through the "more fields" picker; folds back on reset().
|
||||||
|
// openForEdit seeds it with the sections that already hold values.
|
||||||
|
private val _revealed = MutableStateFlow<Set<EventFormField>>(emptySet())
|
||||||
|
// Set while the form edits an existing event instead of composing a new one.
|
||||||
|
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
||||||
|
private val _loadFailed = MutableStateFlow(false)
|
||||||
|
// True once the user has hand-edited the reminders on a new event, which
|
||||||
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
|
// their choice. Reset with the form.
|
||||||
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event being edited plus everything the form saw at load time.
|
||||||
|
* For recurring events the write scope is chosen at save time; the
|
||||||
|
* tapped occurrence's [beginMillis]/[endMillis] anchor occurrence-level
|
||||||
|
* writes and the conflict re-read. [zone] is pinned at load so a device
|
||||||
|
* timezone change mid-edit can't fake a conflict.
|
||||||
|
*/
|
||||||
|
private data class EditTarget(
|
||||||
|
val eventId: Long,
|
||||||
|
val snapshot: EditSnapshot,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
val zone: TimeZone,
|
||||||
|
) {
|
||||||
|
val original: EventForm get() = snapshot.form
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class LocalInputs(
|
||||||
|
val form: EventForm?,
|
||||||
|
val saveState: SaveUiState,
|
||||||
|
val showProblems: Boolean,
|
||||||
|
val revealed: Set<EventFormField>,
|
||||||
|
val editTarget: EditTarget?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReminderDefaults(
|
||||||
|
val timed: Int?,
|
||||||
|
val allDay: Int?,
|
||||||
|
val timedOverrides: Map<Long, Int?>,
|
||||||
|
val allDayOverrides: Map<Long, Int?>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ExternalInputs(
|
||||||
|
val writable: List<CalendarSource>,
|
||||||
|
val lastUsed: Long?,
|
||||||
|
val defaultFields: Set<EventFormField>,
|
||||||
|
val allowColorOnUnsupported: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Writable calendars — the only valid event targets. */
|
||||||
|
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||||
|
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||||
|
.catch { emit(emptyList()) }
|
||||||
|
|
||||||
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
|
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||||
|
_form.map { it?.calendarId },
|
||||||
|
writableCalendars,
|
||||||
|
prefs.lastUsedCalendarId,
|
||||||
|
) { picked, writable, lastUsed ->
|
||||||
|
picked
|
||||||
|
?: lastUsed?.takeIf { id -> writable.any { it.id == id } }
|
||||||
|
?: writable.firstOrNull()?.id
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
|
/** The resolved calendar's published event palette, refetched when it changes. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private val colorPalette: Flow<List<EventColorOption>> = resolvedCalendarId
|
||||||
|
.flatMapLatest { id ->
|
||||||
|
flow { emit(id?.let { repository.eventColorPalette(it) }.orEmpty()) }
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
|
||||||
|
val state: StateFlow<EventEditUiState?> = combine(
|
||||||
|
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
|
||||||
|
combine(
|
||||||
|
writableCalendars,
|
||||||
|
prefs.lastUsedCalendarId,
|
||||||
|
settingsPrefs.defaultFormFields,
|
||||||
|
settingsPrefs.allowColorOnUnsupportedCalendars,
|
||||||
|
::ExternalInputs,
|
||||||
|
).flowOn(io),
|
||||||
|
colorPalette,
|
||||||
|
) { local, external, palette ->
|
||||||
|
val form = local.form ?: return@combine null
|
||||||
|
val resolvedId = form.calendarId
|
||||||
|
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||||
|
?: external.writable.firstOrNull()?.id
|
||||||
|
val resolved = form.copy(calendarId = resolvedId)
|
||||||
|
val visibleFields = external.defaultFields + local.revealed
|
||||||
|
EventEditUiState(
|
||||||
|
form = resolved,
|
||||||
|
calendars = external.writable,
|
||||||
|
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||||
|
saveState = local.saveState,
|
||||||
|
visibleFields = visibleFields,
|
||||||
|
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||||
|
isEditing = local.editTarget != null,
|
||||||
|
// A modified-occurrence exception can't carry its own rule, so
|
||||||
|
// the scope dialog drops "only this event" after a rule change.
|
||||||
|
recurrenceChanged = local.editTarget != null &&
|
||||||
|
resolved.rrule != local.editTarget.original.rrule,
|
||||||
|
colorPalette = palette,
|
||||||
|
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
||||||
|
* from midnight) anchors the start when the form is opened by tapping a slot
|
||||||
|
* in the day/week grid; without it the default is the next full hour (today)
|
||||||
|
* or 09:00 (any other day). No-op when a form is already open, so user input
|
||||||
|
* survives configuration changes; [reset] clears it when the screen closes.
|
||||||
|
*/
|
||||||
|
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
||||||
|
if (_form.value != null) return
|
||||||
|
val zone = TimeZone.currentSystemDefault()
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val start = when {
|
||||||
|
startMinutes != null ->
|
||||||
|
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60))
|
||||||
|
date == now.toLocalDateTime(zone).date -> {
|
||||||
|
// Today: the next full hour (may roll into tomorrow before midnight).
|
||||||
|
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) + 1.hours).toLocalDateTime(zone)
|
||||||
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
|
applyDefaultReminder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
||||||
|
* the create form" path). [form] already carries the file's fields; its
|
||||||
|
* [EventForm.calendarId] is null so the calendar still resolves to the
|
||||||
|
* last-used/first-writable one, and reminders are frozen as touched so the
|
||||||
|
* settings default never overwrites what the file specified. No-op when a
|
||||||
|
* form is already open, so the prefill survives configuration changes.
|
||||||
|
*/
|
||||||
|
fun openImported(form: EventForm) {
|
||||||
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
|
_remindersTouched.value = true
|
||||||
|
_revealed.value = form.populatedFields()
|
||||||
|
_form.value = form
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill a new event's reminders from the settings default — the all-day
|
||||||
|
* default for all-day events, otherwise the resolved calendar's per-calendar
|
||||||
|
* override or the global timed default. No-op while editing an existing event
|
||||||
|
* or once the user has hand-edited the reminders, so the auto-default never
|
||||||
|
* clobbers a manual choice. [calendarId] short-circuits the resolution after a
|
||||||
|
* calendar switch; null resolves it as the form does.
|
||||||
|
*/
|
||||||
|
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||||
|
if (_editTarget.value != null || _remindersTouched.value) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val defaults = combine(
|
||||||
|
settingsPrefs.defaultReminderMinutes,
|
||||||
|
settingsPrefs.defaultAllDayReminderMinutes,
|
||||||
|
settingsPrefs.perCalendarReminderOverride,
|
||||||
|
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { timed, allDay, timedOv, allDayOv ->
|
||||||
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
|
}.first()
|
||||||
|
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||||
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
|
val form = _form.value ?: return@launch
|
||||||
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
|
val default = resolveDefaultReminder(
|
||||||
|
timedGlobal = defaults.timed,
|
||||||
|
allDayGlobal = defaults.allDay,
|
||||||
|
timedOverrides = defaults.timedOverrides,
|
||||||
|
allDayOverrides = defaults.allDayOverrides,
|
||||||
|
calendarId = targetId,
|
||||||
|
isAllDay = form.isAllDay,
|
||||||
|
)
|
||||||
|
val reminders = listOfNotNull(default)
|
||||||
|
_form.value = form.copy(reminders = reminders)
|
||||||
|
// Surface the section so an auto-applied default is visible and
|
||||||
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
|
if (reminders.isNotEmpty()) {
|
||||||
|
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||||
|
* tapped occurrence's own times, like on the detail screen. No-op while a
|
||||||
|
* form is open, so user edits survive configuration changes.
|
||||||
|
*/
|
||||||
|
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||||
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val detail = try {
|
||||||
|
repository.eventDetail(eventId)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_loadFailed.value = true
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val zone = TimeZone.currentSystemDefault()
|
||||||
|
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
|
||||||
|
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
|
||||||
|
// Sections holding data must show even when not in the defaults.
|
||||||
|
_revealed.value = snapshot.form.populatedFields()
|
||||||
|
_form.value = snapshot.form
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
|
||||||
|
fun reset() {
|
||||||
|
_form.value = null
|
||||||
|
_saveState.value = SaveUiState.Idle
|
||||||
|
_showProblems.value = false
|
||||||
|
_revealed.value = emptySet()
|
||||||
|
_editTarget.value = null
|
||||||
|
_loadFailed.value = false
|
||||||
|
_remindersTouched.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
|
fun revealField(field: EventFormField) {
|
||||||
|
_revealed.value = _revealed.value + field
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTitle(value: String) = update { it.copy(title = value) }
|
||||||
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
|
fun setAllDay(value: Boolean) {
|
||||||
|
update { it.copy(isAllDay = value) }
|
||||||
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
|
applyDefaultReminder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switching calendars drops any chosen colour: a palette key is
|
||||||
|
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||||
|
* The event falls back to the new calendar's colour until re-picked.
|
||||||
|
*/
|
||||||
|
fun setCalendar(id: Long) {
|
||||||
|
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||||
|
// A fresh event re-inherits the new calendar's default reminder unless
|
||||||
|
// the user has already hand-edited it (guarded inside).
|
||||||
|
applyDefaultReminder(id)
|
||||||
|
}
|
||||||
|
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||||
|
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||||
|
|
||||||
|
/** 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) }
|
||||||
|
|
||||||
|
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
|
||||||
|
fun setColorRaw(argb: Int) = update { it.copy(colorKey = null, color = argb) }
|
||||||
|
|
||||||
|
/** Clear the colour so the event inherits its calendar's. */
|
||||||
|
fun clearColor() = update { it.copy(colorKey = null, color = null) }
|
||||||
|
|
||||||
|
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||||
|
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||||
|
|
||||||
|
fun addReminder(minutes: Int) {
|
||||||
|
_remindersTouched.value = true
|
||||||
|
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeReminder(minutes: Int) {
|
||||||
|
_remindersTouched.value = true
|
||||||
|
update { it.copy(reminders = it.reminders - minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moving the start drags the end along, preserving the duration. */
|
||||||
|
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
||||||
|
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
||||||
|
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||||
|
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and write. Saving a dirty recurring event pauses in
|
||||||
|
* [SaveUiState.AwaitingScope] until the screen answers via
|
||||||
|
* [saveWithScope]; everything else writes directly. Terminal results
|
||||||
|
* land in [saveState].
|
||||||
|
*/
|
||||||
|
fun save() {
|
||||||
|
val current = state.value ?: return
|
||||||
|
if (current.saveState == SaveUiState.Saving) return
|
||||||
|
val form = current.form
|
||||||
|
if (form.problems().isNotEmpty()) {
|
||||||
|
_showProblems.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val target = _editTarget.value
|
||||||
|
if (target != null && form == target.original) {
|
||||||
|
// A pristine form saves as a no-op instead of a write.
|
||||||
|
_saveState.value = SaveUiState.Saved
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (target != null && target.original.rrule != null) {
|
||||||
|
_saveState.value = SaveUiState.AwaitingScope
|
||||||
|
return
|
||||||
|
}
|
||||||
|
performSave(form, RecurringWriteScope.AllEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finish a save parked in [SaveUiState.AwaitingScope]. */
|
||||||
|
fun saveWithScope(scope: RecurringWriteScope) {
|
||||||
|
val current = state.value ?: return
|
||||||
|
if (current.saveState != SaveUiState.AwaitingScope) return
|
||||||
|
performSave(current.form, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finish a save parked in [SaveUiState.AwaitingConflict], overwriting. */
|
||||||
|
fun saveOverwriting() {
|
||||||
|
val current = state.value ?: return
|
||||||
|
val parked = current.saveState as? SaveUiState.AwaitingConflict ?: return
|
||||||
|
performSave(current.form, parked.scope, ignoreConflict = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performSave(
|
||||||
|
form: EventForm,
|
||||||
|
scope: RecurringWriteScope,
|
||||||
|
ignoreConflict: Boolean = false,
|
||||||
|
) {
|
||||||
|
val target = _editTarget.value
|
||||||
|
viewModelScope.launch {
|
||||||
|
_saveState.value = SaveUiState.Saving
|
||||||
|
// No locking (plan 03, decision 5): right before writing, re-read
|
||||||
|
// the event and compare against what the form loaded. An external
|
||||||
|
// change parks the save in a conflict dialog instead of silently
|
||||||
|
// clobbering the edited fields.
|
||||||
|
if (target != null && !ignoreConflict) {
|
||||||
|
val fresh = try {
|
||||||
|
repository.eventDetail(target.eventId)
|
||||||
|
.toEditSnapshot(target.beginMillis, target.endMillis, target.zone)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
_saveState.value = SaveUiState.Gone
|
||||||
|
return@launch
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Can't verify — proceed; a real problem fails the write itself.
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (fresh != null && fresh != target.snapshot) {
|
||||||
|
_saveState.value = SaveUiState.AwaitingConflict(scope)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_saveState.value = try {
|
||||||
|
if (target == null) {
|
||||||
|
repository.createEvent(form)
|
||||||
|
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||||
|
} else {
|
||||||
|
when (scope) {
|
||||||
|
RecurringWriteScope.ThisEvent ->
|
||||||
|
repository.updateOccurrence(target.eventId, target.beginMillis, form)
|
||||||
|
RecurringWriteScope.ThisAndFollowing ->
|
||||||
|
repository.updateEventFromOccurrence(
|
||||||
|
eventId = target.eventId,
|
||||||
|
beginMillis = target.beginMillis,
|
||||||
|
original = target.original,
|
||||||
|
updated = form,
|
||||||
|
)
|
||||||
|
RecurringWriteScope.AllEvents ->
|
||||||
|
repository.updateEvent(target.eventId, target.original, form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SaveUiState.Saved
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
SaveUiState.NeedsPermission
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SaveUiState.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset [saveState] after the screen handled a terminal result. */
|
||||||
|
fun consumeSaveResult() {
|
||||||
|
_saveState.value = SaveUiState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
|
||||||
|
val zone = TimeZone.currentSystemDefault()
|
||||||
|
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 inline fun update(block: (EventForm) -> EventForm) {
|
||||||
|
_form.value = _form.value?.let(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,17 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.filter
|
package de.jeanlucmakiola.calendula.ui.filter
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
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.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -28,7 +20,9 @@ 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.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
||||||
@@ -53,66 +47,43 @@ fun CalendarFilterList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders as a plain (non-scrolling) [Column] so it flows inside the drawer's
|
||||||
|
* single scroll container — the whole sidebar scrolls as one. Calendar counts
|
||||||
|
* are small, so a lazy list isn't needed.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun FilterList(
|
private fun FilterList(
|
||||||
groups: List<AccountGroup>,
|
groups: List<AccountGroup>,
|
||||||
onSetVisible: (Long, Boolean) -> Unit,
|
onSetVisible: (Long, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
LazyColumn(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
contentPadding = PaddingValues(vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
item(key = "header-${group.account}") {
|
|
||||||
Text(
|
Text(
|
||||||
text = group.account,
|
text = group.account,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
group.calendars.forEachIndexed { index, cal ->
|
||||||
items(group.calendars, key = { it.id }) { cal ->
|
GroupedRow(
|
||||||
CalendarToggleRow(
|
title = cal.displayName,
|
||||||
row = cal,
|
position = positionOf(index, group.calendars.size),
|
||||||
dark = dark,
|
minHeight = 56.dp,
|
||||||
|
leading = { CalendarColorChip(cal.color) },
|
||||||
|
trailing = {
|
||||||
|
Checkbox(
|
||||||
|
checked = cal.visible,
|
||||||
onCheckedChange = { onSetVisible(cal.id, it) },
|
onCheckedChange = { onSetVisible(cal.id, it) },
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
}
|
onClick = { onSetVisible(cal.id, !cal.visible) },
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CalendarToggleRow(
|
|
||||||
row: CalendarRow,
|
|
||||||
dark: Boolean,
|
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 28.dp, vertical = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(14.dp)
|
|
||||||
.background(pastelize(row.color, dark), CircleShape),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = row.displayName,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Checkbox(
|
|
||||||
checked = row.visible,
|
|
||||||
onCheckedChange = onCheckedChange,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FilterLoading(modifier: Modifier = Modifier) {
|
private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
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.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles an opened/received `.ics` file. A single event is handed straight to
|
||||||
|
* the prefilled create form via [onOpenSingle]; several events show a target-
|
||||||
|
* calendar picker and import in bulk (dedup by UID), then a result summary.
|
||||||
|
* Empty/failed files show a short message and close.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ImportScreen(
|
||||||
|
uri: Uri,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
|
viewModel: ImportViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
LaunchedEffect(uri) { viewModel.load(uri) }
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
BackHandler(onBack = onClose)
|
||||||
|
|
||||||
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.import_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
when (val s = state) {
|
||||||
|
ImportUiState.Loading,
|
||||||
|
ImportUiState.Importing,
|
||||||
|
is ImportUiState.Single,
|
||||||
|
-> CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||||
|
|
||||||
|
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||||
|
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||||
|
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
|
||||||
|
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
|
||||||
|
// No writable calendar to import into — tell the user honestly.
|
||||||
|
if (state.calendars.isEmpty()) {
|
||||||
|
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.import_target_header),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
state.calendars.forEach { calendar ->
|
||||||
|
OptionCard(
|
||||||
|
label = calendar.displayName,
|
||||||
|
onClick = { selected = calendar.id },
|
||||||
|
selected = calendar.id == selected,
|
||||||
|
icon = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.warnings.forEach { WarningText(it) }
|
||||||
|
Button(
|
||||||
|
onClick = { onImport(selected) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.import_done_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_done_imported,
|
||||||
|
state.summary.imported,
|
||||||
|
state.summary.imported,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
if (state.summary.skippedDuplicate > 0) {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_done_skipped,
|
||||||
|
state.summary.skippedDuplicate,
|
||||||
|
state.summary.skippedDuplicate,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
Text(stringResource(R.string.import_close))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WarningText(warning: IcsParseWarning) {
|
||||||
|
val text = when (warning) {
|
||||||
|
IcsParseWarning.ModifiedOccurrenceSkipped -> stringResource(R.string.import_warning_recurrence)
|
||||||
|
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
||||||
|
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
||||||
|
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(message, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
if (onClose != null) {
|
||||||
|
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
|
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.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** What an opened/received `.ics` resolved to. */
|
||||||
|
sealed interface ImportUiState {
|
||||||
|
data object Loading : ImportUiState
|
||||||
|
data object Importing : ImportUiState
|
||||||
|
|
||||||
|
/** The file held no importable event (or couldn't be read/parsed as one). */
|
||||||
|
data object Empty : ImportUiState
|
||||||
|
data object Failed : ImportUiState
|
||||||
|
|
||||||
|
/** Exactly one event → review it in the prefilled create form. */
|
||||||
|
data class Single(val form: EventForm, val warnings: Set<IcsParseWarning>) : ImportUiState
|
||||||
|
|
||||||
|
/** Several events → pick a target calendar and bulk-import. */
|
||||||
|
data class Many(
|
||||||
|
val events: List<ParsedIcsEvent>,
|
||||||
|
val warnings: Set<IcsParseWarning>,
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
) : ImportUiState
|
||||||
|
|
||||||
|
data class Done(val summary: IcsImportSummary) : ImportUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads an `.ics` [Uri], parses it (pure [IcsParser]) and routes by event count:
|
||||||
|
* one event opens the create form for review, many open the bulk-import picker.
|
||||||
|
* The bulk import dedups by UID in the repository.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ImportViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val importer: IcsImporter,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val parser = IcsParser()
|
||||||
|
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
||||||
|
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
|
||||||
|
fun load(uri: Uri) {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val parsed = withContext(io) {
|
||||||
|
importer.readText(uri)?.let(parser::parse)
|
||||||
|
}
|
||||||
|
_state.value = when {
|
||||||
|
parsed == null -> ImportUiState.Failed
|
||||||
|
parsed.events.isEmpty() -> ImportUiState.Empty
|
||||||
|
parsed.events.size == 1 -> ImportUiState.Single(
|
||||||
|
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
||||||
|
warnings = parsed.warnings,
|
||||||
|
)
|
||||||
|
else -> ImportUiState.Many(
|
||||||
|
events = parsed.events,
|
||||||
|
warnings = parsed.warnings,
|
||||||
|
calendars = repository.calendars().first().filter { it.canModifyContents },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
|
||||||
|
fun import(targetCalendarId: Long) {
|
||||||
|
val many = _state.value as? ImportUiState.Many ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = ImportUiState.Importing
|
||||||
|
_state.value = try {
|
||||||
|
ImportUiState.Done(repository.importEvents(targetCalendarId, many.events))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ImportUiState.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,30 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -48,7 +43,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -57,11 +54,13 @@ import androidx.compose.ui.semantics.contentDescription
|
|||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
@@ -71,11 +70,12 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -86,6 +86,7 @@ fun MonthScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -113,10 +114,21 @@ fun MonthScreen(
|
|||||||
slideDir = -1
|
slideDir = -1
|
||||||
viewModel.goToPrev()
|
viewModel.goToPrev()
|
||||||
}
|
}
|
||||||
|
// Slide toward today: viewing the future → today comes in from the left
|
||||||
|
// (back), viewing the past → from the right (forward).
|
||||||
val jumpToToday = {
|
val jumpToToday = {
|
||||||
slideDir = 0
|
slideDir = when (val s = state) {
|
||||||
|
is MonthUiState.Success ->
|
||||||
|
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
viewModel.goToToday()
|
viewModel.goToToday()
|
||||||
}
|
}
|
||||||
|
// Drawer jump-to-date: slide from the side the target month lies on.
|
||||||
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
|
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
|
||||||
|
viewModel.goToDate(target)
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -124,8 +136,14 @@ fun MonthScreen(
|
|||||||
gesturesEnabled = drawerState.isOpen,
|
gesturesEnabled = drawerState.isOpen,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
onToday = {
|
currentView = selectedView,
|
||||||
jumpToToday()
|
currentDate = LocalDate(month.year, month.month, 1),
|
||||||
|
onSelectView = { view ->
|
||||||
|
onSelectView(view)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
jumpToDate(target)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
onSettings = {
|
onSettings = {
|
||||||
@@ -147,17 +165,21 @@ fun MonthScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
CalendarFabColumn(
|
||||||
visible = !isOnCurrentMonth,
|
todayVisible = !isOnCurrentMonth,
|
||||||
enter = scaleIn(),
|
todayText = stringResource(R.string.month_today_action),
|
||||||
exit = scaleOut(),
|
onToday = jumpToToday,
|
||||||
) {
|
onCreate = {
|
||||||
ExtendedFloatingActionButton(
|
// Anchor on today when its month is shown, else the 1st.
|
||||||
onClick = jumpToToday,
|
val today = Clock.System.now()
|
||||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
text = { Text(stringResource(R.string.month_today_action)) },
|
onCreateEvent(
|
||||||
|
if (isOnCurrentMonth) today
|
||||||
|
else LocalDate(month.year, month.month, 1),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
@@ -168,7 +190,6 @@ fun MonthScreen(
|
|||||||
WeekdayHeader(weekStart = weekStart)
|
WeekdayHeader(weekStart = weekStart)
|
||||||
MonthContent(
|
MonthContent(
|
||||||
state = state,
|
state = state,
|
||||||
weekStart = weekStart,
|
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
@@ -183,7 +204,6 @@ fun MonthScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthContent(
|
private fun MonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
weekStart: DayOfWeek,
|
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
@@ -228,7 +248,6 @@ private fun MonthContent(
|
|||||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is MonthUiState.Success -> MonthGrid(
|
is MonthUiState.Success -> MonthGrid(
|
||||||
state = s,
|
state = s,
|
||||||
weekStart = weekStart,
|
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -298,140 +317,279 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val EVENT_ROW_HEIGHT = 20.dp
|
||||||
|
private val DAY_NUMBER_HEIGHT = 22.dp
|
||||||
|
private val DAY_NUMBER_GAP = 4.dp
|
||||||
|
private val CELL_TOP_PADDING = 6.dp
|
||||||
|
private val CELL_GAP = 2.dp
|
||||||
|
private val CELL_SHAPE = RoundedCornerShape(12.dp)
|
||||||
|
private const val MAX_EVENT_ROWS = 3
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthGrid(
|
private fun MonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
weekStart: DayOfWeek,
|
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
val firstOfMonth = LocalDate(state.month.year, state.month.month, 1)
|
|
||||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
|
||||||
|
|
||||||
// Show only the weeks the current month actually touches; leading/trailing
|
|
||||||
// days of neighbouring months are left blank rather than rendered.
|
|
||||||
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
|
||||||
val daysInMonth =
|
|
||||||
java.time.YearMonth.of(state.month.year, state.month.month.ordinal + 1).lengthOfMonth()
|
|
||||||
val weeks = (leadOffset + daysInMonth + 6) / 7
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
repeat(weeks) { row ->
|
state.weeks.forEach { week ->
|
||||||
Row(
|
MonthWeekRow(
|
||||||
|
week = week,
|
||||||
|
today = state.today,
|
||||||
|
month = state.month,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
repeat(7) { col ->
|
|
||||||
val date = gridStart.plus(row * 7 + col, DateTimeUnit.DAY)
|
|
||||||
val inMonth =
|
|
||||||
date.month == state.month.month && date.year == state.month.year
|
|
||||||
if (inMonth) {
|
|
||||||
DayCard(
|
|
||||||
date = date,
|
|
||||||
isToday = date == state.today,
|
|
||||||
data = state.cells[date],
|
|
||||||
onClick = { onOpenDay(date) },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
/**
|
||||||
|
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
|
||||||
|
* a multi-day event is one connected bar across the columns; single-day timed
|
||||||
|
* events sit beneath them as filled pills in their own cell. The cap is
|
||||||
|
* [MAX_EVENT_ROWS] rows of bars+pills, then a "+N" dot indicator per day.
|
||||||
|
* A transparent per-day layer on top turns a tap into "open that day".
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayCard(
|
private fun MonthWeekRow(
|
||||||
date: LocalDate,
|
week: MonthWeek,
|
||||||
isToday: Boolean,
|
today: LocalDate,
|
||||||
data: DayCellData?,
|
month: YearMonth,
|
||||||
onClick: () -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
|
val dark = isSystemInDarkTheme()
|
||||||
val cellLabel = buildString {
|
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||||
if (isToday) append(todayPrefix).append(", ")
|
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||||
append(date.year).append('-')
|
|
||||||
append((date.month.ordinal + 1).toString().padStart(2, '0')).append('-')
|
|
||||||
append(date.day.toString().padStart(2, '0'))
|
|
||||||
data?.let { append(", ").append(it.count).append(" Events") }
|
|
||||||
}
|
|
||||||
|
|
||||||
// M3 Expressive press feedback: a spatial spring from the active motion
|
BoxWithConstraints(modifier) {
|
||||||
// scheme drives a subtle scale, instead of a fixed easing curve.
|
val colW = maxWidth / 7
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val pressed by interactionSource.collectIsPressedAsState()
|
|
||||||
val scale by animateFloatAsState(
|
|
||||||
targetValue = if (pressed) 0.94f else 1f,
|
|
||||||
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
|
||||||
label = "day-card-press",
|
|
||||||
)
|
|
||||||
|
|
||||||
Card(
|
// Per-day background pills — same surfaceContainer rounded surface the
|
||||||
onClick = onClick,
|
// week/day views use, so the three views share one visual language.
|
||||||
interactionSource = interactionSource,
|
// Spanning bars draw on top of these, bridging cells, so they still read
|
||||||
shape = MaterialTheme.shapes.medium,
|
// as one continuous event.
|
||||||
colors = CardDefaults.cardColors(
|
Row(Modifier.matchParentSize()) {
|
||||||
containerColor = if (isToday) MaterialTheme.colorScheme.primaryContainer
|
week.days.forEach { d ->
|
||||||
else MaterialTheme.colorScheme.surfaceContainerHigh,
|
val inMonth = d.month == month.month && d.year == month.year
|
||||||
contentColor = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer
|
Box(
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
|
.background(
|
||||||
|
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
shape = CELL_SHAPE,
|
||||||
),
|
),
|
||||||
modifier = modifier
|
)
|
||||||
.fillMaxSize()
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = scale
|
|
||||||
scaleY = scale
|
|
||||||
}
|
}
|
||||||
.semantics { contentDescription = cellLabel },
|
}
|
||||||
) {
|
|
||||||
Column(
|
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
week.days.forEach { d ->
|
||||||
|
DayNumberCell(
|
||||||
|
date = d,
|
||||||
|
isToday = d == today,
|
||||||
|
inMonth = d.month == month.month && d.year == month.year,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Breathing room between the day number (and today's circle) and the
|
||||||
|
// first event row.
|
||||||
|
Spacer(Modifier.height(DAY_NUMBER_GAP))
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp, bottom = 2.dp),
|
.weight(1f)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
// Spanning bars on their shared lanes.
|
||||||
|
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||||
|
val cols = span.endCol - span.startCol + 1
|
||||||
|
MonthBar(
|
||||||
|
event = span.event,
|
||||||
|
dark = dark,
|
||||||
|
continuesLeft = span.continuesLeft,
|
||||||
|
continuesRight = span.continuesRight,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(
|
||||||
|
x = colW * span.startCol,
|
||||||
|
y = EVENT_ROW_HEIGHT * span.lane,
|
||||||
|
)
|
||||||
|
.width(colW * cols)
|
||||||
|
.height(EVENT_ROW_HEIGHT)
|
||||||
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Single-day timed pills + overflow, per column. Pills fill the
|
||||||
|
// lane slots no bar occupies on THIS day (top-most first), so a
|
||||||
|
// bar-free day isn't pushed down by a multi-day event that only
|
||||||
|
// sits on other days of the week.
|
||||||
|
week.days.forEachIndexed { col, d ->
|
||||||
|
val timed = week.timedByDay[d].orEmpty()
|
||||||
|
val occupied = week.spans
|
||||||
|
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||||
|
.map { it.lane }
|
||||||
|
.toSet()
|
||||||
|
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
|
||||||
|
val pillsShown = timed.take(freeSlots.size)
|
||||||
|
pillsShown.forEachIndexed { i, ev ->
|
||||||
|
MonthBar(
|
||||||
|
event = ev,
|
||||||
|
dark = dark,
|
||||||
|
continuesLeft = false,
|
||||||
|
continuesRight = false,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(
|
||||||
|
x = colW * col,
|
||||||
|
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
||||||
|
)
|
||||||
|
.width(colW)
|
||||||
|
.height(EVENT_ROW_HEIGHT)
|
||||||
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||||
|
if (hidden > 0) {
|
||||||
|
val hiddenColors = buildList {
|
||||||
|
week.spans
|
||||||
|
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||||
|
.forEach { add(it.event.color) }
|
||||||
|
timed.drop(pillsShown.size).forEach { add(it.color) }
|
||||||
|
}.distinct().take(3)
|
||||||
|
OverflowDots(
|
||||||
|
colors = hiddenColors,
|
||||||
|
extra = hidden - hiddenColors.size,
|
||||||
|
dark = dark,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||||
|
.width(colW)
|
||||||
|
.padding(horizontal = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tap layer: in month view a tap on any day opens that day. Padded and
|
||||||
|
// clipped to the background pill so the ripple matches it.
|
||||||
|
Row(Modifier.matchParentSize()) {
|
||||||
|
week.days.forEach { d ->
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
|
.clip(CELL_SHAPE)
|
||||||
|
.clickable { onOpenDay(d) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayNumberCell(
|
||||||
|
date: LocalDate,
|
||||||
|
isToday: Boolean,
|
||||||
|
inMonth: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.height(DAY_NUMBER_HEIGHT),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (isToday) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(DAY_NUMBER_HEIGHT)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = date.day.toString(),
|
text = date.day.toString(),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = date.day.toString(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = if (inMonth) MaterialTheme.colorScheme.onSurface
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
EventDotRow(data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A filled event pill/bar — pastelized fill, title clipped to one line. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventDotRow(data: DayCellData?) {
|
private fun MonthBar(
|
||||||
if (data == null || data.swatches.isEmpty()) {
|
event: de.jeanlucmakiola.calendula.domain.EventInstance,
|
||||||
Spacer(Modifier.height(6.dp))
|
dark: Boolean,
|
||||||
return
|
continuesLeft: Boolean,
|
||||||
|
continuesRight: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
val shape = RoundedCornerShape(
|
||||||
|
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
|
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
|
topEnd = if (continuesRight) 0.dp else 4.dp,
|
||||||
|
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(pastelize(event.color, dark), shape)
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.semantics { contentDescription = title },
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = Color.Black.copy(alpha = 0.8f),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val dark = isSystemInDarkTheme()
|
}
|
||||||
|
|
||||||
|
/** Overflow row: a dot per hidden event (up to three) plus "+N" for the rest. */
|
||||||
|
@Composable
|
||||||
|
private fun OverflowDots(
|
||||||
|
colors: List<Int>,
|
||||||
|
extra: Int,
|
||||||
|
dark: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
data.swatches.forEach { argb ->
|
colors.forEach { argb ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(6.dp)
|
.size(6.dp)
|
||||||
.background(pastelize(argb, dark), CircleShape),
|
.background(pastelize(argb, dark), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (data.count > data.swatches.size) {
|
if (extra > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "+${data.count - data.swatches.size}",
|
text = "+$extra",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-day aggregation surfaced to the month grid. We only need
|
* An all-day or multi-day event laid out as one connected horizontal bar across
|
||||||
* - the total event count (drives the optional "+N" indicator), and
|
* a week row, [startCol]..[endCol], stacked on [lane] so overlapping spans don't
|
||||||
* - up to three calendar colors for the dot row.
|
* collide. Mirrors the week view's [de.jeanlucmakiola.calendula.ui.week.AllDaySpan]
|
||||||
*
|
* but adds clip flags so a bar that started in an earlier week (or runs into a
|
||||||
* The day cell never holds full event objects — the detail sheet pulls those
|
* later one) drops its rounded cap on that side.
|
||||||
* lazily.
|
|
||||||
*/
|
*/
|
||||||
data class DayCellData(
|
data class MonthSpan(
|
||||||
val count: Int,
|
val event: EventInstance,
|
||||||
val swatches: List<Int>,
|
val startCol: Int,
|
||||||
|
val endCol: Int,
|
||||||
|
val lane: Int,
|
||||||
|
val continuesLeft: Boolean,
|
||||||
|
val continuesRight: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One week row of the grid with its events resolved for rendering.
|
||||||
|
*
|
||||||
|
* - [spans] are the all-day/multi-day bars, lanes already assigned for the row.
|
||||||
|
* - [timedByDay] holds the single-day timed events per date, sorted by start;
|
||||||
|
* these render as filled pills beneath the bar lanes in their own cell.
|
||||||
|
* - [countByDay] is the total number of events touching each date (bars + pills),
|
||||||
|
* so the cell can compute the "+N" overflow once the visible-row cap is known.
|
||||||
|
*/
|
||||||
|
data class MonthWeek(
|
||||||
|
val days: List<LocalDate>,
|
||||||
|
val spans: List<MonthSpan>,
|
||||||
|
val timedByDay: Map<LocalDate, List<EventInstance>>,
|
||||||
|
val countByDay: Map<LocalDate, Int>,
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface MonthUiState {
|
sealed interface MonthUiState {
|
||||||
@@ -23,6 +43,6 @@ sealed interface MonthUiState {
|
|||||||
data class Success(
|
data class Success(
|
||||||
val month: YearMonth,
|
val month: YearMonth,
|
||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val cells: Map<LocalDate, DayCellData>,
|
val weeks: List<MonthWeek>,
|
||||||
) : MonthUiState
|
) : MonthUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -71,7 +73,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
repository.instances(range),
|
repository.instances(range),
|
||||||
) { calendars, instances ->
|
) { calendars, instances ->
|
||||||
buildState(ym, calendars, instances)
|
buildState(ym, ws, calendars, instances)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
@@ -94,25 +96,73 @@ class MonthViewModel @Inject constructor(
|
|||||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||||
|
fun goToDate(date: LocalDate) {
|
||||||
|
_month.value = YearMonth(date.year, date.month)
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
ym: YearMonth,
|
ym: YearMonth,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): MonthUiState {
|
): MonthUiState {
|
||||||
if (calendars.isEmpty()) {
|
if (calendars.isEmpty()) {
|
||||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
}
|
}
|
||||||
val byDay = instances.groupBy { it.start.toLocalDateTime(zone).date }
|
|
||||||
.mapValues { (_, evs) ->
|
|
||||||
DayCellData(
|
|
||||||
count = evs.size,
|
|
||||||
swatches = evs.map { it.color }.distinct().take(3),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return MonthUiState.Success(
|
return MonthUiState.Success(
|
||||||
month = ym,
|
month = ym,
|
||||||
today = todayDate,
|
today = todayDate,
|
||||||
cells = byDay,
|
weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split the month grid into week rows and resolve each row's events. An event is
|
||||||
|
* a spanning bar when it's all-day or touches more than one of the row's days;
|
||||||
|
* everything else is a single-day timed pill. Bars get lanes from the shared
|
||||||
|
* [layoutAllDay] so a multi-day event stays on one row across the week.
|
||||||
|
*
|
||||||
|
* Shared by the Month screen and the month home-screen widget so both lay out
|
||||||
|
* spans, lanes and per-day counts identically.
|
||||||
|
*/
|
||||||
|
internal fun layoutMonthWeeks(
|
||||||
|
ym: YearMonth,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<MonthWeek> {
|
||||||
|
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
||||||
|
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||||
|
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
val daysInMonth =
|
||||||
|
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
||||||
|
val weekCount = (leadOffset + daysInMonth + 6) / 7
|
||||||
|
|
||||||
|
return (0 until weekCount).map { row ->
|
||||||
|
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
||||||
|
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
||||||
|
val (bars, singles) = weekEvents.partition { ev ->
|
||||||
|
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
||||||
|
}
|
||||||
|
val spans = layoutAllDay(bars, days, zone).map { s ->
|
||||||
|
MonthSpan(
|
||||||
|
event = s.event,
|
||||||
|
startCol = s.startCol,
|
||||||
|
endCol = s.endCol,
|
||||||
|
lane = s.lane,
|
||||||
|
continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone),
|
||||||
|
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MonthWeek(
|
||||||
|
days = days,
|
||||||
|
spans = spans,
|
||||||
|
timedByDay = days.associateWith { d ->
|
||||||
|
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
|
||||||
|
},
|
||||||
|
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** MD3 8dp spacing scale shared by the onboarding screens. */
|
||||||
|
internal object OnboardingSpace {
|
||||||
|
val xs = 8.dp
|
||||||
|
val sm = 16.dp
|
||||||
|
val md = 24.dp
|
||||||
|
val lg = 32.dp
|
||||||
|
val xl = 48.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared onboarding shell (calendar grant, reminder step): a scrollable,
|
||||||
|
* centred hero + body with the call(s) to action pinned to the bottom (clear
|
||||||
|
* of the navigation bar). The content slot is centred horizontally; benefit
|
||||||
|
* rows fill the width so their own content left-aligns.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun OnboardingScaffold(
|
||||||
|
hero: @Composable () -> Unit,
|
||||||
|
actions: @Composable ColumnScope.() -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
bottomBar = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = actions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = OnboardingSpace.md),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
hero()
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
body()
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.md))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
||||||
|
@Composable
|
||||||
|
internal fun BrandHero(denied: Boolean) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = stringResource(R.string.app_name),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (denied) {
|
||||||
|
// A small lock badge sits over the corner to signal "blocked".
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
||||||
|
@Composable
|
||||||
|
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(OnboardingSpace.sm))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,15 +7,24 @@ import android.provider.Settings
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -23,11 +32,18 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
private val CALENDAR_PERMISSIONS = arrayOf(
|
||||||
|
Manifest.permission.READ_CALENDAR,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PermissionScreen(
|
fun PermissionScreen(
|
||||||
onGranted: () -> Unit,
|
onGranted: () -> Unit,
|
||||||
@@ -36,10 +52,17 @@ fun PermissionScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// READ and WRITE are requested together (one system dialog — same
|
||||||
|
// permission group), but only READ gates the app: declining write keeps
|
||||||
|
// Calendula usable read-only.
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
) { granted ->
|
) { results ->
|
||||||
if (granted) viewModel.onGranted() else viewModel.onDenied()
|
if (results[Manifest.permission.READ_CALENDAR] == true) {
|
||||||
|
viewModel.onGranted()
|
||||||
|
} else {
|
||||||
|
viewModel.onDenied()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(state) {
|
LaunchedEffect(state) {
|
||||||
@@ -48,13 +71,13 @@ fun PermissionScreen(
|
|||||||
|
|
||||||
when (state) {
|
when (state) {
|
||||||
is PermissionUiState.Rationale -> RationaleContent(
|
is PermissionUiState.Rationale -> RationaleContent(
|
||||||
onRequest = { launcher.launch(Manifest.permission.READ_CALENDAR) },
|
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
is PermissionUiState.Denied -> DeniedContent(
|
is PermissionUiState.Denied -> DeniedContent(
|
||||||
onRetry = {
|
onRetry = {
|
||||||
viewModel.onRetry()
|
viewModel.onRetry()
|
||||||
launcher.launch(Manifest.permission.READ_CALENDAR)
|
launcher.launch(CALENDAR_PERMISSIONS)
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
@@ -69,24 +92,68 @@ private fun RationaleContent(
|
|||||||
onRequest: () -> Unit,
|
onRequest: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
OnboardingScaffold(
|
||||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
modifier = modifier,
|
||||||
verticalArrangement = Arrangement.Center,
|
hero = { BrandHero(denied = false) },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onRequest,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_request_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(OnboardingSpace.xs))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PrivacyFootnote()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_rationale_title),
|
text = stringResource(R.string.permission_rationale_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_rationale_body),
|
text = stringResource(R.string.permission_rationale_body),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Lock,
|
||||||
|
title = stringResource(R.string.permission_benefit_private_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_private_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.CalendarMonth,
|
||||||
|
title = stringResource(R.string.permission_benefit_sync_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_sync_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.VisibilityOff,
|
||||||
|
title = stringResource(R.string.permission_benefit_privacy_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_privacy_body),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
Button(onClick = onRequest) {
|
|
||||||
Text(stringResource(R.string.permission_request_button))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,26 +163,11 @@ private fun DeniedContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
Column(
|
OnboardingScaffold(
|
||||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
modifier = modifier,
|
||||||
verticalArrangement = Arrangement.Center,
|
hero = { BrandHero(denied = true) },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
actions = {
|
||||||
) {
|
Button(
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.permission_denied_title),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.permission_denied_body),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
Button(onClick = onRetry) {
|
|
||||||
Text(stringResource(R.string.permission_retry_button))
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
onClick = {
|
||||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
data = Uri.fromParts("package", context.packageName, null)
|
data = Uri.fromParts("package", context.packageName, null)
|
||||||
@@ -123,8 +175,54 @@ private fun DeniedContent(
|
|||||||
}
|
}
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
},
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.permission_open_settings_button))
|
Text(
|
||||||
|
text = stringResource(R.string.permission_open_settings_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.permission_retry_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_denied_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_denied_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PrivacyFootnote() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_privacy_footnote),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time onboarding step after the calendar grant (v1.4): explains that
|
||||||
|
* Calendula delivers reminder notifications itself, warns about duplicates
|
||||||
|
* when a second calendar app has notifications on, and requests
|
||||||
|
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
||||||
|
*
|
||||||
|
* Reminders default ON: [onFinished] gets true from the primary action even
|
||||||
|
* if the system dialog is declined — the OS permission is the real gate, and
|
||||||
|
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderOnboardingScreen(
|
||||||
|
onFinished: (remindersEnabled: Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { onFinished(true) }
|
||||||
|
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
hero = { BellHero() },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
} else {
|
||||||
|
onFinished(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reminder_onboarding_enable_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = { onFinished(false) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_onboarding_skip_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reminder_onboarding_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reminder_onboarding_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.NotificationsActive,
|
||||||
|
title = stringResource(R.string.reminder_benefit_delivery_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_delivery_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.ContentCopy,
|
||||||
|
title = stringResource(R.string.reminder_benefit_duplicates_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_duplicates_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Tune,
|
||||||
|
title = stringResource(R.string.reminder_benefit_reversible_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_reversible_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bell in the brand squircle — same silhouette as the permission hero. */
|
||||||
|
@Composable
|
||||||
|
private fun BellHero() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.NotificationsActive,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
|
||||||
|
* grant. [onboardingDone] is null until DataStore's first emission so the
|
||||||
|
* step neither flashes for users who completed it nor gets skipped.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ReminderOnboardingViewModel @Inject constructor(
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||||
|
.map { done -> done as Boolean? }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Close the step, recording whether reminder notifications stay on. */
|
||||||
|
fun finish(remindersEnabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setRemindersEnabled(remindersEnabled)
|
||||||
|
prefs.setReminderOnboardingDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,78 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/** UI-facing language choice. AUTO follows the system languages. */
|
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||||
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-app language via AppCompatDelegate. On API 33+ this delegates to the
|
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
|
||||||
* platform per-app-languages API; below that the appcompat backport persists
|
*
|
||||||
* the choice itself (manifest `autoStoreLocales` service), so we don't mirror
|
* That file is the single source of truth for which languages we ship: dropping
|
||||||
* it in DataStore. Setting a locale recreates the activity, which re-reads the
|
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
|
||||||
* current value for the dropdown.
|
* language show up here and in the system per-app-language settings, with no
|
||||||
|
* other code change. The system-default choice is represented as `null`.
|
||||||
|
*
|
||||||
|
* On API 33+ this delegates to the platform per-app-languages API; below that
|
||||||
|
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
|
||||||
|
* service), so we don't mirror it in DataStore. Setting a locale recreates the
|
||||||
|
* activity, which re-reads the current value for the picker.
|
||||||
*/
|
*/
|
||||||
object AppLanguage {
|
object AppLanguage {
|
||||||
|
|
||||||
fun current(): LanguagePref {
|
/**
|
||||||
val locales = AppCompatDelegate.getApplicationLocales()
|
* The BCP-47 tags the app ships translations for, in declaration order, as
|
||||||
if (locales.isEmpty) return LanguagePref.AUTO
|
* listed in locales_config.xml. Returns whatever could be parsed; a missing
|
||||||
return when (locales[0]?.language) {
|
* or malformed config yields an empty list (the picker then offers only the
|
||||||
"de" -> LanguagePref.GERMAN
|
* system-default entry rather than crashing).
|
||||||
"en" -> LanguagePref.ENGLISH
|
*/
|
||||||
else -> LanguagePref.AUTO
|
fun supportedTags(context: Context): List<String> {
|
||||||
|
val tags = mutableListOf<String>()
|
||||||
|
val parser = context.resources.getXml(R.xml.locales_config)
|
||||||
|
try {
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
|
||||||
|
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
|
||||||
}
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Fall back to whatever was parsed before the failure.
|
||||||
|
} finally {
|
||||||
|
parser.close()
|
||||||
|
}
|
||||||
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
fun apply(pref: LanguagePref) {
|
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
||||||
val locales = when (pref) {
|
fun currentTag(): String? {
|
||||||
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList()
|
val locales = AppCompatDelegate.getApplicationLocales()
|
||||||
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de")
|
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
||||||
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en")
|
}
|
||||||
|
|
||||||
|
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
|
||||||
|
fun apply(tag: String?) {
|
||||||
|
val locales = if (tag == null) {
|
||||||
|
LocaleListCompat.getEmptyLocaleList()
|
||||||
|
} else {
|
||||||
|
LocaleListCompat.forLanguageTags(tag)
|
||||||
}
|
}
|
||||||
AppCompatDelegate.setApplicationLocales(locales)
|
AppCompatDelegate.setApplicationLocales(locales)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The autonym for a tag — the language's own name in its own script, e.g.
|
||||||
|
* "Deutsch", "English", "Français" — so users find their language regardless
|
||||||
|
* of the current UI language. Capitalised per the language's own rules.
|
||||||
|
*/
|
||||||
|
fun displayName(tag: String): String {
|
||||||
|
val locale = Locale.forLanguageTag(tag)
|
||||||
|
return locale.getDisplayName(locale)
|
||||||
|
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -14,4 +17,32 @@ data class SettingsUiState(
|
|||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
val dynamicColorAvailable: Boolean = true,
|
val dynamicColorAvailable: Boolean = true,
|
||||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||||
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
|
val remindersEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* The default reminder lead time (minutes) prefilled on new timed events;
|
||||||
|
* null = no default reminder. Per-calendar overrides take precedence.
|
||||||
|
*/
|
||||||
|
val defaultReminderMinutes: Int? = null,
|
||||||
|
/** The default reminder lead time prefilled on new all-day events; null = none. */
|
||||||
|
val defaultAllDayReminderMinutes: Int? = null,
|
||||||
|
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||||
|
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
||||||
|
* calendar present in the map overrides the global default (null value = no
|
||||||
|
* reminder); absent = inherit the global default.
|
||||||
|
*/
|
||||||
|
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||||
|
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||||
|
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||||
|
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||||
|
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Whether the event-colour picker is offered on calendars that publish no
|
||||||
|
* colour palette (the colour may then not survive their next sync).
|
||||||
|
*/
|
||||||
|
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ import android.os.Build
|
|||||||
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.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -17,21 +24,60 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
|
repository: CalendarRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
|
/** Writable calendars — the only ones that take a per-calendar reminder override. */
|
||||||
|
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||||
|
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||||
|
.catch { emit(emptyList()) }
|
||||||
|
|
||||||
val state: StateFlow<SettingsUiState> =
|
val state: StateFlow<SettingsUiState> =
|
||||||
|
combine(
|
||||||
|
// combine() types up to five flows, so the prefs split into two
|
||||||
|
// groups that fold together in the outer combine.
|
||||||
combine(
|
combine(
|
||||||
prefs.themeMode,
|
prefs.themeMode,
|
||||||
prefs.dynamicColor,
|
prefs.dynamicColor,
|
||||||
prefs.weekStart,
|
prefs.weekStart,
|
||||||
) { theme, dynamic, weekStart ->
|
prefs.defaultFormFields,
|
||||||
|
prefs.remindersEnabled,
|
||||||
|
) { theme, dynamic, weekStart, formFields, reminders ->
|
||||||
SettingsUiState(
|
SettingsUiState(
|
||||||
themeMode = theme,
|
themeMode = theme,
|
||||||
dynamicColor = dynamic && dynamicColorAvailable,
|
dynamicColor = dynamic && dynamicColorAvailable,
|
||||||
dynamicColorAvailable = dynamicColorAvailable,
|
dynamicColorAvailable = dynamicColorAvailable,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
|
defaultFormFields = formFields,
|
||||||
|
remindersEnabled = reminders,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
combine(
|
||||||
|
prefs.allowColorOnUnsupportedCalendars,
|
||||||
|
prefs.defaultReminderMinutes,
|
||||||
|
prefs.defaultAllDayReminderMinutes,
|
||||||
|
prefs.allDayReminderTimeMinutes,
|
||||||
|
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime ->
|
||||||
|
ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime)
|
||||||
|
},
|
||||||
|
combine(
|
||||||
|
prefs.perCalendarReminderOverride,
|
||||||
|
prefs.perCalendarAllDayReminderOverride,
|
||||||
|
writableCalendars,
|
||||||
|
) { overrides, allDayOverrides, calendars ->
|
||||||
|
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||||
|
},
|
||||||
|
) { base, defaults, overrides ->
|
||||||
|
base.copy(
|
||||||
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
|
allDayReminderTimeMinutes = defaults.allDayReminderTime,
|
||||||
|
perCalendarReminderOverride = overrides.timed,
|
||||||
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
|
writableCalendars = overrides.calendars,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -39,6 +85,19 @@ class SettingsViewModel @Inject constructor(
|
|||||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ReminderDefaults(
|
||||||
|
val allowColor: Boolean,
|
||||||
|
val defaultReminder: Int?,
|
||||||
|
val allDayReminder: Int?,
|
||||||
|
val allDayReminderTime: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReminderOverrides(
|
||||||
|
val timed: Map<Long, Int?>,
|
||||||
|
val allDay: Map<Long, Int?>,
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
)
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
@@ -50,4 +109,36 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setWeekStart(pref: WeekStartPref) {
|
fun setWeekStart(pref: WeekStartPref) {
|
||||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRemindersEnabled(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultReminderMinutes(minutes: Int?) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||||
|
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.theme
|
|||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
|
import androidx.compose.material3.MotionScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -17,6 +19,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
* The Settings screen (later) can override useDynamicColor and themePreference,
|
* The Settings screen (later) can override useDynamicColor and themePreference,
|
||||||
* but the V1 foundation just follows the system.
|
* but the V1 foundation just follows the system.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendulaTheme(
|
fun CalendulaTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
@@ -32,9 +35,15 @@ fun CalendulaTheme(
|
|||||||
else -> CalendulaLightFallback
|
else -> CalendulaLightFallback
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme(
|
// MaterialExpressiveTheme routes all component + custom motion through
|
||||||
|
// MaterialTheme.motionScheme (switches, chips, pickers, calendar slide,
|
||||||
|
// FAB, field reveal). The STANDARD scheme is a deliberate choice over
|
||||||
|
// expressive(): same spring choreography, but without the overshoot —
|
||||||
|
// the bouncy variant felt overdone in review (2026-06-11).
|
||||||
|
MaterialExpressiveTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = CalendulaTypography,
|
typography = CalendulaTypography,
|
||||||
|
motionScheme = MotionScheme.standard(),
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -31,12 +29,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -77,6 +73,7 @@ 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.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
@@ -88,7 +85,10 @@ import de.jeanlucmakiola.calendula.ui.common.pastelize
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -113,6 +113,7 @@ fun WeekScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -146,7 +147,20 @@ fun WeekScreen(
|
|||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
||||||
val jumpToToday = { slideDir = 0; viewModel.goToToday() }
|
// Slide toward today: viewing the future → today comes in from the left
|
||||||
|
// (back), viewing the past → from the right (forward).
|
||||||
|
val jumpToToday = {
|
||||||
|
slideDir = when (val s = state) {
|
||||||
|
is WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
viewModel.goToToday()
|
||||||
|
}
|
||||||
|
// Drawer jump-to-date: slide from the side the target week lies on.
|
||||||
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
|
slideDir = if (target < weekStart) -1 else 1
|
||||||
|
viewModel.goToDate(target)
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -154,7 +168,16 @@ fun WeekScreen(
|
|||||||
gesturesEnabled = drawerState.isOpen,
|
gesturesEnabled = drawerState.isOpen,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
currentView = selectedView,
|
||||||
|
currentDate = weekStart,
|
||||||
|
onSelectView = { view ->
|
||||||
|
onSelectView(view)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
jumpToDate(target)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
onSettings = {
|
onSettings = {
|
||||||
onOpenSettings()
|
onOpenSettings()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -174,17 +197,17 @@ fun WeekScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
CalendarFabColumn(
|
||||||
visible = !isOnCurrentWeek,
|
todayVisible = !isOnCurrentWeek,
|
||||||
enter = scaleIn(),
|
todayText = stringResource(R.string.week_today_action),
|
||||||
exit = scaleOut(),
|
onToday = jumpToToday,
|
||||||
) {
|
onCreate = {
|
||||||
ExtendedFloatingActionButton(
|
// Anchor on today when it's in view, else the week's first day.
|
||||||
onClick = jumpToToday,
|
val today = Clock.System.now()
|
||||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
text = { Text(stringResource(R.string.week_today_action)) },
|
onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
WeekContent(
|
WeekContent(
|
||||||
@@ -195,6 +218,7 @@ fun WeekScreen(
|
|||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
@@ -212,6 +236,7 @@ private fun WeekContent(
|
|||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -283,6 +308,7 @@ private fun WeekContent(
|
|||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,6 +321,7 @@ private fun WeekSuccess(
|
|||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -308,7 +335,12 @@ private fun WeekSuccess(
|
|||||||
// Breathing room between the (colour-shifting) top section and the
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
// scrolling timeline below.
|
// scrolling timeline below.
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
|
Timeline(
|
||||||
|
state = state,
|
||||||
|
scrollState = scrollState,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +553,7 @@ private fun Timeline(
|
|||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -576,7 +609,9 @@ private fun Timeline(
|
|||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
blocks = state.timedByDay[day].orEmpty(),
|
blocks = state.timedByDay[day].orEmpty(),
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
date = day,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
@@ -592,9 +627,12 @@ private fun Timeline(
|
|||||||
private fun DayColumnCard(
|
private fun DayColumnCard(
|
||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
date: LocalDate,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||||
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.
|
||||||
@@ -604,7 +642,18 @@ private fun DayColumnCard(
|
|||||||
),
|
),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// Tap an empty slot to create an event there; taps on event
|
||||||
|
// blocks are consumed by their own handler first. Snaps to hour.
|
||||||
|
.pointerInput(date) {
|
||||||
|
detectTapGestures { offset ->
|
||||||
|
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
||||||
|
onCreateAt(date, hour * 60)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
val colWidth = maxWidth
|
val colWidth = maxWidth
|
||||||
blocks.forEach { block ->
|
blocks.forEach { block ->
|
||||||
val laneWidth = colWidth / block.laneCount
|
val laneWidth = colWidth / block.laneCount
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ class WeekViewModel @Inject constructor(
|
|||||||
_anchor.value = todayDate
|
_anchor.value = todayDate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jump to the week containing [date] (drawer jump-to-date). */
|
||||||
|
fun goToDate(date: LocalDate) {
|
||||||
|
_anchor.value = date
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
start: LocalDate,
|
start: LocalDate,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
@@ -176,6 +181,18 @@ internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
|||||||
|
|
||||||
/** 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) {
|
||||||
|
// All-day events live at UTC midnights with an exclusive end. Compare
|
||||||
|
// calendar dates in UTC and step the exclusive end back to the last
|
||||||
|
// covered day (mirroring the detail/edit views), so a one-day event
|
||||||
|
// covers exactly its single date. Slicing the day in the device zone
|
||||||
|
// would push the exclusive end a few hours into the next local day
|
||||||
|
// east of UTC, making the event leak onto day + 1.
|
||||||
|
val startDate = start.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endExclusive = end.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val lastDay = maxOf(startDate, endExclusive.minus(1, DateTimeUnit.DAY))
|
||||||
|
return day in startDate..lastDay
|
||||||
|
}
|
||||||
val dayStart = day.atStartOfDayIn(zone)
|
val dayStart = day.atStartOfDayIn(zone)
|
||||||
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||||
return start < dayEnd && end > dayStart
|
return start < dayEnd && end > dayStart
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/** How far ahead the agenda widget loads (a month of upcoming events). */
|
||||||
|
private const val AGENDA_WIDGET_DAYS = 30
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far either side of today the month widget pre-loads. The displayed month
|
||||||
|
* is chosen reactively in the composition, so one wide read covers ~13 months of
|
||||||
|
* prev/next navigation without re-querying on every arrow tap.
|
||||||
|
*/
|
||||||
|
private const val MONTH_WIDGET_RANGE_DAYS = 400
|
||||||
|
|
||||||
|
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
internal fun today(zone: TimeZone): LocalDate =
|
||||||
|
Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
internal fun Context.hasCalendarPermission(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Snapshot rendered by the agenda widget. */
|
||||||
|
sealed interface AgendaWidgetData {
|
||||||
|
/** Calendar permission not granted — the widget can't read events. */
|
||||||
|
data object NeedsPermission : AgendaWidgetData
|
||||||
|
data class Ready(val today: LocalDate, val days: List<AgendaDay>) : AgendaWidgetData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source data for the month widget: a wide window of instances plus the
|
||||||
|
* week-start preference and today. The widget computes each displayed month's
|
||||||
|
* grid from this in-memory list (via `layoutMonthWeeks`) as the user pages,
|
||||||
|
* so month navigation is pure recomposition — no reload, no flaky widget
|
||||||
|
* session restart.
|
||||||
|
*/
|
||||||
|
sealed interface MonthWidgetSource {
|
||||||
|
data object NeedsPermission : MonthWidgetSource
|
||||||
|
data class Ready(
|
||||||
|
val today: LocalDate,
|
||||||
|
val weekStart: DayOfWeek,
|
||||||
|
val instances: List<EventInstance>,
|
||||||
|
) : MonthWidgetSource
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-lived cache of the wide month window. Month navigation re-runs
|
||||||
|
* `provideGlance` (via `updateAll`), and re-querying ~13 months of instances on
|
||||||
|
* every arrow tap is what made paging feel sluggish — so we load once and reuse
|
||||||
|
* the same snapshot for every nearby month. Invalidated by
|
||||||
|
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
||||||
|
* receiver), and automatically when the day rolls over (the `today` guard).
|
||||||
|
*/
|
||||||
|
internal object MonthWidgetCache {
|
||||||
|
@Volatile
|
||||||
|
var data: MonthWidgetSource.Ready? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun invalidateMonthWidgetCache() {
|
||||||
|
MonthWidgetCache.data = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
||||||
|
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
||||||
|
* [first]-emitted snapshot already has hidden calendars filtered out.
|
||||||
|
*/
|
||||||
|
internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||||
|
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||||
|
val zone = systemZone()
|
||||||
|
val anchor = today(zone)
|
||||||
|
val repo = widgetEntryPoint().calendarRepository()
|
||||||
|
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
|
||||||
|
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
||||||
|
internal suspend fun Context.loadMonthWidgetSource(): MonthWidgetSource {
|
||||||
|
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
||||||
|
val zone = systemZone()
|
||||||
|
val anchor = today(zone)
|
||||||
|
// Reuse the cached window unless the day changed (then it's stale for "today").
|
||||||
|
MonthWidgetCache.data?.let { if (it.today == anchor) return it }
|
||||||
|
val ep = widgetEntryPoint()
|
||||||
|
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
|
val from = anchor.minus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||||
|
val to = anchor.plus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||||
|
val instances = ep.calendarRepository().instances(from..to).first()
|
||||||
|
return MonthWidgetSource.Ready(today = anchor, weekStart = weekStart, instances = instances)
|
||||||
|
.also { MonthWidgetCache.data = it }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hilt bridge for the Glance widgets. A [androidx.glance.appwidget.GlanceAppWidget]
|
||||||
|
* is instantiated by the framework, not by Hilt, so it can't take constructor
|
||||||
|
* injection. We instead reach the singleton graph through this entry point and
|
||||||
|
* read the same [CalendarRepository] / [SettingsPrefs] the app uses — so widget
|
||||||
|
* data (hidden-calendar filtering, week-start preference, …) matches the app
|
||||||
|
* one-to-one.
|
||||||
|
*/
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface WidgetEntryPoint {
|
||||||
|
fun calendarRepository(): CalendarRepository
|
||||||
|
fun settingsPrefs(): SettingsPrefs
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
|
||||||
|
EntryPointAccessors.fromApplication(applicationContext, WidgetEntryPoint::class.java)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.material3.ColorProviders
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand fallback for devices without Material You dynamic colour (API < 31).
|
||||||
|
* Reuses the exact same hand-tuned schemes as the in-app theme
|
||||||
|
* ([CalendulaLightFallback] / [CalendulaDarkFallback]) so a widget on an older
|
||||||
|
* device matches the app surface-for-surface.
|
||||||
|
*/
|
||||||
|
private val CalendulaGlanceColors = ColorProviders(
|
||||||
|
light = CalendulaLightFallback,
|
||||||
|
dark = CalendulaDarkFallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glance equivalent of `CalendulaTheme`. On API 31+ it follows the system's
|
||||||
|
* Material You palette (so the widget matches the home screen / the app's
|
||||||
|
* dynamic colour); below that it falls back to the brand scheme. Either way the
|
||||||
|
* widget draws only from M3 colour-role tokens (`GlanceTheme.colors.*`) — never
|
||||||
|
* a hardcoded colour — so it tracks light/dark automatically.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
|
||||||
|
val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
GlanceTheme.colors
|
||||||
|
} else {
|
||||||
|
CalendulaGlanceColors
|
||||||
|
}
|
||||||
|
GlanceTheme(colors = colors, content = content)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws both home-screen widgets when their data goes stale. Triggered by:
|
||||||
|
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
|
||||||
|
* so it covers both the app's own writes and external sync.
|
||||||
|
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
|
||||||
|
* and the upcoming window roll over at midnight / on a clock change.
|
||||||
|
*
|
||||||
|
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
|
||||||
|
* XML, and the month widget's refresh button forces an immediate redraw.
|
||||||
|
*/
|
||||||
|
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val pending = goAsync()
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
// Calendar data may have changed (sync / our own write) — drop the cached
|
||||||
|
// month window so the widgets reload fresh. Month paging does NOT call
|
||||||
|
// this, so arrow taps stay instant.
|
||||||
|
invalidateMonthWidgetCache()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
AgendaWidget().updateAll(appContext)
|
||||||
|
MonthWidget().updateAll(appContext)
|
||||||
|
} finally {
|
||||||
|
pending.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.glance.ColorFilter
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.Image
|
||||||
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.action.ActionParameters
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
|
import androidx.glance.appwidget.cornerRadius
|
||||||
|
import androidx.glance.appwidget.lazy.LazyColumn
|
||||||
|
import androidx.glance.appwidget.lazy.items
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import androidx.glance.background
|
||||||
|
import androidx.glance.layout.Alignment
|
||||||
|
import androidx.glance.layout.Box
|
||||||
|
import androidx.glance.layout.Column
|
||||||
|
import androidx.glance.layout.Row
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.layout.fillMaxWidth
|
||||||
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.layout.size
|
||||||
|
import androidx.glance.layout.width
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||||
|
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||||
|
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||||
|
*/
|
||||||
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val data = context.loadAgendaWidgetData()
|
||||||
|
val dark = (context.resources.configuration.uiMode and
|
||||||
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
provideContent {
|
||||||
|
CalendulaGlanceTheme {
|
||||||
|
AgendaWidgetBody(data = data, dark = dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-reads the calendar and redraws the widget (header refresh button). */
|
||||||
|
class RefreshAgendaAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
AgendaWidget().updateAll(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||||
|
private sealed interface AgendaRow {
|
||||||
|
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||||
|
data class Event(val event: EventInstance) : AgendaRow
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(GlanceTheme.colors.surface)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
AgendaHeader()
|
||||||
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
|
when (data) {
|
||||||
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
||||||
|
is AgendaWidgetData.Ready ->
|
||||||
|
if (data.days.isEmpty()) {
|
||||||
|
WidgetMessage(R.string.agenda_empty_title)
|
||||||
|
} else {
|
||||||
|
val rows = buildList {
|
||||||
|
data.days.forEach { day ->
|
||||||
|
add(AgendaRow.Header(day.date, data.today))
|
||||||
|
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
|
items(rows.size) { index ->
|
||||||
|
when (val row = rows[index]) {
|
||||||
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||||
|
is AgendaRow.Event -> EventRow(row.event, dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaHeader() {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.widget_agenda_title),
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.primary,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier.defaultWeight(),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
resId = R.drawable.ic_widget_refresh,
|
||||||
|
contentDescription = context.getString(R.string.widget_refresh),
|
||||||
|
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
resId = R.drawable.ic_widget_add,
|
||||||
|
contentDescription = context.getString(R.string.widget_new_event),
|
||||||
|
onClick = GlanceModifier.clickable(
|
||||||
|
actionStartActivity(
|
||||||
|
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(resId),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||||
|
modifier = GlanceModifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
Text(
|
||||||
|
text = agendaDayLabel(context, date, today),
|
||||||
|
style = TextStyle(
|
||||||
|
color = if (date == today) GlanceTheme.colors.primary
|
||||||
|
else GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
||||||
|
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventRow(event: EventInstance, dark: Boolean) {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||||
|
.clickable(
|
||||||
|
actionStartActivity(
|
||||||
|
MainActivity.eventDetailIntent(
|
||||||
|
context = context,
|
||||||
|
eventId = event.eventId,
|
||||||
|
beginMillis = event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = event.end.toEpochMilliseconds(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.width(5.dp)
|
||||||
|
.height(36.dp)
|
||||||
|
.cornerRadius(3.dp)
|
||||||
|
.background(pastelize(event.color, dark)),
|
||||||
|
) {}
|
||||||
|
Spacer(GlanceModifier.width(10.dp))
|
||||||
|
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = eventTimeSummary(context, event),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WidgetMessage(resId: Int) {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(resId),
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zone(): TimeZone = systemZone()
|
||||||
|
|
||||||
|
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
|
||||||
|
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||||
|
val relative = when (date) {
|
||||||
|
today -> context.getString(R.string.agenda_header_today)
|
||||||
|
today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
|
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
val formatted = "$weekday, ${date.day} $monthName"
|
||||||
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun eventTimeSummary(context: Context, event: EventInstance): String {
|
||||||
|
val time = if (event.isAllDay) {
|
||||||
|
context.getString(R.string.event_detail_all_day)
|
||||||
|
} else {
|
||||||
|
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||||
|
}
|
||||||
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
|
return if (location != null) "$time · $location" else time
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(instant: Instant): String {
|
||||||
|
val t = instant.toLocalDateTime(zone()).time
|
||||||
|
return "%02d:%02d".format(t.hour, t.minute)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
||||||
|
* `appwidget_info_agenda` provider metadata; delegates all rendering to
|
||||||
|
* [AgendaWidget].
|
||||||
|
*/
|
||||||
|
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.glance.ColorFilter
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.Image
|
||||||
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalContext
|
||||||
|
import androidx.glance.LocalSize
|
||||||
|
import androidx.glance.action.ActionParameters
|
||||||
|
import androidx.glance.action.actionParametersOf
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
|
import androidx.glance.appwidget.cornerRadius
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import androidx.glance.background
|
||||||
|
import androidx.glance.currentState
|
||||||
|
import androidx.glance.layout.Alignment
|
||||||
|
import androidx.glance.layout.Box
|
||||||
|
import androidx.glance.layout.Column
|
||||||
|
import androidx.glance.layout.Row
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.layout.fillMaxWidth
|
||||||
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.layout.size
|
||||||
|
import androidx.glance.layout.width
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextAlign
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import androidx.glance.unit.ColorProvider
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
||||||
|
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
||||||
|
|
||||||
|
/** Event rows (lanes) shown per week before the rest collapse into "+N". */
|
||||||
|
private const val MAX_LANES = 3
|
||||||
|
private val LANE_HEIGHT = 14.dp
|
||||||
|
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||||
|
private val GRID_HPADDING = 8.dp
|
||||||
|
|
||||||
|
/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */
|
||||||
|
private val EventInk = ColorProvider(Color(0xDE000000))
|
||||||
|
|
||||||
|
private fun currentMonthIndex(zone: TimeZone): Int {
|
||||||
|
val t = today(zone)
|
||||||
|
return t.year * 12 + t.month.ordinal
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun yearMonthOf(index: Int): YearMonth =
|
||||||
|
YearMonth(index / 12, Month(index % 12 + 1))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Month-grid widget: a 6×7 calendar with today highlighted, connected multi-day
|
||||||
|
* event bars and titled single-day pills (the in-app lane layout via
|
||||||
|
* [layoutMonthWeeks]), and prev/next/today navigation.
|
||||||
|
*
|
||||||
|
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
|
||||||
|
* multi-day span renders as a single Box spanning its columns — connected, no
|
||||||
|
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
||||||
|
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||||
|
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||||
|
*/
|
||||||
|
class MonthWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val source = context.loadMonthWidgetSource()
|
||||||
|
val dark = (context.resources.configuration.uiMode and
|
||||||
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
provideContent {
|
||||||
|
CalendulaGlanceTheme {
|
||||||
|
MonthWidgetBody(source = source, dark = dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step the displayed month by the `delta` action parameter (±1). */
|
||||||
|
class ShiftMonthAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
val delta = parameters[deltaKey] ?: 0
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
||||||
|
prefs[MONTH_INDEX_KEY] = cur + delta
|
||||||
|
}
|
||||||
|
MonthWidget().updateAll(context.applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val deltaKey = ActionParameters.Key<Int>("delta")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump the displayed month back to the current month. */
|
||||||
|
class ResetMonthAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
|
||||||
|
MonthWidget().updateAll(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(GlanceTheme.colors.surface)
|
||||||
|
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
when (source) {
|
||||||
|
MonthWidgetSource.NeedsPermission -> {
|
||||||
|
MonthHeader(label = "Calendula")
|
||||||
|
PermissionMessage()
|
||||||
|
}
|
||||||
|
is MonthWidgetSource.Ready -> {
|
||||||
|
val zone = systemZone()
|
||||||
|
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
||||||
|
val ym = yearMonthOf(index)
|
||||||
|
// Column width from the live widget size, minus our H padding.
|
||||||
|
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||||
|
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||||
|
|
||||||
|
MonthHeader(label = monthLabel(ym))
|
||||||
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
|
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||||
|
weeks.forEach { week ->
|
||||||
|
WeekRow(
|
||||||
|
week = week,
|
||||||
|
currentMonth = ym.month,
|
||||||
|
today = source.today,
|
||||||
|
dark = dark,
|
||||||
|
colW = colW,
|
||||||
|
modifier = GlanceModifier.defaultWeight(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonthHeader(label: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
HeaderIcon(
|
||||||
|
resId = R.drawable.ic_widget_chevron_left,
|
||||||
|
contentDescription = context.getString(R.string.widget_prev_month),
|
||||||
|
onClick = GlanceModifier.clickable(
|
||||||
|
actionRunCallback<ShiftMonthAction>(
|
||||||
|
actionParametersOf(ShiftMonthAction.deltaKey to -1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.primary,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.defaultWeight()
|
||||||
|
.clickable(actionRunCallback<ResetMonthAction>()),
|
||||||
|
)
|
||||||
|
HeaderIcon(
|
||||||
|
resId = R.drawable.ic_widget_today,
|
||||||
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
|
onClick = GlanceModifier.clickable(
|
||||||
|
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
HeaderIcon(
|
||||||
|
resId = R.drawable.ic_widget_chevron_right,
|
||||||
|
contentDescription = context.getString(R.string.widget_next_month),
|
||||||
|
onClick = GlanceModifier.clickable(
|
||||||
|
actionRunCallback<ShiftMonthAction>(
|
||||||
|
actionParametersOf(ShiftMonthAction.deltaKey to 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(resId),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||||
|
modifier = GlanceModifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
weekdayNarrowNames(weekStart).forEach { name ->
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier.width(colW),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow weekday initials starting at [weekStart], in the device locale.
|
||||||
|
* Computed outside the composable so the locale read stays observable-safe. */
|
||||||
|
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
return (0 until 7).map { i ->
|
||||||
|
java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1)
|
||||||
|
.getDisplayName(JavaTextStyle.NARROW, locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekRow(
|
||||||
|
week: MonthWeek,
|
||||||
|
currentMonth: Month,
|
||||||
|
today: LocalDate,
|
||||||
|
dark: Boolean,
|
||||||
|
colW: Dp,
|
||||||
|
modifier: GlanceModifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
// Day numbers.
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
week.days.forEach { date ->
|
||||||
|
DayNumber(
|
||||||
|
date = date,
|
||||||
|
isToday = date == today,
|
||||||
|
inMonth = date.month == currentMonth,
|
||||||
|
colW = colW,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
|
// One lane row per event row. A multi-day span is a single Box spanning
|
||||||
|
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||||
|
repeat(MAX_LANES) { lane ->
|
||||||
|
LaneRow(week = week, lane = lane, dark = dark, colW = colW)
|
||||||
|
Spacer(GlanceModifier.height(1.dp))
|
||||||
|
}
|
||||||
|
OverflowRow(week = week, colW = colW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.size(DAY_NUMBER_HEIGHT)
|
||||||
|
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date.day.toString(),
|
||||||
|
style = TextStyle(
|
||||||
|
color = when {
|
||||||
|
isToday -> GlanceTheme.colors.onPrimary
|
||||||
|
inMonth -> GlanceTheme.colors.onSurface
|
||||||
|
else -> GlanceTheme.colors.onSurfaceVariant
|
||||||
|
},
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
var col = 0
|
||||||
|
while (col < 7) {
|
||||||
|
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||||
|
if (span != null) {
|
||||||
|
val cols = span.endCol - col + 1
|
||||||
|
SpanBar(event = span.event, dark = dark, width = colW * cols)
|
||||||
|
col = span.endCol + 1
|
||||||
|
} else {
|
||||||
|
val timed = timedEventAt(week, lane, col, week.days[col])
|
||||||
|
if (timed != null) {
|
||||||
|
SpanBar(event = timed, dark = dark, width = colW)
|
||||||
|
} else {
|
||||||
|
Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {}
|
||||||
|
}
|
||||||
|
col += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
||||||
|
@Composable
|
||||||
|
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.cornerRadius(4.dp)
|
||||||
|
.background(pastelize(event.color, dark)),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = EventInk, fontSize = 9.sp),
|
||||||
|
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
week.days.forEachIndexed { col, date ->
|
||||||
|
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||||
|
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||||
|
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||||
|
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
if (hidden > 0) {
|
||||||
|
Text(
|
||||||
|
text = "+$hidden",
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||||
|
modifier = GlanceModifier.padding(start = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The timed single-day event that fills lane [lane] on day [col], if any. */
|
||||||
|
private fun timedEventAt(week: MonthWeek, lane: Int, col: Int, date: LocalDate): EventInstance? {
|
||||||
|
val occupied = week.spans
|
||||||
|
.filter { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||||
|
.map { it.lane }
|
||||||
|
.toSet()
|
||||||
|
val freeSlots = (0 until MAX_LANES).filter { it !in occupied }
|
||||||
|
val timed = week.timedByDay[date].orEmpty()
|
||||||
|
return timed.getOrNull(freeSlots.indexOf(lane))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionMessage() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.widget_needs_permission),
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun monthLabel(month: YearMonth): String {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val name = java.time.Month.of(month.month.ordinal + 1)
|
||||||
|
.getDisplayName(JavaTextStyle.FULL, locale)
|
||||||
|
return "$name ${month.year}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-facing receiver for the month widget. Declared in the manifest with the
|
||||||
|
* `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget].
|
||||||
|
*/
|
||||||
|
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
|
||||||
|
}
|
||||||
16
app/src/main/res/drawable/ic_gitea.xml
Normal file
16
app/src/main/res/drawable/ic_gitea.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Gitea brand mark, used on the "Source" button in Settings → About.
|
||||||
|
Single-path logo from Simple Icons (https://simpleicons.org, CC0),
|
||||||
|
pathData kept verbatim so Android's PathParser reads the arc flags.
|
||||||
|
fillColor is a placeholder; the Compose Icon recolours it via tint.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z" />
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_notification.xml
Normal file
12
app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome status-bar mark: Material "event" calendar glyph. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19zM7,10h5v5H7V10z" />
|
||||||
|
</vector>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user