Compare commits
170 Commits
v2.7.0
...
86d4e11584
| Author | SHA1 | Date | |
|---|---|---|---|
| 86d4e11584 | |||
| 67a07a9346 | |||
| b8a420bdab | |||
| 442697d57c | |||
| 8e3a17fdc3 | |||
| 4e125e58d5 | |||
| 52816b327a | |||
| cbb241bc4d | |||
| 0fd579b19b | |||
| 030c3e6ce1 | |||
| e67960d352 | |||
| 22bbfef3e2 | |||
| 4da9abe05b | |||
| 4540faf2a0 | |||
| 3aecb19a0c | |||
| f7c0b8a957 | |||
| 9a8d9b2177 | |||
| 93ca38d974 | |||
| a38093cae3 | |||
| 225f4c3491 | |||
| 5d887524b1 | |||
| 048f407ba1 | |||
| a89560953d | |||
| 4e498de051 | |||
| f2fb3d6279 | |||
| c2d88e744e | |||
| 2ae4c818ba | |||
| 25ff7ce7ca | |||
| fb2a40114a | |||
| e1f79df67a | |||
| e258a4a7f2 | |||
| 3f27a47d1e | |||
| feb87a1e73 | |||
| 28e464a616 | |||
| 93df6d6d62 | |||
| 49ddb9437b | |||
| e1cf00999d | |||
| 2d0d707a7d | |||
| de1fe31223 | |||
| 273cfce969 | |||
| b6a45b7264 | |||
| c7ef52e426 | |||
| b1151b6516 | |||
| 1664625bc5 | |||
| e8b87446f7 | |||
| dc478516cd | |||
| 8945b877b2 | |||
| 1424f0ffc0 | |||
| 548e581554 | |||
| a6ae4d7e1d | |||
| 614f4f2d75 | |||
| e6736b049a | |||
| 301904be6d | |||
| 3ca7200624 | |||
| c30a153552 | |||
| bb92d02000 | |||
| cd703e9d54 | |||
| f27b0e269d | |||
| 0f31981d26 | |||
| 6471c48528 | |||
| f8a2569831 | |||
| 6ba7f38887 | |||
| 366059b012 | |||
| 3cf1439850 | |||
| b2bfdc0f42 | |||
| 7c9830deda | |||
| 5e98059388 | |||
| 53c0482760 | |||
| f8efa4d3e4 | |||
| 93cc264fbf | |||
| 585cdac4ab | |||
| 9994e8c534 | |||
| 766c2ffcf8 | |||
| add88fbadf | |||
| 09a1aecb76 | |||
| c91608e48a | |||
| 47e8036000 | |||
| 07a8faf2df | |||
| 54b059e744 | |||
| d0c01ab8c4 | |||
| bf10fee19f | |||
| 2e88c40eff | |||
| 26e538a9ba | |||
| 814daa8151 | |||
| 450fc7f7af | |||
| be6d10d126 | |||
| a1e8c05c7b | |||
| cecd0c2234 | |||
| 43eb5e23e8 | |||
| 946addbbcd | |||
| 8a63aa86a7 | |||
| 0ddbbb11e8 | |||
| 575b1c7eb7 | |||
| dd61e0ed37 | |||
| 376ad5f394 | |||
| 8a9e6f0ee8 | |||
| 913b49e962 | |||
| 96f4d4dc84 | |||
| f4789c535e | |||
| 00220c5f0e | |||
| b7a5f16c84 | |||
| 2c38912834 | |||
| a9c67a5827 | |||
| a69b161178 | |||
| 024deadf78 | |||
| 599c3c03e7 | |||
| 5d48c90408 | |||
| c6d15d0118 | |||
| 79e083b4d4 | |||
| 119a8afe8e | |||
| b537e143f8 | |||
| faf2f27eda | |||
| 3d8e3ca69e | |||
| 3b5c0be765 | |||
| 013efef29e | |||
| 5457a34282 | |||
| 82e93fda4c | |||
| 871cc34cab | |||
| 5b99da32b0 | |||
| b8a7191fbe | |||
| 6b0eb48056 | |||
| 572a4734ea | |||
| 7f8a9069c0 | |||
| 22c4eded96 | |||
| 9839f9cd38 | |||
| c0e27133b4 | |||
| a4e0ec3fde | |||
| ac17343747 | |||
| b0f34ff18a | |||
| ab8a9e3401 | |||
| 92ec718b28 | |||
| 4ad1c09f8f | |||
| 5e8cb686e9 | |||
| 62ebd48e3c | |||
| c3dbcca989 | |||
| 37452be3bd | |||
| 006cce96a9 | |||
| 88d770bebe | |||
| b6bee55fc6 | |||
| 7ab277b434 | |||
| d0b6823385 | |||
| 5c80f99179 | |||
| 0875e69c2d | |||
| ac572da40a | |||
| f27811d215 | |||
| 36e2199dff | |||
| eb51175ceb | |||
| bc59200f77 | |||
| ed6b0fa89f | |||
| c503199e06 | |||
| f3f155b5ff | |||
| 294524a943 | |||
| ef6f1891b3 | |||
| 9fb3c780f1 | |||
| 8a80478555 | |||
| df203c0b6b | |||
| 0c95479051 | |||
| 221313178f | |||
| 297a2350e3 | |||
| 997ee44792 | |||
| 81baadfaf3 | |||
| 35022267dc | |||
| 588e024036 | |||
| eeef089e4a | |||
| 9023899ddb | |||
| 5ab3344f8c | |||
| 2431abe912 | |||
| 2f153fef56 | |||
| 701077f25b | |||
| 290a905f8b |
23
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
23
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Something doesn't work the way it should
|
||||
title: ""
|
||||
labels:
|
||||
- bug
|
||||
---
|
||||
|
||||
### What happened
|
||||
|
||||
|
||||
### What you expected
|
||||
|
||||
|
||||
### Steps to reproduce
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Environment
|
||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||
- Android version:
|
||||
- Device:
|
||||
27
.gitea/ISSUE_TEMPLATE/crash_report.md
Normal file
27
.gitea/ISSUE_TEMPLATE/crash_report.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Crash report
|
||||
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
|
||||
title: "Crash: "
|
||||
labels:
|
||||
- bug
|
||||
- crash
|
||||
- priority:high
|
||||
---
|
||||
|
||||
<!--
|
||||
Thanks for reporting a crash in Calendula!
|
||||
|
||||
If the app prefilled this for you, the crash report is already below — just add
|
||||
what you were doing and submit. Otherwise, paste the report from your clipboard
|
||||
into the code block. The report contains only app/Android/device versions and the
|
||||
stack trace — no personal data or calendar content.
|
||||
-->
|
||||
|
||||
### What happened
|
||||
|
||||
|
||||
### Crash report
|
||||
|
||||
```
|
||||
(paste the crash report here)
|
||||
```
|
||||
16
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
16
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea or improvement
|
||||
title: ""
|
||||
labels:
|
||||
- feat
|
||||
---
|
||||
|
||||
### What would you like Calendula to do?
|
||||
|
||||
|
||||
### Why — what problem does it solve?
|
||||
|
||||
|
||||
### Anything else
|
||||
<!-- mockups, examples from other apps, alternatives you considered -->
|
||||
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask how something works or get help using Calendula
|
||||
title: ""
|
||||
labels:
|
||||
- question
|
||||
---
|
||||
|
||||
### Your question
|
||||
|
||||
|
||||
### What you've tried
|
||||
<!-- so far, if anything -->
|
||||
|
||||
|
||||
### Context
|
||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||
- Android version:
|
||||
- Device:
|
||||
@@ -1,18 +1,23 @@
|
||||
name: CI
|
||||
|
||||
# One gate per pull request. Branch pushes no longer trigger CI on their own,
|
||||
# so a change is built once on its PR (covering feature -> release/* and
|
||||
# release/* -> main) instead of once per push and again on the merge to main.
|
||||
# The merge itself is handled by release.yaml, which only does heavy work when
|
||||
# the merge actually cuts a release.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags-ignore:
|
||||
- '**'
|
||||
pull_request:
|
||||
|
||||
# Cancel superseded runs on the same branch.
|
||||
# Cancel superseded runs for the same PR.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Single job named `ci` so the required "CI" status check is always reported,
|
||||
# even for docs-only PRs: those just skip the Android build and the job still
|
||||
# succeeds (fast green check) instead of being filtered out and leaving the
|
||||
# required check pending forever.
|
||||
ci:
|
||||
runs-on: docker
|
||||
env:
|
||||
@@ -21,14 +26,53 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history so the base..HEAD diff below has a merge-base.
|
||||
fetch-depth: 0
|
||||
|
||||
# Cheap, always-on guard: the release build must stay reproducible for the
|
||||
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
||||
# change scope so a regression can't slip through on a "docs-only" PR.
|
||||
- name: Reproducible-release invariant
|
||||
run: bash scripts/check_reproducible_release.sh
|
||||
|
||||
# Decide whether anything that affects the app build changed. Docs,
|
||||
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
|
||||
# Gradle work below but still report a green `ci`.
|
||||
- name: Classify change scope
|
||||
id: scope
|
||||
run: |
|
||||
set -e
|
||||
BASE="${{ github.base_ref }}"
|
||||
# Full (not --depth=1) base fetch so the merge-base is present even when
|
||||
# the PR branch forked several commits back; a shallow tip has no merge
|
||||
# base with a divergent branch and `git diff base...HEAD` aborts.
|
||||
git fetch --no-tags origin "$BASE"
|
||||
MB=$(git merge-base "origin/$BASE" HEAD 2>/dev/null || true)
|
||||
if [ -z "$MB" ]; then
|
||||
# No common ancestor available — don't risk skipping the build.
|
||||
echo "No merge base with origin/$BASE — running the full build to be safe."
|
||||
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
CHANGED=$(git diff --name-only "$MB" HEAD)
|
||||
echo "Changed files:"; echo "$CHANGED"
|
||||
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
|
||||
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Docs/metadata-only change — skipping the Android build."
|
||||
fi
|
||||
|
||||
- name: Setup Java
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
# Default ("tools platform-tools") drags in the Android Emulator
|
||||
@@ -36,12 +80,14 @@ jobs:
|
||||
packages: ''
|
||||
|
||||
- name: Setup Android SDK cache
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /opt/android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||
|
||||
- name: Install Android SDK packages
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager \
|
||||
@@ -50,6 +96,7 @@ jobs:
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Setup Gradle cache
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
@@ -60,21 +107,25 @@ jobs:
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
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)
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew lintDebug
|
||||
|
||||
- name: Unit tests
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Assemble debug APK
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Trivy filesystem scan
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: |
|
||||
set -e
|
||||
SUDO=""
|
||||
|
||||
@@ -1,75 +1,84 @@
|
||||
name: Release — F-Droid repo + Gitea release
|
||||
|
||||
# A release is cut by merging a release branch into main with a bumped
|
||||
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
|
||||
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
|
||||
# it to the F-Droid repo, and only then creates the vX.Y.Z tag + Gitea release
|
||||
# itself — the tag is an output of the pipeline, not its trigger. Ordinary
|
||||
# merges (no version bump) fall through `detect` and do nothing.
|
||||
#
|
||||
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
|
||||
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
|
||||
# without building an APK or creating a release. Used for key rotation / repo
|
||||
# recovery.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
# Cheap gate: resolve the version from the committed build.gradle and decide
|
||||
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
||||
# heavy job from running on every merge to main.
|
||||
detect:
|
||||
runs-on: docker
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
outputs:
|
||||
is_release: ${{ steps.v.outputs.is_release }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
version_code: ${{ steps.v.outputs.version_code }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
- name: Setup Android SDK cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /opt/android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||
|
||||
- name: Install Android SDK packages
|
||||
- name: Resolve version and whether it is a new release
|
||||
id: v
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-37.0" \
|
||||
"build-tools;36.0.0"
|
||||
set -e
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved version $VERSION (code $VERSION_CODE)"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "Manual dispatch — re-sign path, not a release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# A tag for this version already existing means the release shipped on
|
||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "Tag v$VERSION already exists — nothing to release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No tag for v$VERSION yet — cutting the release."
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- 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
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
# Lint already enforced on every push to main via ci.yaml.
|
||||
# Release sanity only re-runs tests + a debug build to catch
|
||||
# any tag-resolved drift (e.g. version code substitution issues).
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Assemble debug APK (sanity)
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
build-and-deploy:
|
||||
needs: ci
|
||||
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||
# re-uploads the existing index (recovery path).
|
||||
release:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: docker
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
VERSION: ${{ needs.detect.outputs.version }}
|
||||
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||
IS_RELEASE: ${{ needs.detect.outputs.is_release }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -121,31 +130,26 @@ jobs:
|
||||
$SUDO apk add --no-cache jq
|
||||
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
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
# The committed versionName is the source of truth. Pin versionCode to the
|
||||
# value derived from it so the published APK's code is always
|
||||
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
||||
- name: Pin versionCode to versionName
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: |
|
||||
set -e
|
||||
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||||
VERSION="${RAW_TAG#v}"
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||
PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "Version: $VERSION, VersionCode: $VERSION_CODE"
|
||||
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
|
||||
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" 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"
|
||||
|
||||
# Test the exact commit being shipped (only on a real release).
|
||||
- name: Unit tests
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Setup Android Keystore
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: env.IS_RELEASE == 'true'
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
@@ -160,11 +164,8 @@ jobs:
|
||||
storeFile=upload-keystore.jks
|
||||
EOF
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Build release APK
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Setup F-Droid Server Tools
|
||||
@@ -202,8 +203,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
# Fail loudly if the repo key is not configured. NEVER auto-generate
|
||||
# one: a fresh key changes the repo fingerprint and breaks every
|
||||
# user's pinned repo. (Replaces the old `fdroid update --create-key`
|
||||
# path, which silently rotated the key on a wiped server.)
|
||||
# user's pinned repo.
|
||||
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
|
||||
@@ -216,42 +216,33 @@ jobs:
|
||||
mkdir -p fdroid/repo/icons
|
||||
|
||||
- name: Copy new APK to repo
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p fdroid/repo
|
||||
REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||||
SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')"
|
||||
if [ -z "$SAFE_REF_NAME" ]; then
|
||||
SAFE_REF_NAME="${GITHUB_SHA:-manual}"
|
||||
fi
|
||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_${SAFE_REF_NAME}.apk"
|
||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
||||
|
||||
- name: Copy metadata to F-Droid repo
|
||||
# Per-version "What's New": ensure this version's changelog exists in the
|
||||
# fastlane tree (committed at release-cut time for the official repo; this
|
||||
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
|
||||
# the commit having happened). The transform below then carries it across.
|
||||
- name: Ensure this version's changelog is in the fastlane tree
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: bash scripts/sync_changelog_to_fastlane.sh
|
||||
|
||||
- name: Build F-Droid metadata from fastlane (single source of truth)
|
||||
run: |
|
||||
mkdir -p 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"
|
||||
# App-level control file (Categories/License/links) for the self-hosted
|
||||
# repo's `fdroid update`.
|
||||
cp fdroid-metadata/de.jeanlucmakiola.calendula.yml fdroid/metadata/
|
||||
# Localized text + graphics + per-version changelogs come from the SAME
|
||||
# fastlane tree the official F-Droid repo harvests from source,
|
||||
# transformed into the F-Droid repo "localized" layout. One source of
|
||||
# truth, both channels.
|
||||
bash scripts/fastlane_to_fdroid_localized.sh \
|
||||
fastlane/metadata/android \
|
||||
fdroid/metadata/de.jeanlucmakiola.calendula
|
||||
|
||||
- name: Generate F-Droid Index
|
||||
run: |
|
||||
@@ -272,97 +263,46 @@ jobs:
|
||||
SFTP
|
||||
# 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.
|
||||
# releases. keystore.p12 and config.yml are NEVER uploaded.
|
||||
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
|
||||
# The APK is published and the index re-signed — now record the release.
|
||||
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
||||
# this commit, so the tag only ever marks a fully-shipped release (and a
|
||||
# failure before here leaves no tag, so re-running the workflow retries).
|
||||
- name: Create tag + Gitea release
|
||||
if: env.IS_RELEASE == 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
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.
|
||||
TAG="v$VERSION"
|
||||
# Notes = this version's CHANGELOG section.
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > release-notes.md
|
||||
# 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
|
||||
python3 - "$TAG" "$SHA" <<'PY' > payload.json
|
||||
import json, sys
|
||||
print(json.dumps({
|
||||
"tag_name": sys.argv[1],
|
||||
"target_commitish": sys.argv[2],
|
||||
"name": sys.argv[1],
|
||||
"body": open("release-notes.md").read(),
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))
|
||||
PY
|
||||
# Upsert: 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.
|
||||
# Upsert (re-run safe): PATCH if a release for the tag already exists,
|
||||
# else POST a new one (which also creates the tag at target_commitish).
|
||||
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
||||
ID=$(python3 -c "import json,sys; d=json.load(open('existing.json')); print(d.get('id',''))" 2>/dev/null || true)
|
||||
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
|
||||
if [ -n "$ID" ]; then
|
||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
@@ -376,6 +316,33 @@ jobs:
|
||||
fi
|
||||
cat response.json
|
||||
if [ "$CODE" != "$OK" ]; then
|
||||
echo "Release upsert failed with HTTP $CODE (expected $OK)"
|
||||
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Created/updated release $TAG at $SHA"
|
||||
|
||||
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
|
||||
# Attached to the release (it's not an APK, so it fits the no-binaries
|
||||
# rule). Best-effort: never fail a release over it.
|
||||
- name: Attach R8 mapping to Gitea release
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
run: |
|
||||
set -e
|
||||
MAP="app/build/outputs/mapping/release/mapping.txt"
|
||||
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
|
||||
TAG="v$VERSION"
|
||||
ASSET="mapping-${VERSION}.txt.gz"
|
||||
gzip -c "$MAP" > "/tmp/$ASSET"
|
||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
|
||||
# Replace any prior asset of the same name (re-run safe).
|
||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
|
||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$ASSET" \
|
||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
||||
|
||||
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
|
||||
@@ -4,11 +4,7 @@ name: Translations
|
||||
# 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:
|
||||
- '**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'app/src/main/res/values*/strings.xml'
|
||||
- 'app/src/main/res/xml/locales_config.xml'
|
||||
|
||||
@@ -230,44 +230,59 @@ pass on the existing controls; new toggles ride in with their own features.
|
||||
|
||||
**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
|
||||
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; ~~quick-settings tile~~ *(done, v2.8.0 — "New event" QS tile)*
|
||||
|
||||
**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
|
||||
10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — 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)
|
||||
four surfaces all shipped: single-event share + whole-calendar backup
|
||||
(export), open-`.ics`→form + whole-calendar bulk import (import). Closed the
|
||||
device-local-calendar data-loss gap (#10/#11 merged here). Built as two
|
||||
sequential branches: `feat/ics-export` (write side + UID-on-create precursor)
|
||||
then `feat/ics-import` (parser, restore, dedup by UID). Import is
|
||||
liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID`
|
||||
/ guest lists it can't model. Plans:
|
||||
`docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`.
|
||||
11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)* —
|
||||
followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager
|
||||
decision (snooze must re-fire an alarm).
|
||||
|
||||
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
|
||||
12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
|
||||
MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
|
||||
the event model: attendees were already *read* (queried, mapped, shown on the
|
||||
detail screen since v0.6) and are now *writable* from the form. An attendees
|
||||
section on `EventEditScreen` / `EventForm` adds by typed email **or** the
|
||||
no-permission contact picker, edits/removes rows, and sets role (required /
|
||||
optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
|
||||
update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
|
||||
caveat was **settled record-only** (Calendula never sends invites; the backend
|
||||
decides delivery) — full sketch + decision in "Attendee editing" under Locations
|
||||
& People below.
|
||||
|
||||
**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
|
||||
- Locations & People — the no-permission contact pickers already shipped (location v2.8.0, attendee email v2.8.0); what remains gated is 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.
|
||||
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
|
||||
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.
|
||||
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
|
||||
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
|
||||
and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
|
||||
read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers —
|
||||
full-text search, the "New event" Quick Settings tile, and the now-line all
|
||||
shipped there. **No tier work is currently committed as next;** the remaining
|
||||
candidates are unscheduled theme-group ideas (pinch-to-zoom, tablet/foldable,
|
||||
accessibility pass) plus the gated go/no-go items.
|
||||
|
||||
## Navigation & views
|
||||
|
||||
@@ -277,26 +292,22 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact.
|
||||
- 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.
|
||||
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
|
||||
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
|
||||
the view and shrinks the day cells; the badge stays week-view-only.
|
||||
- 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.
|
||||
- ~~Full-text search~~ **shipped v2.8.0** — 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.
|
||||
- ~~**Per-event color**~~ *(shipped v2.4.0)* — `EVENT_COLOR` / `EVENT_COLOR_KEY`
|
||||
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
|
||||
picker in the form, falling back to the calendar color when unset. Reused the
|
||||
color-picker component and palette plumbing from local calendar management and
|
||||
finished the create/edit theme.
|
||||
|
||||
## Calendars & accounts
|
||||
|
||||
@@ -319,15 +330,69 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact.
|
||||
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.
|
||||
- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0** —
|
||||
device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no
|
||||
backup. Settings → Calendars → Export writes every event to a user-chosen
|
||||
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
|
||||
UID). Closed the silent data-loss gap.
|
||||
|
||||
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
|
||||
### Disable a calendar in-app *(captured 2026-06-25)*
|
||||
|
||||
A second, heavier visibility level **above** the existing per-view filter. Today
|
||||
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
|
||||
a calendar's *events* from the month/week/day/agenda views — the calendar itself
|
||||
still clutters the drawer filter list, the event-form calendar picker, and the
|
||||
import target picker. "Disable" removes a calendar from the app's surfaces
|
||||
entirely; "hide" stays the lightweight, frequently-toggled control.
|
||||
|
||||
**Two-level model (both kept):**
|
||||
- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the
|
||||
drawer. Toggles events on/off in the views; the calendar stays listed
|
||||
everywhere. Operates only over the *enabled* calendars.
|
||||
- **Disabled** (new) — the calendar is gone from the app: not in the drawer
|
||||
filter list, not in the event-form picker, not in the import picker, and its
|
||||
events never appear (it's gated out of `instances()` like a hidden one). It
|
||||
remains visible **only** in Settings → Calendars, where the enable/disable
|
||||
toggle lives, so it can be brought back.
|
||||
|
||||
**Storage — app-side (DataStore), mirrors the hidden set.** Add
|
||||
`disabledCalendarIds: Set<Long>` + `setDisabledCalendarIds(...)` to
|
||||
`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`).
|
||||
**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely
|
||||
a Calendula-local preference, so other calendar apps are unaffected and the sync
|
||||
adapters stay out of it (privacy-clean, reversible).
|
||||
|
||||
**Where the disabled set is applied:**
|
||||
- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude
|
||||
`calendarId ∈ (hidden ∪ disabled)` so a disabled calendar's events never show
|
||||
and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw —
|
||||
the screens that need everything still get everything.)
|
||||
- `FilterViewModel.state` — drop disabled calendars from the drawer filter list
|
||||
(you can't hide/show what's disabled).
|
||||
- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create
|
||||
into a calendar you've removed from the app. Handle the last-used-calendar
|
||||
preselect falling on a now-disabled calendar (fall back to first enabled
|
||||
writable).
|
||||
- `ImportViewModel` — exclude disabled from the import target list.
|
||||
- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists
|
||||
disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle.
|
||||
|
||||
**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each
|
||||
row on the existing `CalendarsScreen` (both the local and the synced/read-only
|
||||
groups — disabling is an app-side view choice, independent of write access).
|
||||
Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so
|
||||
they're re-enableable. Follow the project dialog/list conventions (M3 grouped
|
||||
list, `option-card-modal-style-default` if a confirm/selection surface is needed).
|
||||
|
||||
**Decided behaviour:**
|
||||
- All calendars disabled → views show the existing empty state.
|
||||
- A disabled calendar that still holds events: events simply vanish from
|
||||
views/search until re-enabled (no data touched — it's a filter, not a delete).
|
||||
- Deep links / notifications pointing at an event in a disabled calendar still
|
||||
open its detail screen — detail is a direct id lookup, not an `instances()`
|
||||
query, so disabling never strands an existing link.
|
||||
|
||||
## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)*
|
||||
|
||||
Two themes bundled because both are "make reminders trustworthy" — the core of
|
||||
the "Calendula is your only calendar app" promise.
|
||||
@@ -404,30 +469,34 @@ it directly undermines the feature's premise, so it rides in here.
|
||||
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
||||
are notorious for suppressing it).
|
||||
|
||||
### Open decisions (resolve before building)
|
||||
### Decisions made (as shipped in v2.6.0)
|
||||
|
||||
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).
|
||||
1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
|
||||
battery-optimisation exemption is the reliability lever (simplicity + battery
|
||||
cost won over the exact-alarm subsystem).
|
||||
2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
|
||||
time-of-day ("9am the day before") deferred — see the deferred list above.
|
||||
3. **Per-calendar overrides live in the Notifications sub-page** (override list),
|
||||
governing timed events only.
|
||||
|
||||
### Later (round two)
|
||||
### Round two
|
||||
|
||||
- Snooze + dismiss actions on the notification (snooze needs an
|
||||
exact-alarm / WorkManager decision) — Tier 4 #13.
|
||||
- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
|
||||
Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
|
||||
|
||||
## 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)*
|
||||
- ~~Share event as .ics + open/receive .ics into a prefilled create form~~
|
||||
**shipped v2.7.0** — single-event share from detail; opening an `.ics` with one
|
||||
event prefills the create form, many events opens a bulk import (dedup by UID)
|
||||
- ~~ICS file import~~ **shipped v2.7.0** — covered by the open/receive `.ics`
|
||||
flow above (single → form, many → bulk import)
|
||||
|
||||
## 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
|
||||
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
|
||||
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
|
||||
|
||||
## Quality & reliability
|
||||
|
||||
@@ -443,9 +512,10 @@ it directly undermines the feature's premise, so it rides in here.
|
||||
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.
|
||||
- ~~**Contact address picker** for the location field via the system picker
|
||||
(`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
|
||||
the privacy story. The same no-permission mechanism was then reused for the
|
||||
attendee email picker (v2.8.0).
|
||||
- **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
|
||||
@@ -455,9 +525,57 @@ in detail yet:
|
||||
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).
|
||||
- **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now
|
||||
Tier 5 #12, high-importance; the no-permission typed-email path is not an
|
||||
INTERNET/contacts call)*. See the "Attendee editing" sketch below.
|
||||
|
||||
### Attendee editing *(Tier 5 #12, opened 2026-06-22)*
|
||||
|
||||
The last read-only gap in the event model: attendees are read & shown on the
|
||||
detail screen (since v0.6) but the form can't write them. Make guests editable.
|
||||
|
||||
- **Read side already done:** `Attendee` domain model + status/relationship/type
|
||||
enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the
|
||||
attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add
|
||||
there.
|
||||
- **Write side — SHIPPED (`feat/attendee-editing`, commit b0f34ff, 2026-06-22):**
|
||||
`attendees` on `EventForm`; a Guests section in `EventEditScreen` rendered as an
|
||||
inline grouped list — each guest a tonal card (avatar, name/email, tappable
|
||||
Required/Optional role chip, remove), the trailing card an inline email field
|
||||
(type → Done commits). Persisted by `reconcileAttendees` diffing the provider's
|
||||
`CalendarContract.Attendees` rows on insert + dirty-checked update (new guests =
|
||||
`RELATIONSHIP_ATTENDEE` / `STATUS_INVITED`; kept rows keep their status, only the
|
||||
required/optional type updates; organizer/resource/no-email rows untouched).
|
||||
Reminders restyled to the same grouped-list pattern in passing. Needs only the
|
||||
existing `WRITE_CALENDAR` — no new permission.
|
||||
- **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline
|
||||
field is email-only (the quick path); the add row also has a **"from contacts"
|
||||
button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI` →
|
||||
the system Contacts app returns a URI to the picked email row, queried for
|
||||
`Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both
|
||||
email and name in one tap. The result Intent grants temporary read access, so
|
||||
**no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission
|
||||
mechanism as the location address picker above. Self/organizer rows stay
|
||||
non-editable.
|
||||
- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable
|
||||
calendars.** Calendula has no INTERNET and never sends an invitation itself; it
|
||||
only writes `Attendees` rows. Whether a guest is notified is decided downstream:
|
||||
local calendars notify no one (no sync); CalDAV/DAVx5 PUTs the `ATTENDEE` lines
|
||||
and the *server* decides iMIP delivery; Google's sync adapter pushes the change
|
||||
and Google decides (third-party attendee writes are historically unreliable
|
||||
there). Editing is allowed on **any writable calendar** — not gated to local.
|
||||
- **Honest, backend-aware copy is mandatory** (this is the whole point of the
|
||||
decision): on a synced calendar show "Calendula doesn't send invitations —
|
||||
your calendar account may email guests when it syncs"; on a local calendar
|
||||
show "Stored on this device. No one is notified."
|
||||
- Calendula must **not fabricate an ORGANIZER** or otherwise fake scheduling
|
||||
state to coax a send — it writes the guest list faithfully and leaves
|
||||
scheduling entirely to the backend.
|
||||
- The optional "send an .ics invite via your email app" delegate (`ACTION_SEND`,
|
||||
still no INTERNET) was considered and **deferred** — revisit only if users ask
|
||||
to notify guests explicitly.
|
||||
- **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups,
|
||||
resource booking — all carry server round-trips or richer sync semantics.
|
||||
|
||||
## Consciously rejected
|
||||
|
||||
@@ -465,3 +583,5 @@ in detail yet:
|
||||
- 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)
|
||||
- Drag & drop rescheduling in day/week — **rejected** (owner decision,
|
||||
reaffirmed 2026-06-22): not wanted. Rescheduling stays via the edit form.
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
# Calendula — Current State
|
||||
|
||||
*Last updated: 2026-06-17*
|
||||
*Last updated: 2026-06-22*
|
||||
|
||||
## Status
|
||||
|
||||
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11;
|
||||
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
|
||||
**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`.
|
||||
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
|
||||
**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`).
|
||||
**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/
|
||||
interop) arc is now done or in flight:
|
||||
- v2.4.0 per-event colors (2026-06-17)
|
||||
- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New
|
||||
event" launcher shortcut (2026-06-17)
|
||||
- v2.6.0 default reminders (global + per-calendar override, all-day default,
|
||||
battery-exemption row) + system per-app language (2026-06-18)
|
||||
- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export,
|
||||
open/receive `.ics` (single → form, many → bulk import, dedup by UID)
|
||||
(2026-06-18)
|
||||
- v2.7.1–v2.7.5 — crash-reporting + F-Droid reproducible-build hardening + fixes
|
||||
|
||||
**Next release `release/v2.8.0` (integration branch, not yet cut to main):**
|
||||
holds crash reports via the public Codeberg tracker (MR !27) + reminder
|
||||
snooze/dismiss notification actions (MR !28). Version bump to 2.8.0 happens at
|
||||
release-cut.
|
||||
|
||||
## Progress
|
||||
|
||||
@@ -120,11 +130,48 @@ agenda + month widgets, and a "New event" launcher shortcut) shipped
|
||||
sync" warning on the picker and in Settings. Color writes flow through
|
||||
insert / dirty-checked update / occurrence-exception; mapper + form tests.
|
||||
|
||||
- [x] v2.5 (shipped 2026-06-17) — Agenda view (4th top-level view),
|
||||
jump-to-date drawer date picker, two home-screen widgets (scrolling
|
||||
"Upcoming" agenda + month grid), and a "New event" launcher long-press
|
||||
shortcut
|
||||
|
||||
- [x] v2.6 (shipped 2026-06-18) — default reminders: global timed default +
|
||||
separate all-day default + per-calendar override (timed), applied on create
|
||||
with dirty-flag handling; three pickers + override list in Settings →
|
||||
Notifications; battery-optimisation exemption row (status + system deep-link,
|
||||
no new permission). Plus system per-app language (Android 13+) and an
|
||||
immediate-effect fix for the in-app language picker
|
||||
|
||||
- [x] v2.7 (shipped 2026-06-18) — the `.ics` engine: share a single event as
|
||||
`.ics` from the detail screen; back up local calendars (Settings → Calendars
|
||||
→ Export) to a SAF file; open/receive an `.ics` — one event prefills the
|
||||
create form, many events open a bulk import into a chosen calendar (dedup by
|
||||
UID, skip-and-report unrepresentable VTIMEZONE / RECURRENCE-ID / guests).
|
||||
Hand-rolled serializer/parser, zero deps. Plus all-day single-day UTC fix and
|
||||
a widget R8 keep-rule crash fix
|
||||
|
||||
- [x] v2.7.1–v2.7.5 (2026-06-21) — launch crash fix (listener before grant),
|
||||
user-controlled crash reporting, widget loading-spinner R8 keep rule, and
|
||||
F-Droid reproducible-build cleanups for the official repo
|
||||
|
||||
- [~] release/v2.8.0 (not yet cut) — crash reports via the public Codeberg
|
||||
tracker (MR !27) + reminder snooze/dismiss notification actions (MR !28,
|
||||
snooze self-schedules an exact alarm; primary delivery stays provider-broadcast)
|
||||
|
||||
## Next
|
||||
|
||||
1. Monitor the F-Droid build/publish for the v2.4.0 tag
|
||||
2. Decide the "Locations & People" and "remote calendar create/edit"
|
||||
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".
|
||||
1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the
|
||||
merge-driven pipeline) once on-device review signs off
|
||||
2. **Attendee editing** — the committed next feature (Tier 5 #12, high-
|
||||
importance, opened 2026-06-22). Attendees are already read & shown on the
|
||||
detail screen since v0.6; the gap is the write side — make guests editable
|
||||
in `EventEditScreen` / `EventForm` (add by typed email, role, remove),
|
||||
persisted by diffing `CalendarContract.Attendees`. No new permission for the
|
||||
typed-email path. **Invitation behavior DECIDED 2026-06-22: record-only on all
|
||||
writable calendars** — Calendula never sends (no INTERNET); honest backend-aware
|
||||
copy ("your account may email guests when it syncs" on synced calendars, "no one
|
||||
is notified" on local). Full sketch in `ROADMAP.md` → "Attendee editing".
|
||||
3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar
|
||||
create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week
|
||||
numbers in month, full-text search, accessibility pass). Drag-and-drop
|
||||
rescheduling is **rejected**.
|
||||
|
||||
278
CHANGELOG.md
278
CHANGELOG.md
@@ -7,6 +7,267 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||
birthdays, anniversaries and other dates into local "Birthdays",
|
||||
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
|
||||
change. Each is a normal local calendar, so you set its colour, visibility and
|
||||
reminders the usual way — new birthdays even start with a reminder a week before
|
||||
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
|
||||
This is the first feature to use the contacts permission: it is requested only
|
||||
when you turn the feature on, everything stays on your device (Calendula has no
|
||||
internet access), and your contacts are only ever read, never changed. Thanks
|
||||
to @moonj for the suggestion ([#15]).
|
||||
- Set more than one default reminder per calendar. A calendar's default
|
||||
reminders — and the global defaults under Settings → Notifications — can now
|
||||
hold several lead times instead of just one, so new events can start with, say,
|
||||
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||
multi-select; per-calendar overrides can still inherit the global default or
|
||||
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||
|
||||
### Fixed
|
||||
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||
popping up — matching how a disabled calendar's events already stay hidden
|
||||
everywhere else in the app ([#17]).
|
||||
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||
this event** and saving a change to one event in a repeating series silently
|
||||
did nothing — the change was rejected and the edit form simply reappeared with
|
||||
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||
lands on just that one event and leaves the rest of the series untouched
|
||||
([#16]).
|
||||
|
||||
## [2.12.0] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- See past events your way. Two new settings change how events that have already
|
||||
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||
(Settings → Appearance) separately fades finished events in the month and week
|
||||
views. Both are off by default, an event only counts as finished once it has
|
||||
actually ended (events still in progress are never dimmed), and the lists
|
||||
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||
([#12]).
|
||||
|
||||
### Changed
|
||||
- Readable titles on overlapping events. In the week and day views, events that
|
||||
overlap split a day into slim columns where the title used to be clipped to a
|
||||
character or two. The title now wraps across as many lines as the block can
|
||||
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||
for the suggestion ([#13]).
|
||||
|
||||
## [2.11.2] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- Jump straight to typing a title. When you start a new event, Calendula now puts
|
||||
the cursor in the title field and opens the keyboard right away, so you can type
|
||||
the name without an extra tap. It's on by default and only affects creating an
|
||||
event — editing never grabs focus — and a new **Focus title on new event**
|
||||
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
|
||||
suggestion ([#10]).
|
||||
|
||||
## [2.11.1] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- Calendula can now be set as your default calendar app. It registers the
|
||||
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||
that day. Android has no way for an app to make itself the default, so you pick
|
||||
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||
|
||||
## [2.11.0] — 2026-06-27
|
||||
|
||||
### Added
|
||||
- Start the week on any day. The **Week starts on** setting (Settings →
|
||||
Appearance) now offers every weekday — not just Monday or Sunday — alongside
|
||||
the automatic, locale-based default. Thanks to @zmaherdev for the suggestion
|
||||
([#3]).
|
||||
- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings →
|
||||
Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or
|
||||
follow the system setting automatically. It applies everywhere times appear —
|
||||
the week and day timelines, agenda, event details, search and reminders — so
|
||||
the format is now consistent across the whole app. Thanks to @zmaherdev for
|
||||
the suggestion ([#6]).
|
||||
- Optional hour lines in the timeline. A new **Hour lines** switch (Settings →
|
||||
Appearance) draws a faint separator at each hour in the week and day views,
|
||||
making it easier to see when events start and end. Off by default. Thanks to
|
||||
@zmaherdev for the suggestion ([#5]).
|
||||
- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda
|
||||
range** and **Agenda widget range** settings (Settings → Appearance) let the
|
||||
agenda screen and its home-screen widget each show just today, the rest of this
|
||||
week, the rest of this month, a rolling 7 or 30 days, or a custom number of
|
||||
days — "This week" follows your week-start. The agenda also gains a bar at the
|
||||
top naming the exact dates in view, with a button to switch the range just for
|
||||
the current session (it resets when you reopen the app); the bar can be turned
|
||||
off. Thanks to @zmaherdev for the suggestion ([#4]).
|
||||
- Automatic backup of local calendars. A new **Automatic backup** option
|
||||
(Settings → Calendars → Backup) periodically exports your local calendars to an
|
||||
`.ics` file in a folder you choose, on an interval you set (from 30 minutes up).
|
||||
Useful for file-based syncing such as Syncthing, or simply as a safety net — it
|
||||
is a one-way export and never touches your synced accounts. Thanks to @shield
|
||||
for the discussion that inspired it ([#7], [#8]).
|
||||
- Field icons in the event-form settings. Each optional-field toggle (Settings →
|
||||
Event form) now shows the same icon the field uses in the new-event form, so
|
||||
the list is easier to scan.
|
||||
- Help translate Calendula. A **Help translate** link at the top of **Settings →
|
||||
App language** opens the project's Weblate, where you can add or improve a
|
||||
language in your browser — no coding needed. Contributions in any language are
|
||||
welcome.
|
||||
|
||||
### Changed
|
||||
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
|
||||
agenda), and the agenda's empty state now reads "You're all caught up".
|
||||
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
|
||||
into collapsible, source-branded cards — each account shows its app's icon —
|
||||
with a per-account menu to enable or disable all of its calendars at once or
|
||||
open the account in its source app.
|
||||
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
|
||||
moved up with the other global reminder options, and the per-calendar reminder
|
||||
overrides now fold into a single expandable section.
|
||||
- Moved **Add Quick Settings tile** out of the event-form section to its own
|
||||
top-level entry in Settings.
|
||||
|
||||
## [2.10.0] — 2026-06-25
|
||||
|
||||
### Added
|
||||
- Turn a calendar off across the whole app. Settings → Calendars now has a
|
||||
switch on every calendar — both your own and synced ones. Switching one off
|
||||
removes it everywhere: its events disappear from all views and search, and it
|
||||
drops out of the drawer's hide/show filter, the event-form calendar picker and
|
||||
the import target picker. Unlike hiding (the quick per-view checkbox in the
|
||||
drawer), a disabled calendar leaves the app entirely until you turn it back on.
|
||||
Nothing is deleted and no other app is affected — it's a Calendula-only view
|
||||
choice — so you can re-enable it any time from the same screen, where disabled
|
||||
calendars stay listed but dimmed.
|
||||
- An optional way to support development. Settings → About now has a "Support
|
||||
development" button that opens Ko-fi in your browser. It's a plain donation
|
||||
link with no perks attached, and it needs no new permissions — Calendula still
|
||||
has no internet access of its own and just hands the link to your browser.
|
||||
|
||||
## [2.9.0] — 2026-06-25
|
||||
|
||||
### Added
|
||||
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
|
||||
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
|
||||
time you start the app, instead of always opening on Week. Thanks to
|
||||
@devinside for the suggestion ([#1]).
|
||||
- The month widget is now interactive: tap any day to open it, or tap an event to
|
||||
open its details. Previously only the month's prev/next/today controls
|
||||
responded. Thanks to @devinside for spotting this ([#2]).
|
||||
- Predictive back. Swiping back from a screen — an event, the editor, search or
|
||||
settings — now follows your finger, shrinking the screen to preview what's
|
||||
behind so you can see where Back will take you before you let go; slide it back
|
||||
to cancel. The preview needs Android 14 or newer; on older versions Back works
|
||||
as before.
|
||||
- Respect for "Remove animations". If you've turned animations off in your
|
||||
device's Accessibility settings, Calendula now honours it everywhere: motion
|
||||
collapses to a quick fade and the back preview is skipped.
|
||||
|
||||
### Changed
|
||||
- Smoother, more consistent motion throughout. Expanding sections, list updates
|
||||
in search and agenda, and the onboarding screens now animate the same way
|
||||
across the app, instead of some places sliding or growing while others popped
|
||||
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
|
||||
snapping, while paging within a view keeps its slide.
|
||||
|
||||
### Fixed
|
||||
- Home-screen widgets now back out the way you came in. A day or event opened
|
||||
from the Agenda widget keeps you in the agenda context, and from the Month
|
||||
widget in the month context — pressing Back returns there instead of dropping
|
||||
you on the week view. More broadly, the app now keeps a real view history:
|
||||
switching views and drilling into a day are retraced by Back one step at a
|
||||
time, down to your default view before the app exits. Thanks to @devinside for
|
||||
reporting ([#2]).
|
||||
|
||||
## [2.8.0] — 2026-06-23
|
||||
|
||||
### Added
|
||||
- Find events fast. A search button in the top bar of every calendar view opens
|
||||
a search box — type a couple of letters and matching events (by title,
|
||||
location or description) appear, soonest first with past events below. Tap a
|
||||
result to open it. Search covers your whole calendar, not just what's on
|
||||
screen, and skips calendars you've hidden. A recurring event shows its next
|
||||
occurrence rather than the date the series first started.
|
||||
- A current-time line in the day and week views. A thin coloured line marks the
|
||||
present moment across today's column, so you can see at a glance where you are
|
||||
in the day. It updates every minute and only appears when today is in view.
|
||||
- Add and remove event guests. The create/edit form now has a Guests section:
|
||||
add people by email or pick them from your contacts, mark each as required or
|
||||
optional, and remove them. Calendula never sends invitations itself (it has no
|
||||
internet access) — it only records the guests; if the event lives on a synced
|
||||
account, that account may email them when it syncs, and on a local calendar no
|
||||
one is notified. The form tells you which applies. Picking a guest from
|
||||
contacts needs no contacts permission.
|
||||
- Pick a location from your contacts. A contacts button beside the location
|
||||
field drops a contact's address straight into an event — handy for a meeting
|
||||
at someone's home or office. Like the guest picker, it needs no contacts
|
||||
permission.
|
||||
- A "New event" Quick Settings tile. Add it to your quick settings to jump
|
||||
straight into the new-event form from anywhere. Settings → New event has a
|
||||
one-tap button to add the tile (Android 13+); on older versions you can add it
|
||||
from the quick-settings editor.
|
||||
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
|
||||
reminder; snooze hides it and brings it back after a delay you pick in
|
||||
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
|
||||
system won't re-post a reminder on its own, so Calendula schedules an exact
|
||||
alarm to bring a snoozed one back on time.
|
||||
|
||||
### Changed
|
||||
- Event details now show each guest's email beneath their name, instead of only
|
||||
when no name is available.
|
||||
- Crash and problem reports now open on the project's public Codeberg tracker,
|
||||
where anyone can register and file an issue. Nothing is sent automatically —
|
||||
you still review the report and submit it yourself in the browser.
|
||||
|
||||
## [2.7.5] — 2026-06-21
|
||||
|
||||
### Changed
|
||||
- Further build cleanup for the official F-Droid repository: stopped embedding
|
||||
AGP's dependency-metadata block in the APK, which F-Droid's reproducible-build
|
||||
scanner rejects as an extra signing block. No functional or visible changes —
|
||||
the same app as 2.7.4, just without that Play-oriented metadata blob.
|
||||
|
||||
## [2.7.4] — 2026-06-21
|
||||
|
||||
### Changed
|
||||
- Build cleanup that lets Calendula ship in the official F-Droid repository:
|
||||
removed an unused Gradle toolchain-resolver plugin, which F-Droid's offline,
|
||||
reproducible build process disallows. No functional or visible changes — this
|
||||
is the same app as 2.7.3.
|
||||
|
||||
## [2.7.3] — 2026-06-21
|
||||
|
||||
### Fixed
|
||||
- Home-screen widgets no longer get stuck on a loading spinner in the published
|
||||
(F-Droid) release build. They render via Android's background-work system, and
|
||||
release optimisation (R8) was stripping a helper class it loads by name, so the
|
||||
render job never ran. Added the missing keep rule — widgets now load normally.
|
||||
|
||||
## [2.7.2] — 2026-06-21
|
||||
|
||||
### Added
|
||||
- Crash reporting you control. If Calendula closes unexpectedly, it now captures
|
||||
a technical report and, on the next launch, offers to send it as an issue on
|
||||
the project's tracker. Nothing is uploaded automatically — the report stays on
|
||||
your device until you choose to share it, it contains no personal data or
|
||||
calendar content (only the app, Android and device versions plus the stack
|
||||
trace), and you see the full text before sending. There's also a "Report a
|
||||
problem" entry in Settings, and if the app ever fails to start repeatedly, a
|
||||
minimal recovery screen still lets you send the report.
|
||||
|
||||
## [2.7.1] — 2026-06-21
|
||||
|
||||
### Fixed
|
||||
- Fixed the app crashing immediately on launch whenever calendar access hadn't
|
||||
been granted yet (a fresh install, or after revoking the permission). The app
|
||||
set up its live calendar-change listener before the permission screen could
|
||||
appear, which newer Android versions reject outright — so the app died before
|
||||
you could grant access. The listener now waits for the permission and attaches
|
||||
itself the moment it's granted.
|
||||
|
||||
## [2.7.0] — 2026-06-18
|
||||
|
||||
### Added
|
||||
@@ -488,3 +749,20 @@ automatically, with zero telemetry and no internet permission.
|
||||
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
|
||||
- F-Droid metadata stubs (DE + EN short/full descriptions)
|
||||
- `.planning/` project-tracking documents
|
||||
|
||||
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
|
||||
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
|
||||
[#3]: https://codeberg.org/jlmakiola/calendula/issues/3
|
||||
[#4]: https://codeberg.org/jlmakiola/calendula/issues/4
|
||||
[#5]: https://codeberg.org/jlmakiola/calendula/issues/5
|
||||
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
|
||||
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
||||
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
||||
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
|
||||
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
||||
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
|
||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||
|
||||
77
README.md
77
README.md
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png" width="112" alt="Calendula icon">
|
||||
<img src="fastlane/metadata/android/en-US/images/icon.png" width="112" alt="Calendula icon">
|
||||
|
||||
<h1>Calendula</h1>
|
||||
|
||||
@@ -16,11 +16,17 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
||||
|
||||
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
|
||||
</p>
|
||||
|
||||
</div>
|
||||
@@ -62,19 +68,27 @@ database, no sync stack reinvented.
|
||||
|
||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
||||
expressive motion and shapes, light/dark theme
|
||||
- German and English UI, per-app language setting
|
||||
- German and English UI, per-app language setting — and [open to community
|
||||
translations](#-translations)
|
||||
- **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.
|
||||
### F-Droid (recommended)
|
||||
|
||||
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*:
|
||||
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
||||
just search for **Calendula** in any F-Droid client, or
|
||||
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
||||
|
||||
### Self-hosted repo (latest builds)
|
||||
|
||||
New versions are built, signed, and published to a self-hosted repository the
|
||||
moment each tag lands — usually a few days ahead of the official repo, which
|
||||
rebuilds on F-Droid's own schedule. Add it for the freshest builds:
|
||||
|
||||
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||
link below on your phone):
|
||||
|
||||
```
|
||||
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
|
||||
@@ -84,36 +98,27 @@ is built, signed, and published there automatically.
|
||||
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.
|
||||
2. Refresh, search for **Calendula**, install.
|
||||
|
||||
Alternatively, build from source — see below.
|
||||
Both channels share the same signing key, so you can switch between them
|
||||
without reinstalling. Or build from source — see below.
|
||||
|
||||
## 🛠 Building
|
||||
## 📚 Documentation
|
||||
|
||||
Requires Android SDK 36+ and JDK 17. The Gradle wrapper is checked in:
|
||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug # debug APK
|
||||
./gradlew test # JVM unit tests
|
||||
./gradlew lint # Android lint
|
||||
```
|
||||
## 🌍 Translations
|
||||
|
||||
If your default JDK is not 17, set `JAVA_HOME` explicitly.
|
||||
Calendula ships in German and English, and you're warmly invited to add your
|
||||
language. Translations are managed on a self-hosted **Weblate**:
|
||||
|
||||
## 🏗 Architecture
|
||||
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
||||
|
||||
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).
|
||||
No coding needed — register on the Weblate server, pick (or request) a language,
|
||||
and translate the strings in your browser. You can also reach this link in the
|
||||
app from the top of **Settings → App language**.
|
||||
|
||||
## 📜 License
|
||||
|
||||
|
||||
@@ -23,13 +23,13 @@ android {
|
||||
applicationId = "de.jeanlucmakiola.calendula"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
// The git tag is the single source of truth for released builds: at
|
||||
// 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"
|
||||
// These committed values ARE the source of truth for a release: merging
|
||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21200
|
||||
versionName = "2.12.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -47,6 +47,11 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Keep release builds reproducible for F-Droid: don't let AGP embed
|
||||
// build-environment git metadata (META-INF/version-control-info.textproto),
|
||||
// whose `revision`/path content varies by build machine and is the only
|
||||
// thing that otherwise differs from a clean from-source rebuild.
|
||||
vcsInfo { include = false }
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
@@ -61,6 +66,21 @@ android {
|
||||
applicationIdSuffix = ".debug"
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
|
||||
// and resource shrinking, but debug-signed and given its own applicationId
|
||||
// suffix so it installs alongside both the production app (signed with the
|
||||
// real key) and the debug build. Used to smoke-test a release candidate on
|
||||
// a real device before tagging — R8-only breakage and first-run/permission
|
||||
// states don't surface in the unminified debug build, nor on a device that
|
||||
// already holds the permission. Never published. See docs/RELEASING.md.
|
||||
create("releaseTest") {
|
||||
initWith(getByName("release"))
|
||||
applicationIdSuffix = ".releasetest"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
matchingFallbacks += "release"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -70,6 +90,18 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
// BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon).
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||
// a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any
|
||||
// "extra signing block" — so leaving it in blocks publishing to the official
|
||||
// repo. It lives in the signing block, not the zip entries, so disabling it
|
||||
// doesn't change the build output (reproducibility is unaffected).
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
packaging {
|
||||
@@ -122,6 +154,9 @@ dependencies {
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.androidx.documentfile)
|
||||
|
||||
implementation(libs.androidx.glance.appwidget)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
|
||||
11
app/proguard-rules.pro
vendored
11
app/proguard-rules.pro
vendored
@@ -15,3 +15,14 @@
|
||||
# Keep the generated Room database implementations fully intact.
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-dontwarn androidx.room.paging.**
|
||||
|
||||
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
||||
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
||||
# keep both its name and a no-arg constructor. Glance renders every widget
|
||||
# through a WorkManager worker (androidx.glance.session.SessionWorker) whose
|
||||
# default merger is androidx.work.OverwritingInputMerger. Under R8 full mode
|
||||
# (AGP 9 default) that unused no-arg constructor was stripped, so WorkManager
|
||||
# threw "OverwritingInputMerger has no zero argument constructor", the
|
||||
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
||||
|
||||
10
app/src/debug/res/values/colors.xml
Normal file
10
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-only launcher-icon background. Production is slate (#5C6B7A); the
|
||||
debug build paints the adaptive-icon background burnt orange instead, so the
|
||||
debug icon reads at a glance as "not the real app" on the home screen. The
|
||||
off-white foreground mark contrasts on both. See drawable/ic_launcher_background.
|
||||
-->
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFB23B00</color>
|
||||
</resources>
|
||||
10
app/src/debug/res/values/strings.xml
Normal file
10
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-build resource overrides. Merged on top of src/main for the `debug`
|
||||
build type only (release/releaseTest keep the production values), so the
|
||||
debug app is unmistakable on the launcher: its own label, alongside the
|
||||
real app thanks to the `.debug` applicationId suffix.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Calendula Debug</string>
|
||||
</resources>
|
||||
@@ -5,6 +5,15 @@
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!--
|
||||
Optional and feature-gated: only the "Contact special dates" feature reads
|
||||
contacts, and only after the user enables it and grants this at runtime
|
||||
(never requested at startup). Everything stays offline — birthdays and
|
||||
other contact dates are mirrored one-way into local calendars; contacts
|
||||
are never written and nothing leaves the device (the app has no INTERNET
|
||||
permission). See docs/design/contact-special-dates.md.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<!--
|
||||
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||
@@ -13,6 +22,17 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!--
|
||||
Re-fire a snoozed reminder at an exact time (the calendar provider won't —
|
||||
its alert is already fired). USE_EXACT_ALARM is auto-granted to calendar
|
||||
apps on API 33+; SCHEDULE_EXACT_ALARM covers API 31–32 (user-revocable,
|
||||
with an inexact fallback if withheld). F-Droid-clean: no Play allowlisting.
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.SCHEDULE_EXACT_ALARM"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
|
||||
<!-- 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
|
||||
@@ -28,6 +48,7 @@
|
||||
<application
|
||||
android:name=".CalendulaApp"
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -47,6 +68,35 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Be selectable as the system calendar app. Android has no API for
|
||||
an app to make itself the default, so registering the filters
|
||||
launchers and the OS use is what lets the user pick Calendula
|
||||
from the system chooser when a date action fires (issue #9).
|
||||
APP_CALENDAR is the "open the calendar app" action; the VIEW
|
||||
filters below catch a tapped date. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.APP_CALENDAR" />
|
||||
</intent-filter>
|
||||
<!-- A launcher/clock date tap fires ACTION_VIEW on the provider's
|
||||
time Uri (content://com.android.calendar/time/<epochMillis>);
|
||||
some surfaces use the time/epoch mime type. We open the day view
|
||||
on that date (MainActivity.calendarTimeDateOrNull). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
android:scheme="content"
|
||||
android:host="com.android.calendar"
|
||||
android:pathPrefix="/time" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="time/epoch" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -68,6 +118,29 @@
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
|
||||
<!-- Standalone surface for a captured crash report. MainActivity routes
|
||||
here on a startup crash-loop, so it stays clear of the app's Hilt
|
||||
graph and Compose content. Not exported: launched only by us. -->
|
||||
<activity
|
||||
android:name=".ui.crash.CrashReportActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<!-- Quick Settings tile: a one-tap "New event" shortcut in the QS panel.
|
||||
Exported with BIND_QUICK_SETTINGS_TILE so only the system QS host can
|
||||
bind it; the action mirrors the launcher "New event" shortcut. -->
|
||||
<service
|
||||
android:name=".qs.NewEventTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_qs_new_event"
|
||||
android:label="@string/qs_tile_new_event_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- 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. -->
|
||||
@@ -82,6 +155,13 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Snooze / dismiss actions on a reminder notification, plus the snooze
|
||||
re-show alarm. Not exported: only our own notification buttons and
|
||||
AlarmManager PendingIntents target it. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.ReminderActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||
<receiver
|
||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||
|
||||
@@ -1,11 +1,67 @@
|
||||
package de.jeanlucmakiola.calendula
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Application entry point. Registered as android:name=".CalendulaApp"
|
||||
* in AndroidManifest.xml. Hilt initializes its component graph here.
|
||||
*/
|
||||
@HiltAndroidApp
|
||||
class CalendulaApp : Application()
|
||||
class CalendulaApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Install first thing so startup crashes are captured too (privacy-
|
||||
// respecting, on-device; the user submits the report by hand).
|
||||
CrashReporter.install(this)
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the scheduled auto-backup work back in line with the saved settings
|
||||
* on every launch — re-arms it after a reinstall and, crucially, cancels any
|
||||
* orphaned work once backup has been turned off.
|
||||
*/
|
||||
private fun reconcileAutoBackup() {
|
||||
val deps = EntryPointAccessors.fromApplication(this, BackupWorker.Deps::class.java)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
val prefs = deps.settingsPrefs()
|
||||
BackupScheduler.apply(
|
||||
context = this@CalendulaApp,
|
||||
enabled = prefs.autoBackupEnabled.first(),
|
||||
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = prefs.autoBackupFolderUri.first() != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||
* settings on every launch — like [reconcileAutoBackup], this cancels
|
||||
* orphaned work after the feature is turned off and re-schedules after a
|
||||
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
|
||||
* refresh, so no immediate run is needed here.
|
||||
*/
|
||||
private fun reconcileSpecialDates() {
|
||||
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
SpecialDatesScheduler.apply(
|
||||
context = this@CalendulaApp,
|
||||
enabled = deps.settingsPrefs().specialDatesEnabled.first(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,38 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@@ -41,12 +57,31 @@ class MainActivity : AppCompatActivity() {
|
||||
// by CalendarHost's import flow.
|
||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||
// over the calendar on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
private var pendingCrashReport by mutableStateOf<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// If the app keeps crashing as it starts, the main UI can't be trusted
|
||||
// to come up — route to the standalone report screen instead of
|
||||
// re-entering the crashing graph.
|
||||
if (CrashReporter.isCrashLoop(this)) {
|
||||
startActivity(
|
||||
Intent(this, CrashReportActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
|
||||
)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
// and the Settings screen, so a theme change applies app-wide at once.
|
||||
@@ -57,23 +92,62 @@ class MainActivity : AppCompatActivity() {
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
// The app-wide clock convention: the time-format preference resolved
|
||||
// against the device's 24-hour system setting, provided once here so
|
||||
// every time label reads it via LocalUse24HourFormat.
|
||||
val context = LocalContext.current
|
||||
val use24Hour = remember(settings.timeFormat, context) {
|
||||
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
}
|
||||
CalendulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
// mistaken for the production app; compiled out of release.
|
||||
if (BuildConfig.DEBUG) DebugRibbon()
|
||||
}
|
||||
pendingCrashReport?.let { report ->
|
||||
CrashReportDialog(
|
||||
report = report,
|
||||
onSend = {
|
||||
submitCrashReport(this@MainActivity, report)
|
||||
CrashReporter.clearReport(this@MainActivity)
|
||||
pendingCrashReport = null
|
||||
},
|
||||
onDismiss = {
|
||||
// Keep the report (Settings can still reach it); just
|
||||
// stop it popping on every launch.
|
||||
CrashReporter.dismissPrompt(this@MainActivity)
|
||||
pendingCrashReport = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||
CrashReporter.markHealthy(this)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||
@@ -92,21 +166,64 @@ class MainActivity : AppCompatActivity() {
|
||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
else -> null
|
||||
} ?: return null
|
||||
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||
// it's a navigation, not a file to import, so [navRequestOrNull] owns it.
|
||||
if (uri.host == CALENDAR_PROVIDER_HOST) 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
|
||||
/**
|
||||
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||
* <epochMillis>`. Null for any other intent. The matching manifest filter is
|
||||
* what lets users pick Calendula from the system calendar chooser (issue #9).
|
||||
*/
|
||||
private fun Intent.calendarTimeDateOrNull(): LocalDate? {
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.firstOrNull() != "time") return null
|
||||
val millis = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||
return Instant.fromEpochMilliseconds(millis)
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
}
|
||||
|
||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||
// the day view rooted over the default home view (OpenDate source = null).
|
||||
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||
val source = sourceViewOrNull()
|
||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||
return 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))
|
||||
// A widget event tap carries both the occurrence and its source view;
|
||||
// reminders (no source) fall through to [detailKeyOrNull] instead.
|
||||
source != null && eventId != -1L -> WidgetNavRequest.OpenEvent(
|
||||
eventId = eventId,
|
||||
beginMillis = getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
|
||||
endMillis = getLongExtra(EXTRA_END_MILLIS, 0L),
|
||||
source = source,
|
||||
)
|
||||
source != null && getStringExtra(EXTRA_DATE_ISO) != null ->
|
||||
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!, source)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** The view the launching widget represents, if any (see [EXTRA_SOURCE_VIEW]). */
|
||||
private fun Intent.sourceViewOrNull(): CalendarView? =
|
||||
getStringExtra(EXTRA_SOURCE_VIEW)
|
||||
?.let { name -> CalendarView.entries.firstOrNull { it.name == name } }
|
||||
|
||||
private fun Intent.detailKeyOrNull(): LongArray? {
|
||||
// A widget event tap (source present) is routed through [navRequestOrNull]
|
||||
// so it can also set the base view; only sourceless reminder taps land here.
|
||||
if (sourceViewOrNull() != null) return null
|
||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||
if (eventId == -1L) return null
|
||||
return longArrayOf(
|
||||
@@ -117,12 +234,20 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The calendar provider's authority/host. A date tap arrives as
|
||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||
|
||||
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"
|
||||
|
||||
// The [CalendarView] (by name) of the widget a launch came from. Roots the
|
||||
// in-app back stack in that view; absent for non-widget launches (reminders).
|
||||
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
|
||||
|
||||
// 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"
|
||||
@@ -145,14 +270,35 @@ class MainActivity : AppCompatActivity() {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/** Open the day view anchored on [date] (home-screen widgets). */
|
||||
fun openDateIntent(context: Context, date: LocalDate): Intent =
|
||||
/**
|
||||
* Open the day view anchored on [date], drilled in over the launching
|
||||
* widget's [source] view (home-screen widgets). Backing out of the day
|
||||
* returns to [source], then to the default home view.
|
||||
*/
|
||||
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
data = "calendula://date/$date".toUri()
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one occurrence's detail from a widget event tap, rooting the back
|
||||
* stack in the widget's [source] view. Same occurrence-key shape as
|
||||
* [eventDetailIntent]; the [source] extra is what distinguishes a widget
|
||||
* tap (sets the base view) from a reminder tap (leaves it untouched).
|
||||
*/
|
||||
fun openEventIntent(
|
||||
context: Context,
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
endMillis: Long,
|
||||
source: CalendarView,
|
||||
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
|
||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||
}
|
||||
|
||||
/** Open the create-event form prefilled for [date] (home-screen widgets). */
|
||||
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package de.jeanlucmakiola.calendula.data.backup
|
||||
|
||||
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 androidx.core.net.toUri
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Schedules and runs the automatic periodic `.ics` backup of local calendars
|
||||
* (issue #8). One-way export only — it mirrors the manual "Back up" into a
|
||||
* user-chosen folder on an interval; it is not a sync. Stays INTERNET-free:
|
||||
* everything is local provider reads + a local file write.
|
||||
*/
|
||||
object BackupScheduler {
|
||||
|
||||
private const val WORK_NAME = "auto-backup"
|
||||
private const val WORK_NAME_NOW = "auto-backup-now"
|
||||
|
||||
/**
|
||||
* Reconcile the scheduled work with the current settings: enqueue a unique
|
||||
* periodic request when backup is on and a folder is set, otherwise cancel
|
||||
* it. WorkManager persists the request across reboots on its own.
|
||||
*
|
||||
* The first run is delayed by one interval so it never overlaps an immediate
|
||||
* [runNow] (two simultaneous writes would race and leave a "(1)" duplicate).
|
||||
*/
|
||||
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
if (!enabled || !hasFolder) {
|
||||
workManager.cancelUniqueWork(WORK_NAME)
|
||||
// Also cancel any pending immediate run — otherwise a failing run-now
|
||||
// keeps retrying after the user has turned backup off.
|
||||
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||
return
|
||||
}
|
||||
// WorkManager's own floor is 15 min; the UI floors the user choice at 30.
|
||||
val interval = intervalMinutes.coerceAtLeast(15L)
|
||||
val request = PeriodicWorkRequestBuilder<BackupWorker>(interval, TimeUnit.MINUTES)
|
||||
.setInitialDelay(interval, TimeUnit.MINUTES)
|
||||
.build()
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one export immediately (e.g. right after enabling or changing the
|
||||
* folder) for instant feedback. Unique + REPLACE so rapid taps coalesce into
|
||||
* a single run rather than racing each other.
|
||||
*/
|
||||
fun runNow(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
WORK_NAME_NOW,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
OneTimeWorkRequestBuilder<BackupWorker>().build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
|
||||
* overwriting the previous file. Pulls its collaborators through a Hilt
|
||||
* [EntryPoint] so it works under WorkManager's default (no-arg) worker factory —
|
||||
* no custom factory / Application wiring needed. Records the outcome for the
|
||||
* settings status line, and notifies after repeated failures.
|
||||
*/
|
||||
class BackupWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun settingsPrefs(): SettingsPrefs
|
||||
fun repository(): CalendarRepository
|
||||
fun exporter(): IcsExporter
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
val prefs = deps.settingsPrefs()
|
||||
// Respect the toggle even for already-queued work: if backup was turned
|
||||
// off, no-op (and don't retry) so a lingering run can't revive itself.
|
||||
if (!prefs.autoBackupEnabled.first()) return Result.success()
|
||||
val folder = prefs.autoBackupFolderUri.first()
|
||||
?: return Result.failure() // nothing to write to — leave scheduling to settings
|
||||
val now = System.currentTimeMillis()
|
||||
return try {
|
||||
val events = deps.repository().exportEvents()
|
||||
val content = IcsWriter().writeCalendar(events, Clock.System.now())
|
||||
deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content)
|
||||
prefs.recordAutoBackupRun(success = true, atMillis = now)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
prefs.recordAutoBackupRun(success = false, atMillis = now)
|
||||
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
|
||||
notifyFailure(applicationContext)
|
||||
}
|
||||
Log.w(TAG, "Automatic backup failed", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyFailure(context: Context) {
|
||||
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!canPost) return
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.backup_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { description = context.getString(R.string.backup_channel_description) },
|
||||
)
|
||||
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
val tap = launch?.let {
|
||||
PendingIntent.getActivity(
|
||||
context, 0, it,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.backup_failed_title))
|
||||
.setContentText(context.getString(R.string.backup_failed_text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setAutoCancel(true)
|
||||
.apply { tap?.let(::setContentIntent) }
|
||||
.build()
|
||||
try {
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Could not post backup-failure notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BACKUP_FILE_NAME = "calendula-backup.ics"
|
||||
private const val FAILURE_NOTIFY_THRESHOLD = 2
|
||||
private const val CHANNEL_ID = "backup"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val TAG = "BackupWorker"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
/**
|
||||
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
||||
* hue (the managed special-dates calendars), so they can't drift from the
|
||||
* swatches offered in the colour picker.
|
||||
*/
|
||||
object CalendarColorPalette {
|
||||
val Red = 0xFFD50000.toInt()
|
||||
val Orange = 0xFFE67C00.toInt()
|
||||
val Amber = 0xFFF6BF26.toInt()
|
||||
val Green = 0xFF33B679.toInt()
|
||||
val DarkGreen = 0xFF0B8043.toInt()
|
||||
val Blue = 0xFF039BE5.toInt()
|
||||
val Indigo = 0xFF3F51B5.toInt()
|
||||
val Purple = 0xFF8E24AA.toInt()
|
||||
val Graphite = 0xFF616161.toInt()
|
||||
|
||||
/** The full palette, in swatch-row order. */
|
||||
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.ContentObserver
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
@@ -11,19 +13,23 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
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.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
@@ -43,6 +49,14 @@ interface CalendarDataSource {
|
||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||
fun eventDetail(eventId: Long): EventDetail?
|
||||
|
||||
/**
|
||||
* Master/one-off events whose title, description or location contains
|
||||
* [query] (case-insensitive), across all calendars, newest first. Reads the
|
||||
* Events table directly so the search is unbounded in time; exception rows
|
||||
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
|
||||
*/
|
||||
fun searchEvents(query: String): List<EventInstance>
|
||||
|
||||
/**
|
||||
* The event-colour palette the calendar's account publishes
|
||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
||||
@@ -85,6 +99,54 @@ interface CalendarDataSource {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
fun deleteCalendar(id: Long)
|
||||
|
||||
/**
|
||||
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||
* local calendar, so it inherits per-calendar colour/visibility/reminders.
|
||||
*/
|
||||
fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long
|
||||
|
||||
/**
|
||||
* The app's special-dates calendars, found by their `CAL_SYNC2` marker.
|
||||
* Lets the mirror re-adopt its calendars after the stored ids are lost, and
|
||||
* detect when the user deleted one in system settings.
|
||||
*/
|
||||
fun findManagedCalendars(): List<ManagedCalendarRow>
|
||||
|
||||
/**
|
||||
* The managed events in [calendarId] (those carrying a `UID_2445`, not
|
||||
* soft-deleted) as [ManagedEventRow]s — the existing side of the sync diff.
|
||||
*/
|
||||
fun queryManagedEvents(calendarId: Long): List<ManagedEventRow>
|
||||
|
||||
/**
|
||||
* Insert a managed event carrying the deterministic [uid] in `UID_2445`;
|
||||
* seeds its reminder rows from [form] (never touched again by sync). Returns
|
||||
* the new `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
*/
|
||||
fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long
|
||||
|
||||
/**
|
||||
* Overwrite exactly the given managed columns on [eventId] (keys are
|
||||
* `CalendarContract.Events` columns; a null value clears the column). A
|
||||
* bare, targeted update — it never reconciles reminders or attendees, so
|
||||
* user-owned data on the event survives every sync. No-ops on an empty map.
|
||||
*/
|
||||
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
|
||||
|
||||
/**
|
||||
* Replace the reminders on **every** event in [calendarId] with [minutes]
|
||||
* (all-day lead times), each encoded from its own date so it fires at
|
||||
* [allDayReminderTimeMinutes]. Used by the special-dates section so a reminder
|
||||
* change applies to existing events too, not just future ones — the managed
|
||||
* calendars treat reminders as a calendar-level setting.
|
||||
*/
|
||||
fun applyManagedCalendarReminders(
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
minutes: List<Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||
@@ -162,14 +224,52 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
) : CalendarDataSource {
|
||||
|
||||
private val resolver: ContentResolver get() = context.contentResolver
|
||||
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
|
||||
|
||||
override fun calendars(): List<CalendarSource> = resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
CalendarProjection.COLUMNS,
|
||||
null, null,
|
||||
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
||||
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
||||
// All access to these two collections is guarded by [observerLock] because
|
||||
// listeners are registered on the main thread (repository init, via ViewModel
|
||||
// creation) while [ensureObserversRegistered] runs on the IO dispatcher.
|
||||
private val observerLock = Any()
|
||||
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
|
||||
private val registeredObservers = mutableSetOf<ContentObserver>()
|
||||
|
||||
private fun hasCalendarPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* Attach any not-yet-registered observers to the provider, but only once the
|
||||
* calendar permission is held. Registering a ContentObserver on the calendar
|
||||
* provider without that permission throws SecurityException on newer Android
|
||||
* (it used to silently no-op), which crashed the app at launch — the repo
|
||||
* registers its observer eagerly, before the permission gate. Called from the
|
||||
* observed reads ([calendars]/[instances]) so the observer re-attaches the
|
||||
* first time a screen queries after the permission is granted.
|
||||
*/
|
||||
private fun ensureObserversRegistered() {
|
||||
if (!hasCalendarPermission()) return
|
||||
synchronized(observerLock) {
|
||||
if (registeredObservers.size == observers.size) return
|
||||
observers.values.forEach(::registerObserverLocked)
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerObserverLocked(obs: ContentObserver) {
|
||||
if (obs in registeredObservers || !hasCalendarPermission()) return
|
||||
runCatching {
|
||||
resolver.registerContentObserver(CalendarContract.CONTENT_URI, true, obs)
|
||||
}.onSuccess { registeredObservers += obs }
|
||||
.onFailure { Log.w(TAG, "Calendar observer registration skipped", it) }
|
||||
}
|
||||
|
||||
override fun calendars(): List<CalendarSource> {
|
||||
ensureObserversRegistered()
|
||||
return resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
CalendarProjection.COLUMNS,
|
||||
null, null,
|
||||
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
||||
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar-row writes must address the provider as a sync adapter and name
|
||||
@@ -204,7 +304,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
putDescription(description)
|
||||
}
|
||||
val uri = resolver.insert(localCalendarsUri(), values)
|
||||
?: throw WriteFailedException("create local calendar '$name'")
|
||||
// No calendar name in the message — it can reach a crash report.
|
||||
?: throw WriteFailedException("create local calendar")
|
||||
return ContentUris.parseId(uri)
|
||||
}
|
||||
|
||||
@@ -241,7 +342,130 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||
}
|
||||
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): 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)
|
||||
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)
|
||||
// The marker that identifies this as our special-dates calendar,
|
||||
// independent of the (user-editable) display name. CAL_SYNC1 already
|
||||
// holds the description, so the marker lives in CAL_SYNC2.
|
||||
put(MANAGED_MARKER_COLUMN, markerFor(type))
|
||||
}
|
||||
val uri = resolver.insert(localCalendarsUri(), values)
|
||||
?: throw WriteFailedException("create managed calendar")
|
||||
return ContentUris.parseId(uri)
|
||||
}
|
||||
|
||||
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Calendars._ID, MANAGED_MARKER_COLUMN),
|
||||
"${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " +
|
||||
"${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
|
||||
"$MANAGED_MARKER_COLUMN IS NOT NULL",
|
||||
arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL, LOCAL_ACCOUNT_NAME),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
val type = typeForMarker(c.getString(1)) ?: continue
|
||||
add(ManagedCalendarRow(id = c.getLong(0), type = type))
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> = resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.UID_2445,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.RRULE,
|
||||
),
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||
arrayOf(calendarId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
val uid = c.getString(1)?.takeIf { it.isNotEmpty() } ?: continue
|
||||
add(
|
||||
ManagedEventRow(
|
||||
eventId = c.getLong(0),
|
||||
uid = uid,
|
||||
title = c.getString(2).orEmpty(),
|
||||
dtStartMillis = c.getLong(3),
|
||||
rrule = c.getString(4)?.takeIf { it.isNotEmpty() },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
|
||||
// Deterministic UID = the reconciliation key. A re-sync reads it back
|
||||
// (queryManagedEvents) and diffs on it, so a contact never doubles.
|
||||
val values = buildEventInsertValues(form, uid, form.toWriteTimes(ZoneId.systemDefault()))
|
||||
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||
?.let(ContentUris::parseId)
|
||||
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
|
||||
// Seeded once, then never re-touched by sync — so a user who moves a
|
||||
// birthday reminder keeps their change.
|
||||
seedReminders(eventId, form, allDayReminderTimeMinutes)
|
||||
return eventId
|
||||
}
|
||||
|
||||
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||
if (values.isEmpty()) return
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values.toContentValues(),
|
||||
null, null,
|
||||
)
|
||||
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
|
||||
}
|
||||
|
||||
override fun applyManagedCalendarReminders(
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
minutes: List<Int>,
|
||||
) {
|
||||
val zone = ZoneId.systemDefault()
|
||||
resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
|
||||
arrayOf(calendarId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) {
|
||||
val eventId = c.getLong(0)
|
||||
// Managed events are all-day: DTSTART is a UTC midnight.
|
||||
val startDate = Instant.ofEpochMilli(c.getLong(1))
|
||||
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val encoded = minutes
|
||||
.map { toProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) }
|
||||
.distinct()
|
||||
reconcileReminders(eventId, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||
ensureObserversRegistered()
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
ContentUris.appendId(this, beginMillis)
|
||||
ContentUris.appendId(this, endMillis)
|
||||
@@ -254,6 +478,89 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun searchEvents(query: String): List<EventInstance> {
|
||||
ensureObserversRegistered()
|
||||
val trimmed = query.trim()
|
||||
if (trimmed.isEmpty()) return emptyList()
|
||||
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
|
||||
// itself instead of acting as a wildcard.
|
||||
val escaped = trimmed
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
val like = "%$escaped%"
|
||||
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||
val selection = "($match) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||
return resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
SearchProjection.COLUMNS,
|
||||
selection,
|
||||
arrayOf(like, like, like),
|
||||
CalendarContract.Events.DTSTART + " DESC",
|
||||
)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
val out = ArrayList<EventInstance>(c.count)
|
||||
while (c.moveToNext()) {
|
||||
val base = reader.toSearchResult() ?: continue
|
||||
// A recurring master's DTSTART is the series start; show its
|
||||
// nearest occurrence instead so the date is the one the user
|
||||
// actually cares about (and sorting reflects it).
|
||||
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
|
||||
out += if (recurring) {
|
||||
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
||||
base.copy(
|
||||
start = begin.toKotlinInstantFromEpochMillis(),
|
||||
end = end.toKotlinInstantFromEpochMillis(),
|
||||
)
|
||||
} ?: base
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
out
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
||||
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
||||
* within the same window behind. Null when neither exists (e.g. a series
|
||||
* that only starts further out than the window) — the caller then keeps the
|
||||
* series-start DTSTART. Returns (begin, end) epoch millis.
|
||||
*/
|
||||
private fun nearestOccurrenceMillis(eventId: Long): Pair<Long, Long>? {
|
||||
val now = System.currentTimeMillis()
|
||||
return occurrenceInWindow(eventId, now, now + OCCURRENCE_WINDOW_MILLIS, soonestFirst = true)
|
||||
?: occurrenceInWindow(eventId, now - OCCURRENCE_WINDOW_MILLIS, now, soonestFirst = false)
|
||||
}
|
||||
|
||||
private fun occurrenceInWindow(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
endMillis: Long,
|
||||
soonestFirst: Boolean,
|
||||
): Pair<Long, Long>? {
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
ContentUris.appendId(this, beginMillis)
|
||||
ContentUris.appendId(this, endMillis)
|
||||
}.build()
|
||||
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
|
||||
return resolver.query(
|
||||
uri,
|
||||
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
|
||||
"${CalendarContract.Instances.EVENT_ID} = ?",
|
||||
arrayOf(eventId.toString()),
|
||||
order,
|
||||
)?.use { c ->
|
||||
if (!c.moveToFirst()) null else c.getLong(0) to c.getLong(1)
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventDetail(eventId: Long): EventDetail? {
|
||||
val attendees = queryAttendees(eventId)
|
||||
val reminders = queryReminders(eventId)
|
||||
@@ -440,35 +747,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
.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 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).
|
||||
val values = buildEventInsertValues(
|
||||
form,
|
||||
uid = "${UUID.randomUUID()}@calendula",
|
||||
times = form.toWriteTimes(ZoneId.systemDefault()),
|
||||
).toContentValues().apply {
|
||||
// A null colour just leaves both columns unset (the event inherits
|
||||
// its calendar's colour), so only the key/raw cases are written.
|
||||
when {
|
||||
@@ -480,22 +767,32 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
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")
|
||||
}
|
||||
}
|
||||
seedReminders(eventId, form, allDayReminderTimeMinutes)
|
||||
// Guests are best-effort like reminders: a row that fails to attach is
|
||||
// logged, not surfaced as a failed create. Calendula never sends an
|
||||
// invitation — it only writes the rows; the backend decides delivery.
|
||||
insertAttendees(eventId, form.attendees)
|
||||
return eventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach [form]'s reminders to a freshly inserted event. Best effort
|
||||
* (spec §8): the event exists at this point — a reminder that fails to
|
||||
* attach is logged, not surfaced as a failed create.
|
||||
*/
|
||||
private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateEvent(
|
||||
eventId: Long,
|
||||
original: EventForm,
|
||||
@@ -522,6 +819,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
|
||||
}
|
||||
// Same untouched-set guard for guests: only reconcile when the form's
|
||||
// attendees actually changed, so unrelated edits never disturb the
|
||||
// organizer/resource rows the form doesn't model.
|
||||
if (updated.attendees.toSet() != original.attendees.toSet()) {
|
||||
reconcileAttendees(eventId, updated.attendees)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateOccurrence(
|
||||
@@ -544,6 +847,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
// 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))
|
||||
reconcileAttendees(exceptionId, form.attendees)
|
||||
return exceptionId
|
||||
}
|
||||
|
||||
@@ -677,6 +981,135 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
|
||||
private fun EventAttendee.key(): String = email.trim().lowercase()
|
||||
|
||||
/** Distinct guests by email; blanks dropped (we key and write on the address). */
|
||||
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
|
||||
val seen = HashSet<String>()
|
||||
return filter { it.email.isNotBlank() && seen.add(it.key()) }
|
||||
}
|
||||
|
||||
private fun attendeeValues(eventId: Long, attendee: EventAttendee): ContentValues =
|
||||
ContentValues().apply {
|
||||
put(CalendarContract.Attendees.EVENT_ID, eventId)
|
||||
put(CalendarContract.Attendees.ATTENDEE_EMAIL, attendee.email.trim())
|
||||
attendee.name.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Attendees.ATTENDEE_NAME, it) }
|
||||
// A plain guest — never the organizer (we don't fabricate scheduling
|
||||
// identity; the backend owns that). Invited = awaiting their reply.
|
||||
put(
|
||||
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE,
|
||||
)
|
||||
put(CalendarContract.Attendees.ATTENDEE_TYPE, attendee.providerType())
|
||||
put(
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
|
||||
)
|
||||
}
|
||||
|
||||
private fun EventAttendee.providerType(): Int = if (optional) {
|
||||
CalendarContract.Attendees.TYPE_OPTIONAL
|
||||
} else {
|
||||
CalendarContract.Attendees.TYPE_REQUIRED
|
||||
}
|
||||
|
||||
/** Best-effort attendee inserts (like reminders); emails are never logged. */
|
||||
private fun insertAttendees(eventId: Long, attendees: List<EventAttendee>) {
|
||||
attendees.distinctByEmail().forEach { attendee ->
|
||||
if (resolver.insert(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
attendeeValues(eventId, attendee),
|
||||
) == null
|
||||
) {
|
||||
Log.w(TAG, "Failed to attach a guest to event $eventId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class AttendeeRow(
|
||||
val id: Long,
|
||||
val email: String,
|
||||
val relationship: Int,
|
||||
val type: Int,
|
||||
) {
|
||||
val key: String get() = email.trim().lowercase()
|
||||
}
|
||||
|
||||
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
arrayOf(
|
||||
CalendarContract.Attendees._ID,
|
||||
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||
CalendarContract.Attendees.ATTENDEE_TYPE,
|
||||
),
|
||||
CalendarContract.Attendees.EVENT_ID + " = ?",
|
||||
arrayOf(eventId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
add(
|
||||
AttendeeRow(
|
||||
id = c.getLong(0),
|
||||
email = c.getString(1).orEmpty(),
|
||||
relationship = c.getInt(2),
|
||||
type = c.getInt(3),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
/**
|
||||
* Make the event's *editable* guest rows match [attendees]. Only rows we
|
||||
* own — has an email, not the organizer, not a resource — are reconciled;
|
||||
* everything else the backend put there is left untouched. Matched rows keep
|
||||
* their response status (only the required/optional type is updated if it
|
||||
* changed); dropped rows are deleted; new guests inserted. Calendula sends
|
||||
* nothing — the backend decides if anyone is notified on sync.
|
||||
*/
|
||||
private fun reconcileAttendees(eventId: Long, attendees: List<EventAttendee>) {
|
||||
val target = attendees.distinctByEmail()
|
||||
val targetByKey = target.associateBy { it.key() }
|
||||
val editable = queryAttendeeRows(eventId).filter {
|
||||
it.email.isNotBlank() &&
|
||||
it.relationship != CalendarContract.Attendees.RELATIONSHIP_ORGANIZER &&
|
||||
it.type != CalendarContract.Attendees.TYPE_RESOURCE
|
||||
}
|
||||
val editableKeys = editable.map { it.key }.toSet()
|
||||
editable.forEach { row ->
|
||||
val want = targetByKey[row.key]
|
||||
if (want == null) {
|
||||
resolver.delete(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
CalendarContract.Attendees._ID + " = ?",
|
||||
arrayOf(row.id.toString()),
|
||||
)
|
||||
} else if (row.type != want.providerType()) {
|
||||
resolver.update(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
ContentValues().apply {
|
||||
put(CalendarContract.Attendees.ATTENDEE_TYPE, want.providerType())
|
||||
},
|
||||
CalendarContract.Attendees._ID + " = ?",
|
||||
arrayOf(row.id.toString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
target.filterNot { it.key() in editableKeys }.forEach { attendee ->
|
||||
if (resolver.insert(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
attendeeValues(eventId, attendee),
|
||||
) == null
|
||||
) {
|
||||
Log.w(TAG, "Failed to attach a guest to event $eventId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
||||
ContentValues().also { cv ->
|
||||
forEach { (column, value) ->
|
||||
@@ -685,7 +1118,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
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")
|
||||
// Only the type, never the value — a cell value can be event content.
|
||||
else -> error("Unsupported value type for column '$column': ${value::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -718,16 +1152,20 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
listener()
|
||||
}
|
||||
}
|
||||
observers[listener] = obs
|
||||
resolver.registerContentObserver(
|
||||
CalendarContract.CONTENT_URI,
|
||||
/* notifyForDescendants = */ true,
|
||||
obs,
|
||||
)
|
||||
synchronized(observerLock) {
|
||||
observers[listener] = obs
|
||||
// Attach now if we already hold the permission; otherwise it stays
|
||||
// pending and re-attaches on the first read after the grant.
|
||||
registerObserverLocked(obs)
|
||||
}
|
||||
}
|
||||
|
||||
override fun unregisterChangeListener(listener: () -> Unit) {
|
||||
observers.remove(listener)?.let { resolver.unregisterContentObserver(it) }
|
||||
synchronized(observerLock) {
|
||||
observers.remove(listener)?.let { obs ->
|
||||
if (registeredObservers.remove(obs)) resolver.unregisterContentObserver(obs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(
|
||||
@@ -758,6 +1196,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
||||
}
|
||||
|
||||
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
|
||||
private fun markerFor(type: SpecialDateType): String = when (type) {
|
||||
SpecialDateType.Birthday -> "$MANAGED_MARKER_PREFIX:birthday"
|
||||
SpecialDateType.Anniversary -> "$MANAGED_MARKER_PREFIX:anniversary"
|
||||
SpecialDateType.Custom -> "$MANAGED_MARKER_PREFIX:custom"
|
||||
}
|
||||
|
||||
/** Inverse of [markerFor]; null for a value that isn't one of ours. */
|
||||
private fun typeForMarker(marker: String?): SpecialDateType? =
|
||||
SpecialDateType.entries.firstOrNull { markerFor(it) == marker }
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CalendarDataSource"
|
||||
|
||||
@@ -766,5 +1215,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
* together (by account) in the filter sheet and calendar manager.
|
||||
*/
|
||||
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
||||
|
||||
/**
|
||||
* Column and namespace for the special-dates marker. CAL_SYNC1 already
|
||||
* holds the local-calendar description, so the marker uses CAL_SYNC2.
|
||||
*/
|
||||
val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
|
||||
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
|
||||
|
||||
/**
|
||||
* How far ahead/behind a search looks for a recurring event's nearest
|
||||
* occurrence (~2 years). Wide enough for everyday series; a series that
|
||||
* next fires beyond it falls back to its series-start date.
|
||||
*/
|
||||
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ interface CalendarRepository {
|
||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||
suspend fun eventDetail(eventId: Long): EventDetail
|
||||
|
||||
/**
|
||||
* Events whose title, description or location contains [query], with hidden
|
||||
* calendars removed and newest first. Empty when [query] is blank. Searches
|
||||
* the whole history/future (see [CalendarDataSource.searchEvents]).
|
||||
*/
|
||||
suspend fun searchEvents(query: String): List<EventInstance>
|
||||
|
||||
/**
|
||||
* The event-colour palette a calendar's account publishes; empty when it
|
||||
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
@@ -55,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
.reQuery { dataSource.calendars() }
|
||||
.flowOn(io)
|
||||
|
||||
// Instances are filtered by the app-side hidden-calendar set (M3): an event
|
||||
// is dropped whenever the user has hidden its calendar. Re-runs when the
|
||||
// provider ticks *or* the hidden set changes — toggling a calendar in the
|
||||
// filter sheet updates every view immediately. [calendars] stays unfiltered
|
||||
// so the filter sheet can list and re-enable hidden calendars.
|
||||
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
||||
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
||||
// calendar. Re-runs when the provider ticks *or* either set changes —
|
||||
// toggling a calendar in the filter sheet or the calendar manager updates
|
||||
// every view immediately. [calendars] stays unfiltered so those screens can
|
||||
// list and re-enable hidden/disabled calendars.
|
||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||
combine(
|
||||
ticks
|
||||
@@ -71,15 +73,29 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
)
|
||||
},
|
||||
prefs.hiddenCalendarIds,
|
||||
) { instances, hidden ->
|
||||
if (hidden.isEmpty()) instances
|
||||
else instances.filterNot { it.calendarId in hidden }
|
||||
}.flowOn(io)
|
||||
prefs.disabledCalendarIds,
|
||||
) { instances, hidden, disabled ->
|
||||
val excluded = hidden + disabled
|
||||
if (excluded.isEmpty()) instances
|
||||
else instances.filterNot { it.calendarId in excluded }
|
||||
}
|
||||
// hidden and disabled both derive from one DataStore, so toggling
|
||||
// either makes both re-emit and combine briefly surfaces the same
|
||||
// list twice — collapse the duplicate so views don't re-render for it.
|
||||
.distinctUntilChanged()
|
||||
.flowOn(io)
|
||||
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
|
||||
dataSource.searchEvents(query)
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||
}
|
||||
|
||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
||||
|
||||
|
||||
@@ -49,6 +49,42 @@ internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (
|
||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||
}
|
||||
|
||||
/**
|
||||
* Column values for a brand-new Events row: identity, times (the provider's
|
||||
* invariant — recurring rows carry RRULE+DURATION and no DTEND, one-off rows
|
||||
* carry DTEND), availability/access level and trimmed optional text. Shared by
|
||||
* the user-event and managed-event insert paths, which differ only in the
|
||||
* [uid] they stamp; colour and attendees are user-event concerns the caller
|
||||
* layers on top.
|
||||
*/
|
||||
internal fun buildEventInsertValues(
|
||||
form: EventForm,
|
||||
uid: String,
|
||||
times: EventWriteTimes,
|
||||
): Map<String, Any?> = buildMap {
|
||||
put(
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||
)
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dirty-checked column values for updating an existing Events row: only what
|
||||
* the user actually changed is written, so untouched fields can't stomp
|
||||
@@ -118,8 +154,14 @@ internal fun buildEventUpdateValues(
|
||||
* 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.
|
||||
* clone starts from the parent's values).
|
||||
*
|
||||
* The occurrence's length travels as DURATION, never DTEND: the provider
|
||||
* rejects DTEND on an exception outright (`CalendarProvider2`:
|
||||
* "Exceptions can't overwrite dtend") and derives the instance end from
|
||||
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
||||
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
||||
* "only this event" fail on-device (Codeberg #16).
|
||||
*/
|
||||
internal fun buildOccurrenceExceptionValues(
|
||||
form: EventForm,
|
||||
@@ -131,7 +173,7 @@ internal fun buildOccurrenceExceptionValues(
|
||||
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.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())
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
|
||||
/**
|
||||
* One of the app's special-dates calendars, discovered by the marker stamped in
|
||||
* its `CAL_SYNC2` column — so the mirror can re-adopt its calendars even if the
|
||||
* stored ids in preferences were lost (e.g. an app-data wipe).
|
||||
*/
|
||||
data class ManagedCalendarRow(val id: Long, val type: SpecialDateType)
|
||||
|
||||
/**
|
||||
* A managed event as read back for the sync diff. [uid] is the deterministic
|
||||
* `Events.UID_2445` (`contact-<type>:<lookupKey>@calendula`) the mirror keys on;
|
||||
* [title], [dtStartMillis] and [rrule] are the managed columns compared against
|
||||
* the desired state to decide whether a targeted update is needed.
|
||||
*/
|
||||
data class ManagedEventRow(
|
||||
val eventId: Long,
|
||||
val uid: String,
|
||||
val title: String,
|
||||
val dtStartMillis: Long,
|
||||
val rrule: String?,
|
||||
)
|
||||
@@ -139,6 +139,44 @@ internal object EventExportProjection {
|
||||
const val IDX_CALENDAR_ID = 13
|
||||
}
|
||||
|
||||
/**
|
||||
* Master/one-off Events rows matched by a full-text search. Like
|
||||
* [EventExportProjection] it reads the Events table directly (so the search is
|
||||
* unbounded in time), carrying DURATION for recurring rows that have no DTEND.
|
||||
* Colour folds the calendar fallback like [InstanceProjection].
|
||||
*/
|
||||
internal object SearchProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.EVENT_COLOR,
|
||||
CalendarContract.Events.CALENDAR_COLOR,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
// Recurrence markers: a non-empty RRULE or RDATE means the result should
|
||||
// display its nearest occurrence, not the series-start DTSTART.
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.RDATE,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
const val IDX_CALENDAR_ID = 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_COLOR = 7
|
||||
const val IDX_CALENDAR_COLOR = 8
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_RRULE = 10
|
||||
const val IDX_RDATE = 11
|
||||
}
|
||||
|
||||
internal object AttendeeProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
|
||||
/**
|
||||
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
|
||||
* Instances query this reads the series master, so there is no instance id (the
|
||||
* event id stands in as the list key) and recurring rows carry DURATION instead
|
||||
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
||||
*/
|
||||
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||
if (dtStart < 0L) return null
|
||||
val end = when {
|
||||
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||
}.coerceAtLeast(dtStart)
|
||||
|
||||
val rawTitle = getString(SearchProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||
} else {
|
||||
getInt(SearchProjection.IDX_EVENT_COLOR)
|
||||
}
|
||||
val eventId = getLong(SearchProjection.IDX_ID)
|
||||
|
||||
return EventInstance(
|
||||
instanceId = eventId,
|
||||
eventId = eventId,
|
||||
calendarId = getLong(SearchProjection.IDX_CALENDAR_ID),
|
||||
title = title,
|
||||
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||
end = end.toKotlinInstantFromEpochMillis(),
|
||||
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(SearchProjection.IDX_LOCATION),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Resolves the localized calendar names/templates and per-type colours the
|
||||
* mirror creates its calendars with. The colours are picked from the shared
|
||||
* palette so managed calendars look native alongside user calendars.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidSpecialDatesCalendarSpec @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : SpecialDatesCalendarSpec {
|
||||
|
||||
override fun displayName(type: SpecialDateType): String = context.getString(
|
||||
when (type) {
|
||||
SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday
|
||||
SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary
|
||||
SpecialDateType.Custom -> R.string.special_dates_calendar_custom
|
||||
},
|
||||
)
|
||||
|
||||
override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString(
|
||||
when (type) {
|
||||
SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday
|
||||
SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary
|
||||
SpecialDateType.Custom -> R.string.special_dates_default_title_custom
|
||||
},
|
||||
)
|
||||
|
||||
override fun color(type: SpecialDateType): Int = when (type) {
|
||||
SpecialDateType.Birthday -> CalendarColorPalette.Purple
|
||||
SpecialDateType.Anniversary -> CalendarColorPalette.Red
|
||||
SpecialDateType.Custom -> CalendarColorPalette.Blue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.Event
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Reads the dated `Event` rows (birthdays, anniversaries, custom dates) of the
|
||||
* device's contacts. Read-only and offline — the one-way source for the
|
||||
* special-dates mirror. Requires `READ_CONTACTS`; returns an empty list when
|
||||
* the permission is absent so the sync can degrade to a stalled state rather
|
||||
* than crash.
|
||||
*/
|
||||
interface ContactSpecialDatesDataSource {
|
||||
fun hasPermission(): Boolean
|
||||
|
||||
/** All usable contact special-dates, deduplicated per contact and type. */
|
||||
fun readSpecialDates(): List<ContactSpecialDate>
|
||||
}
|
||||
|
||||
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
|
||||
fun Context.hasContactsPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
|
||||
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
|
||||
else -> SpecialDateType.Custom
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ContactSpecialDatesDataSource {
|
||||
|
||||
override fun hasPermission(): Boolean = context.hasContactsPermission()
|
||||
|
||||
override fun readSpecialDates(): List<ContactSpecialDate> {
|
||||
if (!hasPermission()) return emptyList()
|
||||
val resolver = context.contentResolver
|
||||
// A contact can carry the same date more than once (multiple raw
|
||||
// contacts under one aggregate); keep the first per (contact, type).
|
||||
val seen = HashSet<Pair<String, SpecialDateType>>()
|
||||
val result = ArrayList<ContactSpecialDate>()
|
||||
runCatching {
|
||||
resolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
PROJECTION,
|
||||
"${ContactsContract.Data.MIMETYPE} = ?",
|
||||
arrayOf(Event.CONTENT_ITEM_TYPE),
|
||||
null,
|
||||
)?.use { c ->
|
||||
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
||||
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
||||
val idxLabel = c.getColumnIndexOrThrow(Event.LABEL)
|
||||
val idxLookup = c.getColumnIndexOrThrow(ContactsContract.Data.LOOKUP_KEY)
|
||||
val idxName = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)
|
||||
while (c.moveToNext()) {
|
||||
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
|
||||
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
|
||||
val type = specialDateTypeForRawEventType(c.getInt(idxType))
|
||||
if (!seen.add(lookup to type)) continue
|
||||
result += ContactSpecialDate(
|
||||
lookupKey = lookup,
|
||||
displayName = c.getString(idxName)?.trim().orEmpty(),
|
||||
type = type,
|
||||
month = parts.month,
|
||||
day = parts.day,
|
||||
year = parts.year,
|
||||
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
||||
return result
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ContactSpecialDates"
|
||||
|
||||
val PROJECTION = arrayOf(
|
||||
ContactsContract.Data.LOOKUP_KEY,
|
||||
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
|
||||
Event.START_DATE,
|
||||
Event.TYPE,
|
||||
Event.LABEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Schedules the contact special-dates mirror. Birthdays change rarely, so this
|
||||
* is deliberately cheap: a **daily** periodic reconcile, plus an immediate run
|
||||
* on enable / "Sync now" and a debounced one when the app is foregrounded. No
|
||||
* ContentObserver — it would need the process alive and buys almost nothing for
|
||||
* once-a-year events. Everything stays offline (local contacts → local calendar).
|
||||
*/
|
||||
object SpecialDatesScheduler {
|
||||
|
||||
private const val WORK_NAME = "special-dates-sync"
|
||||
private const val WORK_NAME_NOW = "special-dates-sync-now"
|
||||
private const val KEY_FOREGROUND = "foreground"
|
||||
|
||||
/** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */
|
||||
fun apply(context: Context, enabled: Boolean) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
if (!enabled) {
|
||||
workManager.cancelUniqueWork(WORK_NAME)
|
||||
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||
return
|
||||
}
|
||||
val request = PeriodicWorkRequestBuilder<SpecialDatesSyncWorker>(1, TimeUnit.DAYS)
|
||||
// Delay the first periodic run so it never overlaps an immediate run.
|
||||
.setInitialDelay(1, TimeUnit.DAYS)
|
||||
.build()
|
||||
workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one reconcile immediately. [foreground] runs are debounced (the app can
|
||||
* resume often); enable/"Sync now" runs ([foreground] false) always sync.
|
||||
*/
|
||||
fun runNow(context: Context, foreground: Boolean = false) {
|
||||
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
|
||||
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(WORK_NAME_NOW, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
internal const val INPUT_FOREGROUND = KEY_FOREGROUND
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the [SpecialDatesSyncEngine]. Pulls its collaborators through a Hilt
|
||||
* [EntryPoint] so it works under WorkManager's default worker factory. Records
|
||||
* the run for the settings status line; a missing permission parks the feature
|
||||
* in a stalled state (surfaced in settings) rather than retrying forever.
|
||||
*/
|
||||
class SpecialDatesSyncWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun settingsPrefs(): SettingsPrefs
|
||||
fun syncEngine(): SpecialDatesSyncEngine
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
val prefs = deps.settingsPrefs()
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
// A foreground resume can fire often — skip if we synced recently.
|
||||
val foreground = inputData.getBoolean(SpecialDatesScheduler.INPUT_FOREGROUND, false)
|
||||
if (foreground && now - prefs.specialDatesLastForegroundSync.first() < FOREGROUND_DEBOUNCE_MILLIS) {
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
return try {
|
||||
when (val result = deps.syncEngine().sync()) {
|
||||
// The engine re-checks the toggle, so already-queued work never
|
||||
// revives a disabled feature; don't record a run either.
|
||||
SpecialDatesSyncResult.Disabled -> return Result.success()
|
||||
else -> {
|
||||
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
|
||||
SpecialDatesStalledReason.PermissionRevoked
|
||||
} else {
|
||||
null
|
||||
}
|
||||
prefs.recordSpecialDatesRun(now, stalled)
|
||||
}
|
||||
}
|
||||
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Special-dates sync failed", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SpecialDatesSync"
|
||||
|
||||
/** Skip a foreground-triggered sync if one ran within this window (4h). */
|
||||
private const val FOREGROUND_DEBOUNCE_MILLIS = 4L * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.managedEventUid
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
|
||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Outcome of one mirror reconcile, reported back so the worker can record status. */
|
||||
enum class SpecialDatesSyncResult { Success, Disabled, PermissionMissing }
|
||||
|
||||
/**
|
||||
* Localized, per-type presentation the engine needs but can't derive purely
|
||||
* (calendar display name, colour, and the default title template). Kept behind
|
||||
* an interface so the engine's diff stays unit-testable without Android
|
||||
* resources.
|
||||
*/
|
||||
interface SpecialDatesCalendarSpec {
|
||||
fun displayName(type: SpecialDateType): String
|
||||
fun color(type: SpecialDateType): Int
|
||||
fun defaultTitleTemplate(type: SpecialDateType): String
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored title template for [type], falling back to the localized default
|
||||
* when blank/absent — the one resolution rule shared by the sync (what gets
|
||||
* written) and the settings editor (what gets shown).
|
||||
*/
|
||||
fun SpecialDatesCalendarSpec.resolveTitleTemplate(
|
||||
type: SpecialDateType,
|
||||
stored: Map<SpecialDateType, String>,
|
||||
): String = stored[type]?.takeIf { it.isNotBlank() } ?: defaultTitleTemplate(type)
|
||||
|
||||
/**
|
||||
* Mirrors contact birthdays/anniversaries/custom dates into local calendars,
|
||||
* one per type. Every reconcile is an idempotent diff keyed on the deterministic
|
||||
* `UID_2445` ([managedEventUid]): new contacts are inserted (seeding user-owned
|
||||
* fields once), changed contacts get a *targeted* managed-column update, and
|
||||
* removed contacts are deleted — so a user's own edits (reminders, location,
|
||||
* notes) are never clobbered. See docs/design/contact-special-dates.md.
|
||||
*/
|
||||
@Singleton
|
||||
class SpecialDatesSyncEngine @Inject constructor(
|
||||
private val contacts: ContactSpecialDatesDataSource,
|
||||
private val calendars: CalendarDataSource,
|
||||
private val prefs: SettingsPrefs,
|
||||
private val spec: SpecialDatesCalendarSpec,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Reconcile every enabled type against the device's contacts. Returns why it
|
||||
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
|
||||
*/
|
||||
suspend fun sync(): SpecialDatesSyncResult {
|
||||
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
|
||||
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
|
||||
|
||||
val enabledTypes = prefs.specialDatesTypes.first()
|
||||
val calendarByType = reconcileCalendars(enabledTypes)
|
||||
|
||||
val desiredByType = contacts.readSpecialDates().groupBy { it.type }
|
||||
val reminderCtx = readReminderContext()
|
||||
val templates = prefs.specialDatesTitleTemplates.first()
|
||||
val showYear = prefs.specialDatesShowYear.first()
|
||||
|
||||
calendarByType.forEach { (type, calendarId) ->
|
||||
val template = spec.resolveTitleTemplate(type, templates)
|
||||
syncType(
|
||||
calendarId = calendarId,
|
||||
type = type,
|
||||
contactsOfType = desiredByType[type].orEmpty(),
|
||||
template = template,
|
||||
showYear = showYear,
|
||||
reminderCtx = reminderCtx,
|
||||
)
|
||||
}
|
||||
return SpecialDatesSyncResult.Success
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the reminder default for a type's managed calendar and apply it to
|
||||
* **all** its existing events too (not just future ones) — for managed
|
||||
* calendars the reminder is a calendar-level setting. Persists the per-calendar
|
||||
* all-day override so new events keep matching. No-op if the calendar for
|
||||
* [type] doesn't exist yet.
|
||||
*/
|
||||
suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
||||
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||
val minutes = when (override) {
|
||||
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||
CalendarReminderOverride.None -> emptyList()
|
||||
is CalendarReminderOverride.Minutes -> override.minutes
|
||||
}
|
||||
calendars.applyManagedCalendarReminders(
|
||||
calendarId = calendarId,
|
||||
allDayReminderTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||
minutes = minutes,
|
||||
)
|
||||
}
|
||||
|
||||
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
|
||||
suspend fun teardown() {
|
||||
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
|
||||
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure each enabled type has exactly one managed calendar (adopting an
|
||||
* existing one, or the stored id if it still exists, else creating one) and
|
||||
* that disabled types have none. Returns the calendar id per enabled type.
|
||||
*/
|
||||
private suspend fun reconcileCalendars(enabledTypes: Set<SpecialDateType>): Map<SpecialDateType, Long> {
|
||||
val foundByType = calendars.findManagedCalendars()
|
||||
.groupBy({ it.type }, { it.id })
|
||||
.mapValues { it.value.first() }
|
||||
val stored = prefs.specialDatesCalendars.first()
|
||||
val result = LinkedHashMap<SpecialDateType, Long>()
|
||||
for (type in SpecialDateType.entries) {
|
||||
// A stored id counts only if the calendar still exists (the user may
|
||||
// have deleted it in system settings); otherwise adopt a found one.
|
||||
val existingId = stored[type]?.takeIf { id -> foundByType.containsValue(id) }
|
||||
?: foundByType[type]
|
||||
if (type in enabledTypes) {
|
||||
val id = existingId ?: createCalendar(type)
|
||||
if (stored[type] != id) prefs.setSpecialDatesCalendarId(type, id)
|
||||
result[type] = id
|
||||
} else {
|
||||
if (existingId != null) calendars.deleteCalendar(existingId)
|
||||
if (stored.containsKey(type)) prefs.setSpecialDatesCalendarId(type, null)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun createCalendar(type: SpecialDateType): Long {
|
||||
// reconcileCalendars persists the id — the single place ids are recorded.
|
||||
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type)
|
||||
// Seed a useful all-day reminder default (on the day + a week before), so
|
||||
// birthdays get lead time out of the box. Per-calendar, user-adjustable —
|
||||
// only set when the user hasn't already configured this calendar.
|
||||
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||
prefs.setCalendarAllDayReminderOverride(
|
||||
id,
|
||||
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||
)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
private suspend fun syncType(
|
||||
calendarId: Long,
|
||||
type: SpecialDateType,
|
||||
contactsOfType: List<ContactSpecialDate>,
|
||||
template: String,
|
||||
showYear: Boolean,
|
||||
reminderCtx: ReminderContext,
|
||||
) {
|
||||
val built = contactsOfType.map { sd ->
|
||||
buildManagedEvent(calendarId, type, sd, template, showYear, reminderCtx)
|
||||
}
|
||||
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
|
||||
val formByUid = built.associate { it.desired.uid to it.form }
|
||||
diff.insertUids.forEach { uid ->
|
||||
calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes)
|
||||
}
|
||||
diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) }
|
||||
diff.deleteEventIds.forEach { calendars.deleteEvent(it) }
|
||||
}
|
||||
|
||||
private fun buildManagedEvent(
|
||||
calendarId: Long,
|
||||
type: SpecialDateType,
|
||||
sd: ContactSpecialDate,
|
||||
template: String,
|
||||
showYear: Boolean,
|
||||
reminderCtx: ReminderContext,
|
||||
): BuiltManagedEvent {
|
||||
val anchor = sd.anchorDate()
|
||||
val start = LocalDateTime(anchor, LocalTime(0, 0))
|
||||
// The source year is static and correct on every occurrence (unlike age).
|
||||
val year = if (showYear) sd.year else null
|
||||
val title = renderSpecialDateTitle(template, sd.displayName, year)
|
||||
val form = EventForm(
|
||||
calendarId = calendarId,
|
||||
title = title,
|
||||
isAllDay = true,
|
||||
start = start,
|
||||
end = start,
|
||||
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||
availability = Availability.Free,
|
||||
rrule = YEARLY_RRULE,
|
||||
)
|
||||
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
|
||||
return BuiltManagedEvent(
|
||||
desired = ManagedEventDesired(
|
||||
uid = managedEventUid(type, sd.lookupKey),
|
||||
title = title,
|
||||
dtStartMillis = dtStartMillis,
|
||||
rrule = YEARLY_RRULE,
|
||||
),
|
||||
form = form,
|
||||
)
|
||||
}
|
||||
|
||||
// Managed events are always all-day, so only the all-day defaults apply.
|
||||
private suspend fun readReminderContext(): ReminderContext = ReminderContext(
|
||||
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
|
||||
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
|
||||
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||
)
|
||||
|
||||
private data class ReminderContext(
|
||||
val allDayGlobal: List<Int>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
val allDayTimeMinutes: Int,
|
||||
) {
|
||||
/** Same semantics as `resolveDefaultReminder`: present-empty = explicit none. */
|
||||
fun resolveAllDay(calendarId: Long): List<Int> =
|
||||
allDayOverrides[calendarId] ?: allDayGlobal
|
||||
}
|
||||
|
||||
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)
|
||||
|
||||
private companion object {
|
||||
/** "On the day" (0) + one week before (7 days), as all-day lead minutes. */
|
||||
val DEFAULT_REMINDER_MINUTES = listOf(0, 7 * 24 * 60)
|
||||
val YEARLY_RRULE = SimpleRecurrence(freq = RecurrenceFreq.Yearly).toRRule()
|
||||
}
|
||||
}
|
||||
|
||||
/** The managed columns of a desired event, compared against the existing row. */
|
||||
internal data class ManagedEventDesired(
|
||||
val uid: String,
|
||||
val title: String,
|
||||
val dtStartMillis: Long,
|
||||
val rrule: String?,
|
||||
)
|
||||
|
||||
internal data class ManagedFieldUpdate(val eventId: Long, val columns: Map<String, Any?>)
|
||||
|
||||
internal data class ManagedDiff(
|
||||
val insertUids: List<String>,
|
||||
val updates: List<ManagedFieldUpdate>,
|
||||
val deleteEventIds: List<Long>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The idempotent diff at the heart of the mirror, keyed on `UID_2445`:
|
||||
* desired-not-existing → insert, existing-not-desired → delete, and for events
|
||||
* in both only the *changed* managed columns (title/dtstart/rrule) are emitted —
|
||||
* so a re-run with no contact changes produces nothing, and a managed update
|
||||
* never touches user-owned columns or reminder rows. Pure, so it's unit-tested.
|
||||
*/
|
||||
internal fun diffManagedEvents(
|
||||
desired: List<ManagedEventDesired>,
|
||||
existing: List<ManagedEventRow>,
|
||||
): ManagedDiff {
|
||||
val desiredByUid = desired.associateBy { it.uid }
|
||||
val existingByUid = existing.associateBy { it.uid }
|
||||
val insertUids = desired.filter { it.uid !in existingByUid }.map { it.uid }
|
||||
val deleteEventIds = existing.filter { it.uid !in desiredByUid }.map { it.eventId }
|
||||
val updates = buildList {
|
||||
for (d in desired) {
|
||||
val row = existingByUid[d.uid] ?: continue
|
||||
val columns = buildMap<String, Any?> {
|
||||
if (row.title != d.title) put(CalendarContract.Events.TITLE, d.title)
|
||||
if (row.dtStartMillis != d.dtStartMillis) {
|
||||
put(CalendarContract.Events.DTSTART, d.dtStartMillis)
|
||||
}
|
||||
if (row.rrule != d.rrule) put(CalendarContract.Events.RRULE, d.rrule)
|
||||
}
|
||||
if (columns.isNotEmpty()) add(ManagedFieldUpdate(row.eventId, columns))
|
||||
}
|
||||
}
|
||||
return ManagedDiff(insertUids, updates, deleteEventIds)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package de.jeanlucmakiola.calendula.data.crash
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
|
||||
* exception it writes a self-contained report to the app's private storage and
|
||||
* then chains to the platform's default handler, so the process still dies
|
||||
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
|
||||
* the app holds no `INTERNET` permission. The user submits the report later,
|
||||
* by hand, as a Gitea issue (see the ui/crash surfaces).
|
||||
*
|
||||
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
|
||||
* version, locale, time, and the stack trace — and **nothing else**: no device
|
||||
* identifiers, no account names, no calendar/event content, no logcat. The user
|
||||
* is always shown the full text before it leaves the device.
|
||||
*/
|
||||
object CrashReporter {
|
||||
|
||||
/**
|
||||
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
|
||||
* also catches crashes during startup. The handler swallows nothing — it
|
||||
* persists, then delegates to the previously-registered handler.
|
||||
*/
|
||||
fun install(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
// Capturing must never mask the original crash, so guard every step.
|
||||
runCatching {
|
||||
val now = System.currentTimeMillis()
|
||||
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
|
||||
recordCrashTime(appContext, now)
|
||||
}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** The persisted report from the last crash, or null if there is none. */
|
||||
fun pendingReport(context: Context): String? {
|
||||
val file = reportFile(context)
|
||||
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to surface the report unprompted (on the next launch): a report
|
||||
* exists and the user hasn't already waved this one away. Settings reaches
|
||||
* the report via [pendingReport] regardless, so "Not now" only stops the
|
||||
* auto-prompt — it doesn't discard the report.
|
||||
*/
|
||||
fun shouldPrompt(context: Context): Boolean =
|
||||
reportFile(context).exists() && !dismissedFile(context).exists()
|
||||
|
||||
/** Stop auto-prompting for the current report without discarding it. */
|
||||
fun dismissPrompt(context: Context) {
|
||||
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
|
||||
}
|
||||
|
||||
/** Drop the persisted report once the user has reported it (or from Settings). */
|
||||
fun clearReport(context: Context) {
|
||||
runCatching { reportFile(context).delete() }
|
||||
runCatching { dismissedFile(context).delete() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app appears to be in a startup crash-loop: at least
|
||||
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
|
||||
* can't be trusted to start, so the caller routes straight to the standalone
|
||||
* report screen instead of re-entering the crashing graph.
|
||||
*/
|
||||
fun isCrashLoop(context: Context): Boolean {
|
||||
val times = readCrashTimes(context)
|
||||
if (times.size < LOOP_THRESHOLD) return false
|
||||
val recent = times.sortedDescending()
|
||||
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the app as having started successfully, resetting the loop counter so
|
||||
* an ordinary single crash much later never trips loop detection. The
|
||||
* pending report itself is kept — only the timing trail is cleared.
|
||||
*/
|
||||
fun markHealthy(context: Context) {
|
||||
runCatching { timesFile(context).delete() }
|
||||
}
|
||||
|
||||
// --- persistence -------------------------------------------------------
|
||||
|
||||
private fun writeReport(context: Context, report: String) {
|
||||
val file = reportFile(context).apply { parentFile?.mkdirs() }
|
||||
file.writeText(report.take(MAX_REPORT_CHARS))
|
||||
// A fresh crash should prompt again, even if the previous one was waved away.
|
||||
runCatching { dismissedFile(context).delete() }
|
||||
}
|
||||
|
||||
private fun recordCrashTime(context: Context, nowMillis: Long) {
|
||||
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
|
||||
timesFile(context).apply { parentFile?.mkdirs() }
|
||||
.writeText(kept.joinToString("\n"))
|
||||
}
|
||||
|
||||
private fun readCrashTimes(context: Context): List<Long> {
|
||||
val file = timesFile(context)
|
||||
if (!file.exists()) return emptyList()
|
||||
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
|
||||
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
|
||||
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
|
||||
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
|
||||
|
||||
private const val CRASH_DIR = "crash"
|
||||
private const val REPORT_FILE = "last_crash.txt"
|
||||
private const val TIMES_FILE = "crash_times.txt"
|
||||
private const val DISMISSED_FILE = "dismissed"
|
||||
private const val MAX_TIMES = 5
|
||||
private const val MAX_REPORT_CHARS = 64 * 1024
|
||||
private const val LOOP_THRESHOLD = 2
|
||||
private const val LOOP_WINDOW_MS = 10_000L
|
||||
}
|
||||
|
||||
/**
|
||||
* The allowlist of non-personal facts that go into a crash report. Built from
|
||||
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
|
||||
*/
|
||||
data class CrashContext(
|
||||
val appVersionName: String,
|
||||
val appVersionCode: Long,
|
||||
val sdkInt: Int,
|
||||
val androidRelease: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val locale: String,
|
||||
) {
|
||||
companion object {
|
||||
fun from(context: Context): CrashContext {
|
||||
val pkg = runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
}.getOrNull()
|
||||
return CrashContext(
|
||||
appVersionName = pkg?.versionName ?: "?",
|
||||
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
androidRelease = Build.VERSION.RELEASE ?: "?",
|
||||
manufacturer = Build.MANUFACTURER ?: "?",
|
||||
model = Build.MODEL ?: "?",
|
||||
locale = Locale.getDefault().toLanguageTag(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
|
||||
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
|
||||
* tested. The leading marker doubles as the file's sanity check in
|
||||
* [CrashReporter.pendingReport].
|
||||
*/
|
||||
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
|
||||
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||
val time = runCatching {
|
||||
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
|
||||
}.getOrDefault(nowMillis.toString())
|
||||
return buildString {
|
||||
appendLine("Calendula crash report")
|
||||
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
|
||||
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
|
||||
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
|
||||
appendLine("Locale: ${ctx.locale}")
|
||||
appendLine("Time: $time")
|
||||
appendLine()
|
||||
appendLine("Stack trace:")
|
||||
append(trace)
|
||||
}
|
||||
}
|
||||
|
||||
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
@@ -14,6 +14,10 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -45,6 +49,18 @@ abstract class DataBindModule {
|
||||
abstract fun bindReminderAlertStore(
|
||||
impl: AndroidReminderAlertStore,
|
||||
): ReminderAlertStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindContactSpecialDatesDataSource(
|
||||
impl: AndroidContactSpecialDatesDataSource,
|
||||
): ContactSpecialDatesDataSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSpecialDatesCalendarSpec(
|
||||
impl: AndroidSpecialDatesCalendarSpec,
|
||||
): SpecialDatesCalendarSpec
|
||||
}
|
||||
|
||||
@Module
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.ics
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
@@ -24,7 +25,38 @@ class IcsExporter @Inject constructor(
|
||||
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")
|
||||
// Only the scheme — the full Uri can embed the user's chosen filename.
|
||||
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
||||
}
|
||||
|
||||
/**
|
||||
* Write [content] to [fileName] inside the persisted SAF tree [folder],
|
||||
* overwriting it if it already exists (the automatic-backup destination).
|
||||
* Requires a persisted write grant on [folder]. Throws on failure.
|
||||
*/
|
||||
fun writeToFolder(folder: Uri, fileName: String, content: String) {
|
||||
val dir = DocumentFile.fromTreeUri(context, folder)
|
||||
?: throw IOException("Backup folder is not accessible")
|
||||
if (!dir.exists() || !dir.canWrite()) {
|
||||
throw IOException("Backup folder is missing or not writable")
|
||||
}
|
||||
// Reuse the canonical file if present and clean up any "name (1).ics"
|
||||
// duplicates SAF may have created if two runs ever raced — so we always
|
||||
// converge on a single overwritten file.
|
||||
val base = fileName.substringBeforeLast('.')
|
||||
val ext = fileName.substringAfterLast('.', "")
|
||||
var target: DocumentFile? = null
|
||||
for (child in dir.listFiles()) {
|
||||
val name = child.name ?: continue
|
||||
when {
|
||||
name == fileName -> target = child
|
||||
name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete()
|
||||
}
|
||||
}
|
||||
val file = target
|
||||
?: dir.createFile(MIME_CALENDAR, fileName)
|
||||
?: throw IOException("Could not create backup file in the chosen folder")
|
||||
writeDocument(file.uri, content)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,5 +73,6 @@ class IcsExporter @Inject constructor(
|
||||
|
||||
private companion object {
|
||||
const val SHARE_DIR = "shared_ics"
|
||||
const val MIME_CALENDAR = "text/calendar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,31 @@ class CalendarPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App-side preference for "calendars the user has disabled in this app" — a
|
||||
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
|
||||
* every surface (drawer filter, event-form picker, import picker) and its
|
||||
* events never appear; it stays listed only in Settings → Calendars so it can
|
||||
* be re-enabled. Stored exactly like the hidden set; never touches the
|
||||
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
|
||||
*/
|
||||
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||
prefs[DISABLED_IDS_KEY].orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
|
||||
store.edit { prefs ->
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove(DISABLED_IDS_KEY)
|
||||
} else {
|
||||
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar the user last created an event in; preselected in the
|
||||
* event form. Null until the first event is created.
|
||||
@@ -53,6 +78,7 @@ class CalendarPrefs @Inject constructor(
|
||||
|
||||
companion object {
|
||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
|
||||
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,14 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -18,18 +24,52 @@ import javax.inject.Singleton
|
||||
/** Light/dark override. SYSTEM follows the device setting. */
|
||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||
|
||||
/** Week-start override. AUTO derives the first day from the active locale. */
|
||||
enum class WeekStartPref { AUTO, MONDAY, SUNDAY }
|
||||
/**
|
||||
* Week-start override. [Auto] derives the first day from the active locale;
|
||||
* [Day] pins a specific weekday (any of the seven).
|
||||
*/
|
||||
sealed interface WeekStartPref {
|
||||
data object Auto : WeekStartPref
|
||||
data class Day(val day: DayOfWeek) : WeekStartPref
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the preference to a concrete first-day-of-week. AUTO reads the
|
||||
* locale's convention (e.g. Monday in DE, Sunday in en-US).
|
||||
* Clock convention for time-of-day labels. AUTO follows the device's 24-hour
|
||||
* system setting; the others force a 12- or 24-hour clock app-wide.
|
||||
*/
|
||||
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
||||
|
||||
/**
|
||||
* How the Agenda screen treats events that have already finished today: [SHOW]
|
||||
* leaves them as-is (the historical behaviour), [DIM] fades them, [HIDE] drops
|
||||
* them from the list entirely.
|
||||
*/
|
||||
enum class PastEventDisplay { SHOW, DIM, HIDE }
|
||||
|
||||
/** Parse a stored [PastEventDisplay.name]; unknown/null falls back to [default]. */
|
||||
fun parsePastEventDisplay(
|
||||
stored: String?,
|
||||
default: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default
|
||||
|
||||
/**
|
||||
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
|
||||
* device's `DateFormat.is24HourFormat` value).
|
||||
*/
|
||||
fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
|
||||
TimeFormatPref.AUTO -> systemIs24Hour
|
||||
TimeFormatPref.TWELVE_HOUR -> false
|
||||
TimeFormatPref.TWENTY_FOUR_HOUR -> true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the preference to a concrete first-day-of-week. [WeekStartPref.Auto]
|
||||
* reads the locale's convention (e.g. Monday in DE, Sunday in en-US).
|
||||
*/
|
||||
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||
WeekStartPref.MONDAY -> DayOfWeek.MONDAY
|
||||
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
|
||||
is WeekStartPref.Day -> day
|
||||
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
|
||||
WeekStartPref.AUTO -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +94,7 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
|
||||
prefs[WEEK_START_KEY].toEnum(WeekStartPref.AUTO)
|
||||
parseWeekStart(prefs[WEEK_START_KEY])
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) {
|
||||
@@ -66,7 +106,101 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun setWeekStart(pref: WeekStartPref) {
|
||||
store.edit { it[WEEK_START_KEY] = pref.name }
|
||||
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
||||
}
|
||||
|
||||
/** Clock convention for time-of-day labels (v2.11). Defaults to AUTO (system). */
|
||||
val timeFormat: Flow<TimeFormatPref> = store.data.map { prefs ->
|
||||
prefs[TIME_FORMAT_KEY].toEnum(TimeFormatPref.AUTO)
|
||||
}
|
||||
|
||||
suspend fun setTimeFormat(pref: TimeFormatPref) {
|
||||
store.edit { it[TIME_FORMAT_KEY] = pref.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the week/day timeline draws a faint separator line at each hour
|
||||
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
|
||||
*/
|
||||
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[SHOW_HOUR_LINES_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setShowHourLines(enabled: Boolean) {
|
||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How the Agenda screen treats events that already ended today. Defaults to
|
||||
* [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming
|
||||
* or hiding.
|
||||
*/
|
||||
val pastEventDisplay: Flow<PastEventDisplay> = store.data.map { prefs ->
|
||||
prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW)
|
||||
}
|
||||
|
||||
suspend fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the month/week grids fade events that have already finished.
|
||||
* Defaults to OFF — independent of the Agenda's [pastEventDisplay].
|
||||
*/
|
||||
val dimCompletedEvents: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[DIM_COMPLETED_EVENTS_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setDimCompletedEvents(enabled: Boolean) {
|
||||
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||
* widget's [agendaWidgetRange].
|
||||
*/
|
||||
val agendaScreenRange: Flow<AgendaRange> = store.data.map { prefs ->
|
||||
parseAgendaRange(prefs[AGENDA_SCREEN_RANGE_KEY], AgendaRange.Month)
|
||||
}
|
||||
|
||||
suspend fun setAgendaScreenRange(range: AgendaRange) {
|
||||
store.edit { it[AGENDA_SCREEN_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
|
||||
/** How far ahead the agenda **widget** shows events (v2.11). Defaults to Month. */
|
||||
val agendaWidgetRange: Flow<AgendaRange> = store.data.map { prefs ->
|
||||
parseAgendaRange(prefs[AGENDA_WIDGET_RANGE_KEY], AgendaRange.Month)
|
||||
}
|
||||
|
||||
suspend fun setAgendaWidgetRange(range: AgendaRange) {
|
||||
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agenda shows its top range bar — the "showing …" header and
|
||||
* the session range switcher (v2.11). Default ON.
|
||||
*/
|
||||
val agendaShowRangeBar: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[AGENDA_SHOW_RANGE_BAR_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAgendaShowRangeBar(enabled: Boolean) {
|
||||
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||
* the historical hard-coded startup view — so existing users see no change
|
||||
* until they pick another. Also the bottom of the in-app view back stack, so
|
||||
* back from any other view returns here before exiting.
|
||||
*/
|
||||
val defaultView: Flow<CalendarView> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_VIEW_KEY].toEnum(CalendarView.Week)
|
||||
}
|
||||
|
||||
suspend fun setDefaultView(view: CalendarView) {
|
||||
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +221,20 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opening the new-event form focuses the title field and raises the
|
||||
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||
* gets a title, so this saves a tap; users who set the time/calendar first
|
||||
* can turn it off. Only the create form auto-focuses — editing never does.
|
||||
*/
|
||||
val autofocusEventTitle: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[AUTOFOCUS_EVENT_TITLE_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAutofocusEventTitle(enabled: Boolean) {
|
||||
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||
* for users whose only calendar app this is, reminders are essential; the
|
||||
@@ -129,35 +277,36 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* The default reminder lead times (minutes before start) prefilled on new
|
||||
* **timed** events. The empty list = 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 comma-joined list of minutes, or
|
||||
* "none"/empty for no reminder (also the unset state). A legacy single value
|
||||
* ("30") parses transparently to a one-element list. 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()
|
||||
val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_REMINDER_KEY].toReminderList()
|
||||
}
|
||||
|
||||
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||
suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The default reminder lead time prefilled on new **all-day** events, in
|
||||
* The default reminder lead times 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.
|
||||
* the timed one. Empty list = 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()
|
||||
val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
|
||||
}
|
||||
|
||||
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,16 +326,30 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the notification "Snooze" action defers a reminder, in minutes.
|
||||
* Tapping Snooze cancels the notification and re-posts it after this delay
|
||||
* via an exact alarm (the calendar provider won't re-fire). Default 10 min;
|
||||
* clamped to at least 1.
|
||||
*/
|
||||
val snoozeMinutes: Flow<Int> = store.data.map { prefs ->
|
||||
(prefs[SNOOZE_MINUTES_KEY] ?: DEFAULT_SNOOZE_MINUTES).coerceAtLeast(1)
|
||||
}
|
||||
|
||||
suspend fun setSnoozeMinutes(minutes: Int) {
|
||||
store.edit { it[SNOOZE_MINUTES_KEY] = minutes.coerceAtLeast(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].)
|
||||
* timed default for its new events: an **empty** list means "no reminder", a
|
||||
* non-empty list means those lead times. A calendar **absent** from the map
|
||||
* inherits the global default. Serialised as `id=value;id=value`, where a value
|
||||
* is a comma-joined minute list or `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 ->
|
||||
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
@@ -203,7 +366,7 @@ class SettingsPrefs @Inject constructor(
|
||||
* 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 ->
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
@@ -219,6 +382,141 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Automatic backup (issue #8) ------------------------------------
|
||||
|
||||
/** Whether periodic automatic `.ics` export of local calendars is on. Default OFF. */
|
||||
val autoBackupEnabled: Flow<Boolean> = store.data.map { it[AUTO_BACKUP_ENABLED_KEY] ?: false }
|
||||
|
||||
suspend fun setAutoBackupEnabled(enabled: Boolean) {
|
||||
store.edit { it[AUTO_BACKUP_ENABLED_KEY] = enabled }
|
||||
}
|
||||
|
||||
/** Export interval in minutes. Default daily; floored at [MIN_BACKUP_INTERVAL]. */
|
||||
val autoBackupIntervalMinutes: Flow<Long> = store.data.map { prefs ->
|
||||
(prefs[AUTO_BACKUP_INTERVAL_KEY] ?: DEFAULT_BACKUP_INTERVAL).coerceAtLeast(MIN_BACKUP_INTERVAL)
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupIntervalMinutes(minutes: Long) {
|
||||
store.edit { it[AUTO_BACKUP_INTERVAL_KEY] = minutes.coerceAtLeast(MIN_BACKUP_INTERVAL) }
|
||||
}
|
||||
|
||||
/** Persisted SAF tree Uri of the destination folder, or null if unset. */
|
||||
val autoBackupFolderUri: Flow<String?> = store.data.map { it[AUTO_BACKUP_FOLDER_KEY] }
|
||||
|
||||
suspend fun setAutoBackupFolderUri(uri: String?) {
|
||||
store.edit { prefs ->
|
||||
if (uri == null) prefs.remove(AUTO_BACKUP_FOLDER_KEY) else prefs[AUTO_BACKUP_FOLDER_KEY] = uri
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of the last automatic run, for the settings status line. */
|
||||
val autoBackupStatus: Flow<BackupStatus> = store.data.map { prefs ->
|
||||
BackupStatus(
|
||||
lastRun = prefs[AUTO_BACKUP_LAST_RUN_KEY] ?: 0L,
|
||||
lastSuccess = prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] ?: true,
|
||||
consecutiveFailures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
/** Record an automatic run's outcome; resets the failure streak on success. */
|
||||
suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) {
|
||||
store.edit { prefs ->
|
||||
prefs[AUTO_BACKUP_LAST_RUN_KEY] = atMillis
|
||||
prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] = success
|
||||
val failures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0
|
||||
prefs[AUTO_BACKUP_FAILURES_KEY] = if (success) 0 else failures + 1
|
||||
}
|
||||
}
|
||||
|
||||
// --- Contact special dates (issue #15) ------------------------------
|
||||
|
||||
/** Master switch for the contact special-dates mirror. Default OFF (opt-in). */
|
||||
val specialDatesEnabled: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_ENABLED_KEY] ?: false }
|
||||
|
||||
suspend fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||
store.edit { it[SPECIAL_DATES_ENABLED_KEY] = enabled }
|
||||
}
|
||||
|
||||
/** The date types the user wants mirrored (default: all three). */
|
||||
val specialDatesTypes: Flow<Set<SpecialDateType>> = store.data.map { prefs ->
|
||||
SpecialDateType.entries.filterTo(mutableSetOf()) { prefs[typeEnabledKey(it)] ?: true }
|
||||
}
|
||||
|
||||
suspend fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||
store.edit { it[typeEnabledKey(type)] = enabled }
|
||||
}
|
||||
|
||||
/** The managed calendar id per type, for the ones that currently exist. */
|
||||
val specialDatesCalendars: Flow<Map<SpecialDateType, Long>> = store.data.map { prefs ->
|
||||
SpecialDateType.entries.mapNotNull { type ->
|
||||
prefs[calendarIdKey(type)]?.let { type to it }
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
/** Every managed calendar id — the editor locks title/date/recurrence for their events. */
|
||||
val managedCalendarIds: Flow<Set<Long>> = specialDatesCalendars.map { it.values.toSet() }
|
||||
|
||||
suspend fun setSpecialDatesCalendarId(type: SpecialDateType, calendarId: Long?) {
|
||||
store.edit { prefs ->
|
||||
if (calendarId == null) prefs.remove(calendarIdKey(type)) else prefs[calendarIdKey(type)] = calendarId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The title template per type ("{name}'s birthday"); an empty string means
|
||||
* unset, so the settings screen can seed the localized default. `{name}` is
|
||||
* the contact name, `{age}` the age at the upcoming date (empty when the
|
||||
* birth year is unknown or [specialDatesShowAge] is off).
|
||||
*/
|
||||
val specialDatesTitleTemplates: Flow<Map<SpecialDateType, String>> = store.data.map { prefs ->
|
||||
SpecialDateType.entries.associateWith { prefs[titleTemplateKey(it)].orEmpty() }
|
||||
}
|
||||
|
||||
suspend fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||
store.edit { it[titleTemplateKey(type)] = template }
|
||||
}
|
||||
|
||||
/** Whether `{year}` resolves in the title (only meaningful when the year is known). Default ON. */
|
||||
val specialDatesShowYear: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true }
|
||||
|
||||
suspend fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||
store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/** Outcome of the last mirror sync, for the settings status line. */
|
||||
val specialDatesStatus: Flow<SpecialDatesStatus> = store.data.map { prefs ->
|
||||
SpecialDatesStatus(
|
||||
lastRun = prefs[SPECIAL_DATES_LAST_RUN_KEY] ?: 0L,
|
||||
stalled = prefs[SPECIAL_DATES_STALLED_KEY]
|
||||
?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */
|
||||
suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) {
|
||||
store.edit { prefs ->
|
||||
prefs[SPECIAL_DATES_LAST_RUN_KEY] = atMillis
|
||||
if (stalled == null) prefs.remove(SPECIAL_DATES_STALLED_KEY) else prefs[SPECIAL_DATES_STALLED_KEY] = stalled.name
|
||||
}
|
||||
}
|
||||
|
||||
/** Epoch millis of the last foreground-triggered sync, to debounce ON_RESUME. */
|
||||
val specialDatesLastForegroundSync: Flow<Long> =
|
||||
store.data.map { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] ?: 0L }
|
||||
|
||||
suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) {
|
||||
store.edit { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] = atMillis }
|
||||
}
|
||||
|
||||
private fun typeEnabledKey(type: SpecialDateType) =
|
||||
booleanPreferencesKey("special_dates_type_${type.name}")
|
||||
|
||||
private fun calendarIdKey(type: SpecialDateType) =
|
||||
longPreferencesKey("special_dates_calendar_${type.name}")
|
||||
|
||||
private fun titleTemplateKey(type: SpecialDateType) =
|
||||
stringPreferencesKey("special_dates_title_${type.name}")
|
||||
|
||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
@@ -230,7 +528,16 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||
@@ -243,82 +550,170 @@ class SettingsPrefs @Inject constructor(
|
||||
/** 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 SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
||||
/** Default snooze delay for the notification "Snooze" action. */
|
||||
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||
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)
|
||||
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||
internal val AUTO_BACKUP_LAST_RUN_KEY = longPreferencesKey("auto_backup_last_run")
|
||||
internal val AUTO_BACKUP_LAST_SUCCESS_KEY = booleanPreferencesKey("auto_backup_last_success")
|
||||
internal val AUTO_BACKUP_FAILURES_KEY = intPreferencesKey("auto_backup_failures")
|
||||
/** Default automatic-backup interval: daily. */
|
||||
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
||||
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
||||
const val MIN_BACKUP_INTERVAL = 30L
|
||||
|
||||
internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled")
|
||||
internal val SPECIAL_DATES_SHOW_YEAR_KEY = booleanPreferencesKey("special_dates_show_year")
|
||||
internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run")
|
||||
internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason")
|
||||
internal val SPECIAL_DATES_LAST_FG_SYNC_KEY =
|
||||
longPreferencesKey("special_dates_last_foreground_sync")
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of the special-dates mirror's last outcome (see [SettingsPrefs.specialDatesStatus]). */
|
||||
data class SpecialDatesStatus(
|
||||
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||
val lastRun: Long,
|
||||
/** Non-null when the mirror is paused and why; null when healthy. */
|
||||
val stalled: SpecialDatesStalledReason?,
|
||||
)
|
||||
|
||||
/** Why the special-dates mirror is paused. */
|
||||
enum class SpecialDatesStalledReason {
|
||||
/** READ_CONTACTS was revoked in system settings after the feature was enabled. */
|
||||
PermissionRevoked,
|
||||
}
|
||||
|
||||
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
||||
data class BackupStatus(
|
||||
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||
val lastRun: Long,
|
||||
val lastSuccess: Boolean,
|
||||
val consecutiveFailures: Int,
|
||||
)
|
||||
|
||||
/** 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
|
||||
/** Specific lead times in minutes before the event start (non-empty). */
|
||||
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead time to prefill on a new event: the matching per-calendar override
|
||||
* The stored override for [calendarId] in an override map, as a
|
||||
* [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit],
|
||||
* empty → [CalendarReminderOverride.None]) — the inverse of how
|
||||
* [SettingsPrefs.setCalendarReminderOverride] stores a choice.
|
||||
*/
|
||||
fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||
getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(getValue(calendarId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead times 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.
|
||||
* events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
|
||||
* Pure so it can be unit-tested.
|
||||
*/
|
||||
fun resolveDefaultReminder(
|
||||
timedGlobal: Int?,
|
||||
allDayGlobal: Int?,
|
||||
timedOverrides: Map<Long, Int?>,
|
||||
allDayOverrides: Map<Long, Int?>,
|
||||
timedGlobal: List<Int>,
|
||||
allDayGlobal: List<Int>,
|
||||
timedOverrides: Map<Long, List<Int>>,
|
||||
allDayOverrides: Map<Long, List<Int>>,
|
||||
calendarId: Long?,
|
||||
isAllDay: Boolean,
|
||||
): Int? {
|
||||
): List<Int> {
|
||||
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||
overrides[calendarId]
|
||||
overrides.getValue(calendarId)
|
||||
} else {
|
||||
global
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
|
||||
private fun MutableMap<Long, Int?>.applyOverride(
|
||||
/**
|
||||
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
|
||||
* key; [None] and an empty [Minutes] both store the empty list).
|
||||
*/
|
||||
private fun MutableMap<Long, List<Int>>.applyOverride(
|
||||
calendarId: Long,
|
||||
override: CalendarReminderOverride,
|
||||
) {
|
||||
when (override) {
|
||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||
CalendarReminderOverride.None -> put(calendarId, null)
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
||||
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
||||
}
|
||||
}
|
||||
|
||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||
private const val WEEK_START_AUTO = "AUTO"
|
||||
|
||||
private fun WeekStartPref.storageValue(): String = when (this) {
|
||||
WeekStartPref.Auto -> WEEK_START_AUTO
|
||||
is WeekStartPref.Day -> day.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the stored week-start value. "AUTO"/null/garbage → [WeekStartPref.Auto];
|
||||
* a [DayOfWeek] name → [WeekStartPref.Day]. The legacy "MONDAY"/"SUNDAY" enum
|
||||
* values migrate transparently, since both are valid day names.
|
||||
*/
|
||||
private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
|
||||
null, WEEK_START_AUTO -> WeekStartPref.Auto
|
||||
else -> DayOfWeek.entries.firstOrNull { it.name == stored }
|
||||
?.let { WeekStartPref.Day(it) } ?: WeekStartPref.Auto
|
||||
}
|
||||
|
||||
private const val NONE = "none"
|
||||
private const val ENTRY_SEP = ";"
|
||||
private const val KEY_VALUE_SEP = "="
|
||||
private const val LIST_SEP = ","
|
||||
|
||||
private fun String?.toReminderMinutes(): Int? = when (this) {
|
||||
null, "", NONE -> null
|
||||
else -> toIntOrNull()
|
||||
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||
|
||||
/**
|
||||
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||
* list; a comma-joined list → its minutes; a legacy single value ("30") → a
|
||||
* one-element list. Non-numeric parts are dropped defensively.
|
||||
*/
|
||||
private fun String?.toReminderList(): List<Int> = when {
|
||||
this == null || isEmpty() || this == NONE -> emptyList()
|
||||
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
|
||||
}
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
||||
/** Serialise lead times for storage: "none" when empty, else comma-joined. */
|
||||
private fun List<Int>.toStoredReminders(): String =
|
||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, List<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
|
||||
id to parts[1].toReminderList()
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
|
||||
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,6 +17,17 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The due alerts that should actually surface as notifications: everything
|
||||
* except alerts whose calendar the user has disabled in-app (mirroring the
|
||||
* event filtering in CalendarRepositoryImpl). The caller still marks the full
|
||||
* due set fired, so suppressed alerts are not re-broadcast by the provider.
|
||||
*/
|
||||
internal fun postableAlerts(
|
||||
due: List<ReminderAlert>,
|
||||
disabledCalendarIds: Set<Long>,
|
||||
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
|
||||
|
||||
/**
|
||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||
* visible notifications (the Etar model — the provider broadcasts
|
||||
@@ -32,6 +44,7 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var alertStore: ReminderAlertStore
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||
@@ -46,7 +59,11 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
if (settingsPrefs.remindersEnabled.first()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val due = alertStore.dueAlerts(now)
|
||||
due.forEach(notifier::post)
|
||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||
// Suppress reminders for disabled calendars, but still mark
|
||||
// every due alert fired so the provider stops re-broadcasting
|
||||
// the suppressed ones.
|
||||
postableAlerts(due, disabled).forEach { notifier.post(it) }
|
||||
alertStore.markFired(due.map { it.alertId }, now)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
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
|
||||
|
||||
/**
|
||||
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
|
||||
* the internal re-show when a snooze elapses. All three are app-internal
|
||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||
* alarm), so the receiver is not exported.
|
||||
*
|
||||
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
||||
* already fired, so nothing re-posts it.
|
||||
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||
* it after the user's snooze delay.
|
||||
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||
* or dismiss it again.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderActionReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var scheduler: ReminderSnoozeScheduler
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val alert = alertFrom(intent) ?: return
|
||||
when (intent.action) {
|
||||
ACTION_DISMISS -> notifier.cancel(alert)
|
||||
|
||||
ACTION_SNOOZE -> {
|
||||
// Cancel now so the notification doesn't linger until the alarm;
|
||||
// the snooze delay read is the only async work.
|
||||
notifier.cancel(alert)
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
val minutes = settingsPrefs.snoozeMinutes.first()
|
||||
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
|
||||
scheduler.schedule(alert, triggerAt)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_SHOW -> {
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||
notifier.post(alert)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_SNOOZE = "de.jeanlucmakiola.calendula.reminders.SNOOZE"
|
||||
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||
|
||||
private const val EXTRA_ALERT_ID = "alert_id"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
private const val EXTRA_BEGIN = "begin"
|
||||
private const val EXTRA_END = "end"
|
||||
private const val EXTRA_TITLE = "title"
|
||||
private const val EXTRA_LOCATION = "location"
|
||||
private const val EXTRA_ALL_DAY = "all_day"
|
||||
|
||||
/** An explicit intent to this receiver carrying [alert] as extras. */
|
||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
putExtra(EXTRA_LOCATION, alert.location)
|
||||
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable request code per (alert, action) so the three PendingIntents
|
||||
* of one notification stay distinct and don't clobber each other.
|
||||
*/
|
||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||
val actionOffset = when (action) {
|
||||
ACTION_SNOOZE -> 1
|
||||
ACTION_DISMISS -> 2
|
||||
ACTION_SHOW -> 3
|
||||
else -> 0
|
||||
}
|
||||
return alert.alertId.toInt() * 8 + actionOffset
|
||||
}
|
||||
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
||||
return ReminderAlert(
|
||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
||||
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||
location = intent.getStringExtra(EXTRA_LOCATION),
|
||||
isAllDay = intent.getBooleanExtra(EXTRA_ALL_DAY, false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import javax.inject.Singleton
|
||||
data class ReminderAlert(
|
||||
val alertId: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
@@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor(
|
||||
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,
|
||||
calendarId = c.getLong(2),
|
||||
beginMillis = c.getLong(3),
|
||||
endMillis = c.getLong(4),
|
||||
title = c.getString(5).orEmpty(),
|
||||
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(7) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor(
|
||||
val PROJECTION = arrayOf(
|
||||
CalendarContract.CalendarAlerts._ID,
|
||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||
CalendarContract.CalendarAlerts.BEGIN,
|
||||
CalendarContract.CalendarAlerts.END,
|
||||
CalendarContract.CalendarAlerts.TITLE,
|
||||
|
||||
@@ -14,6 +14,9 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -29,6 +32,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -39,15 +43,18 @@ class ReminderNotifier @Inject constructor(
|
||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
fun post(alert: ReminderAlert) {
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
ensureChannel()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val time = reminderTimeText(
|
||||
beginMillis = alert.beginMillis,
|
||||
endMillis = alert.endMillis,
|
||||
isAllDay = alert.isAllDay,
|
||||
zone = ZoneId.systemDefault(),
|
||||
locale = Locale.getDefault(),
|
||||
is24Hour = is24Hour,
|
||||
)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
@@ -61,6 +68,16 @@ class ReminderNotifier @Inject constructor(
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(detailIntent(alert))
|
||||
.addAction(
|
||||
R.drawable.ic_notification_snooze,
|
||||
context.getString(R.string.reminder_action_snooze),
|
||||
actionIntent(alert, ReminderActionReceiver.ACTION_SNOOZE),
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.ic_notification_dismiss,
|
||||
context.getString(R.string.reminder_action_dismiss),
|
||||
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||
)
|
||||
.build()
|
||||
try {
|
||||
NotificationManagerCompat.from(context)
|
||||
@@ -71,6 +88,19 @@ class ReminderNotifier @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||
fun cancel(alert: ReminderAlert) {
|
||||
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
ReminderActionReceiver.requestCode(alert, action),
|
||||
ReminderActionReceiver.intent(context, action, alert),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
/* requestCode = */ alert.alertId.toInt(),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
||||
*
|
||||
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
||||
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
||||
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
||||
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
||||
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
||||
* exact-alarm capability (API 31–32 where the user revoked it).
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderSnoozeScheduler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
fun schedule(alert: ReminderAlert, triggerAtMillis: Long) {
|
||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
|
||||
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
if (canScheduleExact(alarmManager)) {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
} else {
|
||||
// Exact alarms revoked (API 31–32): an inexact wake is the honest
|
||||
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
|
||||
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
|
||||
* (Calendula is a calendar app), user-revocable on 31–32.
|
||||
*/
|
||||
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
@@ -25,6 +26,7 @@ fun reminderTimeText(
|
||||
isAllDay: Boolean,
|
||||
zone: ZoneId,
|
||||
locale: Locale,
|
||||
is24Hour: Boolean,
|
||||
): String {
|
||||
if (isAllDay) {
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
@@ -40,16 +42,18 @@ fun reminderTimeText(
|
||||
}
|
||||
}
|
||||
|
||||
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
val timeFormat = timeOfDayFormatter(is24Hour, 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)
|
||||
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||
// applies to the time portion too.
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||
dateTime(begin) + RANGE + dateTime(end)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,27 @@ data class EventForm(
|
||||
*/
|
||||
val colorKey: String? = null,
|
||||
val color: Int? = null,
|
||||
/**
|
||||
* Guests the user has added/kept on the event. Calendula only writes these
|
||||
* `Attendees` rows — it has no INTERNET and never sends an invitation
|
||||
* itself; whether a guest is notified is decided downstream by the
|
||||
* calendar's backend (local: no one; CalDAV/Google: the server/account).
|
||||
* Read-only rows the form doesn't model (the organizer, resources) are
|
||||
* preserved by the data layer, not carried here.
|
||||
*/
|
||||
val attendees: List<EventAttendee> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One editable guest: the user controls the [email] (the identity we dedup and
|
||||
* write on), an optional display [name], and whether they're [optional] rather
|
||||
* than required. Response status and the organizer/resource distinction are not
|
||||
* user-editable, so they aren't modelled here.
|
||||
*/
|
||||
data class EventAttendee(
|
||||
val email: String,
|
||||
val name: String = "",
|
||||
val optional: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -55,6 +76,7 @@ enum class EventFormField {
|
||||
Availability,
|
||||
Visibility,
|
||||
Color,
|
||||
Attendees,
|
||||
}
|
||||
|
||||
enum class EventFormProblem {
|
||||
@@ -108,6 +130,24 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
// calendar's colour.
|
||||
colorKey = eventColorKey,
|
||||
color = eventColor,
|
||||
// Only editable guests ride in the form: drop the organizer and
|
||||
// resource rows (not user-editable) and any without an email (we key
|
||||
// edits and dedup on the address). The data layer preserves the rows we
|
||||
// don't carry here, so they're never clobbered on save.
|
||||
attendees = attendees
|
||||
.filter {
|
||||
it.relationship != AttendeeRelationship.Organizer &&
|
||||
it.type != AttendeeType.Resource
|
||||
}
|
||||
.mapNotNull { a ->
|
||||
a.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||
EventAttendee(
|
||||
email = email,
|
||||
name = a.name,
|
||||
optional = a.type == AttendeeType.Optional,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,9 +157,10 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
* 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.
|
||||
* event. Guests are covered (the form writes editable attendees), so an
|
||||
* external attendee change now also trips the conflict check. Still not
|
||||
* covered: status, the user's own response, reminder methods, the
|
||||
* organizer/resource rows, and a recurring event's duration.
|
||||
*/
|
||||
data class EditSnapshot(
|
||||
val form: EventForm,
|
||||
@@ -148,6 +189,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
||||
}
|
||||
|
||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||
|
||||
@@ -40,6 +40,14 @@ data class EventInstance(
|
||||
val location: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this event has finished relative to [now] — its end is at or before
|
||||
* the current instant. An in-progress event (already started but not yet ended)
|
||||
* is *not* considered ended. All-day events end at the exclusive next-midnight,
|
||||
* so they only count as ended once their day is fully over.
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.jeanlucmakiola.calendula.domain.contacts
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* The kinds of contact "special date" Calendula mirrors into local calendars.
|
||||
* Each kind gets its own local calendar, so it inherits per-calendar colour,
|
||||
* visibility and reminder defaults for free. See
|
||||
* docs/design/contact-special-dates.md.
|
||||
*/
|
||||
enum class SpecialDateType {
|
||||
Birthday,
|
||||
Anniversary,
|
||||
|
||||
/** Everything else — a contact "Event" that is neither birthday nor anniversary. */
|
||||
Custom,
|
||||
}
|
||||
|
||||
/**
|
||||
* One dated event read from a device contact (a `ContactsContract` `Event`
|
||||
* row). The [lookupKey] is the stable contact identity used to reconcile the
|
||||
* mirror without duplicating; [year] is null when the contact stored the date
|
||||
* without one (`--MM-dd`), in which case age can't be shown.
|
||||
*/
|
||||
data class ContactSpecialDate(
|
||||
val lookupKey: String,
|
||||
val displayName: String,
|
||||
val type: SpecialDateType,
|
||||
val month: Int,
|
||||
val day: Int,
|
||||
val year: Int?,
|
||||
/** The contact's custom label for a [SpecialDateType.Custom] date, if any. */
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
/** The parsed month/day (+ optional year) of a contact date. */
|
||||
data class ContactDateParts(val year: Int?, val month: Int, val day: Int)
|
||||
|
||||
/**
|
||||
* A fixed leap year to validate and anchor year-less dates against, so that a
|
||||
* `--02-29` birthday is representable (and, once anchored, only recurs in leap
|
||||
* years — matching how the calendar provider expands a yearly Feb-29 series).
|
||||
*/
|
||||
const val YEARLESS_ANCHOR_YEAR = 1972
|
||||
|
||||
/**
|
||||
* Parse a `ContactsContract.CommonDataKinds.Event.START_DATE` value into its
|
||||
* calendar parts, or null if it isn't a usable date. Handles the three shapes
|
||||
* seen in the wild:
|
||||
* - full `yyyy-MM-dd` (year known),
|
||||
* - year-less `--MM-dd` (year null),
|
||||
* - compact `yyyyMMdd`.
|
||||
*
|
||||
* A date that names an impossible day (e.g. `1999-02-29`) is rejected. Pure, so
|
||||
* it's unit-tested without a device.
|
||||
*/
|
||||
fun parseContactEventDate(raw: String?): ContactDateParts? {
|
||||
val s = raw?.trim().orEmpty()
|
||||
if (s.isEmpty()) return null
|
||||
|
||||
// Year-less: "--MM-dd" or "--MMdd".
|
||||
if (s.startsWith("--")) {
|
||||
val (m, d) = parseMonthDay(s.substring(2)) ?: return null
|
||||
return validated(null, m, d)
|
||||
}
|
||||
|
||||
if (s.contains('-')) {
|
||||
val parts = s.split('-').filter { it.isNotEmpty() }
|
||||
return when (parts.size) {
|
||||
// "yyyy-MM-dd" — but a leading '-' would have dropped the empty
|
||||
// first part, so require the first token to look like a year.
|
||||
3 -> if (s.startsWith('-')) null else validated(
|
||||
year = parts[0].toIntOrNull() ?: return null,
|
||||
month = parts[1].toIntOrNull() ?: return null,
|
||||
day = parts[2].toIntOrNull() ?: return null,
|
||||
)
|
||||
// Year-less "MM-dd" without the "--" prefix.
|
||||
2 -> validated(
|
||||
year = null,
|
||||
month = parts[0].toIntOrNull() ?: return null,
|
||||
day = parts[1].toIntOrNull() ?: return null,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// Compact "yyyyMMdd".
|
||||
if (s.length == 8 && s.all { it.isDigit() }) {
|
||||
return validated(
|
||||
year = s.substring(0, 4).toInt(),
|
||||
month = s.substring(4, 6).toInt(),
|
||||
day = s.substring(6, 8).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** "MM-dd" or compact "MMdd". */
|
||||
private fun parseMonthDay(rest: String): Pair<Int, Int>? {
|
||||
if (rest.contains('-')) {
|
||||
val p = rest.split('-').filter { it.isNotEmpty() }
|
||||
if (p.size != 2) return null
|
||||
return (p[0].toIntOrNull() ?: return null) to (p[1].toIntOrNull() ?: return null)
|
||||
}
|
||||
if (rest.length == 4 && rest.all { it.isDigit() }) {
|
||||
return rest.substring(0, 2).toInt() to rest.substring(2, 4).toInt()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm month/day form a real calendar date (validated against the actual
|
||||
* year when known, otherwise the leap anchor so `02-29` survives).
|
||||
*/
|
||||
private fun validated(year: Int?, month: Int, day: Int): ContactDateParts? {
|
||||
if (month !in 1..12 || day !in 1..31) return null
|
||||
val checkYear = year ?: YEARLESS_ANCHOR_YEAR
|
||||
return runCatching { LocalDate.of(checkYear, month, day) }
|
||||
.map { ContactDateParts(year, month, day) }
|
||||
.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.jeanlucmakiola.calendula.domain.contacts
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
||||
* contact date. Stable across syncs (the reconciliation key), and namespaced by
|
||||
* type so the same contact's birthday and anniversary never collide.
|
||||
*/
|
||||
fun managedEventUid(type: SpecialDateType, lookupKey: String): String =
|
||||
"contact-${type.name.lowercase()}:$lookupKey@calendula"
|
||||
|
||||
/**
|
||||
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
|
||||
* date when the year is known (so age can be derived), otherwise the month/day
|
||||
* on a fixed leap anchor year so a `--02-29` date stays representable and the
|
||||
* anchor never drifts between syncs.
|
||||
*/
|
||||
fun ContactSpecialDate.anchorDate(): LocalDate =
|
||||
LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day)
|
||||
|
||||
/**
|
||||
* Render a title [template] for a contact, substituting `{name}` and `{year}`
|
||||
* (the source year — a birthday's birth year or an anniversary's start year;
|
||||
* empty when [year] is null). Unlike an age, the year is static and correct on
|
||||
* every occurrence of the yearly event. Collapses the whitespace an empty
|
||||
* `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to
|
||||
* "Jane's birthday" when no year is known.
|
||||
*/
|
||||
fun renderSpecialDateTitle(template: String, name: String, year: Int?): String =
|
||||
template
|
||||
.replace("{name}", name)
|
||||
.replace("{year}", year?.toString().orEmpty())
|
||||
// Drop an empty "()" left by an unresolved {year}, then tidy spacing.
|
||||
.replace(Regex("""\(\s*\)"""), "")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.jeanlucmakiola.calendula.qs
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.TileService
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Quick Settings tile: tapping it opens the create-event form on today — the
|
||||
* same action as the launcher "New event" shortcut and the agenda widget's "+".
|
||||
* A stateless action tile, so there is no on/off state to keep in sync.
|
||||
*/
|
||||
class NewEventTileService : TileService() {
|
||||
|
||||
// The pre-34 branch intentionally uses the deprecated Intent overload: it is
|
||||
// the only form available below UpsideDownCake, and is reached only there.
|
||||
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
val today = Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
val intent = MainActivity.openCreateIntent(this, today)
|
||||
// Launch only once the device is unlocked: creating an event behind the
|
||||
// keyguard makes no sense, and the shade can't start an activity over a
|
||||
// locked screen anyway.
|
||||
unlockAndRun {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
val pending = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
startActivityAndCollapse(pending)
|
||||
} else {
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -12,20 +14,28 @@ 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.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
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.calendarFadeThrough
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -34,14 +44,24 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||
* Holds the top-level view back stack (spec M1) and swaps between the calendar
|
||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||
* pill in their top bars writes back here via [onSelectView].
|
||||
*
|
||||
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
||||
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
||||
* (see [selectView]); a widget launch resets the stack to its own view; a date
|
||||
* tap drills the day view on top. Pressing back pops one level, and the
|
||||
* base-level [BackHandler] hands off to the system to exit once only the home
|
||||
* view remains. So back from a widget-opened screen returns to that widget's
|
||||
* view, then home; and back through pill switches walks the views in reverse.
|
||||
*
|
||||
* [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.
|
||||
* so a later recomposition can't re-open it. (A widget event tap instead arrives
|
||||
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
||||
* widget's view.)
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarHost(
|
||||
@@ -52,15 +72,24 @@ fun CalendarHost(
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
// opens straight on the user's choice instead of flashing a placeholder and
|
||||
// correcting it. Brief blank first frame, matching the onboarding gate above.
|
||||
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
|
||||
|
||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||
mutableStateOf(listOf(defaultView))
|
||||
}
|
||||
val view = viewStack.last()
|
||||
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
|
||||
|
||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||
pendingDayIso = date.toString()
|
||||
view = CalendarView.Day
|
||||
viewStack = viewStack.drillToDay()
|
||||
}
|
||||
|
||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||
@@ -97,6 +126,12 @@ fun CalendarHost(
|
||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||
val onOpenSettings = { showSettings = true }
|
||||
|
||||
// Full-text search — its own overlay, opened from each calendar screen's
|
||||
// top bar. Sits below the detail/edit overlays so tapping a result reveals
|
||||
// the detail on top and backing out returns to the results.
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
val onOpenSearch = { showSearch = true }
|
||||
|
||||
// Calendar manager (reached from Settings) — its own overlay so it slides
|
||||
// over Settings and survives view switches.
|
||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -137,16 +172,52 @@ fun CalendarHost(
|
||||
}
|
||||
}
|
||||
|
||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
||||
// Close every overlay that can sit over the calendar, so an externally
|
||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||
// top instead of underneath whatever the user had open.
|
||||
fun dismissCoveringOverlays() {
|
||||
showSettings = false
|
||||
showCalendars = false
|
||||
detailKey = null
|
||||
editKey = null
|
||||
importUri = null
|
||||
importForm = null
|
||||
}
|
||||
|
||||
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
// view so backing out returns there (then home), not to the default.
|
||||
LaunchedEffect(widgetNavRequest) {
|
||||
when (val req = widgetNavRequest) {
|
||||
is WidgetNavRequest.OpenDate -> {
|
||||
// Drill the day view in over the widget's view: drop any overlay
|
||||
// that would cover it, so the open doesn't land under Settings/form.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = req.dateIso
|
||||
view = CalendarView.Day
|
||||
// No widget source (an external date tap) roots over the default
|
||||
// home view, so backing out of the day returns home then exits.
|
||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.OpenEvent -> {
|
||||
// Root the stack in the widget's view, then open the occurrence
|
||||
// detail over it (same key shape as a tapped event / reminder).
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
viewStack = viewBaseStack(defaultView, req.source)
|
||||
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
||||
heldKey = key
|
||||
detailKey = key
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.Create -> {
|
||||
// External "new event" entries (QS tile / launcher shortcut /
|
||||
// widget) must land on top of whatever is open — the form overlay
|
||||
// sits below Settings/calendars in the Box, so without this it
|
||||
// would open hidden underneath them.
|
||||
dismissCoveringOverlays()
|
||||
val iso = req.dateIso ?: Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
|
||||
heldCreateIso = iso
|
||||
@@ -161,36 +232,73 @@ fun CalendarHost(
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||
// so back there falls through to the system and exits the app.
|
||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||
editKey != null || showSettings || showCalendars || importUri != null ||
|
||||
importForm != null
|
||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||
viewStack = viewStack.dropLast(1)
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when (view) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||
// navigation, so it fades through rather than sliding — paging *within* a
|
||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||
val viewSwitch = calendarFadeThrough()
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
transitionSpec = { viewSwitch },
|
||||
label = "view-switch",
|
||||
) { currentView ->
|
||||
when (currentView) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||
// detail screen draws on top, and closing it returns to the results.
|
||||
AnimatedVisibility(
|
||||
visible = showSearch,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
SearchScreen(
|
||||
onBack = { showSearch = false },
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -293,3 +401,9 @@ fun CalendarHost(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists the view back stack across config change / process death by ordinal. */
|
||||
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Supplies [CalendarHost] with the user's default startup view (M1). Held as a
|
||||
* nullable [StateFlow] so the host can wait for DataStore's first emission before
|
||||
* seeding its view back stack — rendering nothing for that first frame rather
|
||||
* than flashing the old hard-coded Week view and then correcting it.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CalendarHostViewModel @Inject constructor(
|
||||
prefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -16,10 +19,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RootScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -45,38 +51,52 @@ fun RootScreen(
|
||||
hasPermission = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
// Refresh the contact special-dates mirror on foreground (the
|
||||
// worker is debounced and no-ops when the feature is off). Gated
|
||||
// on the permission so users without the opt-in never enqueue it.
|
||||
if (context.hasContactsPermission()) {
|
||||
SpecialDatesScheduler.runNow(context, foreground = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(obs)
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
|
||||
if (hasPermission) {
|
||||
// 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,
|
||||
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
||||
// onboarding eases into the next screen instead of snapping. A fade carries no
|
||||
// spatial motion, so it stays appropriate under "remove animations" too.
|
||||
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
||||
if (granted) {
|
||||
// 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()
|
||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||
when (done) {
|
||||
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 {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
modifier = modifier,
|
||||
)
|
||||
null -> {}
|
||||
}
|
||||
} else {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* [CalendarHost]. Every request carries the [source] view of the widget it came
|
||||
* from (the agenda widget → [CalendarView.Agenda], the month widget →
|
||||
* [CalendarView.Month]) so the in-app back stack roots itself in that view:
|
||||
* backing out of the opened date/event returns to the widget's own view, not the
|
||||
* default home. (Reminder notifications are not widgets — they keep the separate
|
||||
* detail-key channel and leave the base view untouched.)
|
||||
*/
|
||||
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 day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
|
||||
* [source]. A null [source] means the request came from outside the app (a
|
||||
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
|
||||
* the default home view instead of a widget's view.
|
||||
*/
|
||||
data class OpenDate(val dateIso: String, val source: CalendarView?) : WidgetNavRequest
|
||||
|
||||
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
||||
data class OpenEvent(
|
||||
val eventId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val source: CalendarView,
|
||||
) : WidgetNavRequest
|
||||
|
||||
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* How far ahead the agenda (screen or widget) shows events, starting at today.
|
||||
*
|
||||
* Two flavours:
|
||||
* - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days),
|
||||
* [Month] (30 days), [Custom] (1–365 days). These never degenerate.
|
||||
* - **Calendar-aligned** windows that end at a period boundary: [ThisWeek] runs
|
||||
* through the end of the current week (respecting the week-start preference —
|
||||
* a Monday start means "everything before next Monday"); [ThisMonth] runs
|
||||
* through the last day of the current month. These shrink as the period ends.
|
||||
*/
|
||||
sealed interface AgendaRange {
|
||||
data object Day : AgendaRange
|
||||
data object Week : AgendaRange
|
||||
data object Month : AgendaRange
|
||||
data object ThisWeek : AgendaRange
|
||||
data object ThisMonth : AgendaRange
|
||||
data class Custom(val days: Int) : AgendaRange
|
||||
|
||||
companion object {
|
||||
/** Allowed bounds for a [Custom] day count. */
|
||||
const val MIN_CUSTOM_DAYS = 1
|
||||
const val MAX_CUSTOM_DAYS = 365
|
||||
}
|
||||
}
|
||||
|
||||
private const val DAYS_PER_WEEK = 7
|
||||
|
||||
/**
|
||||
* Inclusive number of days the window spans, starting at (and including)
|
||||
* [anchor]. The calendar-aligned ranges depend on the anchor day and, for
|
||||
* [AgendaRange.ThisWeek], the [weekStart] preference.
|
||||
*/
|
||||
fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (this) {
|
||||
AgendaRange.Day -> 1
|
||||
AgendaRange.Week -> DAYS_PER_WEEK
|
||||
AgendaRange.Month -> 30
|
||||
AgendaRange.ThisWeek -> {
|
||||
// Days elapsed since the week's first day (0 when today *is* the start),
|
||||
// so the window is the rest of the week through the day before it repeats.
|
||||
val sinceWeekStart = ((anchor.dayOfWeek.ordinal - weekStart.ordinal) + DAYS_PER_WEEK) % DAYS_PER_WEEK
|
||||
DAYS_PER_WEEK - sinceWeekStart
|
||||
}
|
||||
AgendaRange.ThisMonth -> {
|
||||
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
|
||||
daysInMonth - anchor.day + 1
|
||||
}
|
||||
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
|
||||
}
|
||||
|
||||
/** Stored representation: a fixed token, or `CUSTOM:<days>`. */
|
||||
fun AgendaRange.storageValue(): String = when (this) {
|
||||
AgendaRange.Day -> "DAY"
|
||||
AgendaRange.Week -> "WEEK"
|
||||
AgendaRange.Month -> "MONTH"
|
||||
AgendaRange.ThisWeek -> "THIS_WEEK"
|
||||
AgendaRange.ThisMonth -> "THIS_MONTH"
|
||||
is AgendaRange.Custom -> "$CUSTOM_PREFIX$days"
|
||||
}
|
||||
|
||||
/** Parse a stored value; unknown/garbage falls back to [default]. */
|
||||
fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when {
|
||||
stored == "DAY" -> AgendaRange.Day
|
||||
stored == "WEEK" -> AgendaRange.Week
|
||||
stored == "MONTH" -> AgendaRange.Month
|
||||
stored == "THIS_WEEK" -> AgendaRange.ThisWeek
|
||||
stored == "THIS_MONTH" -> AgendaRange.ThisMonth
|
||||
stored != null && stored.startsWith(CUSTOM_PREFIX) -> {
|
||||
val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull()
|
||||
if (days != null) {
|
||||
AgendaRange.Custom(days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS))
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
else -> default
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete span the range covers, starting at [start] through [end]
|
||||
* (inclusive), for a human-readable header:
|
||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
||||
*/
|
||||
fun agendaRangeWindowSummary(
|
||||
range: AgendaRange,
|
||||
start: LocalDate,
|
||||
end: LocalDate,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
return when (range) {
|
||||
AgendaRange.Day -> medium.format(javaStart)
|
||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
||||
}
|
||||
}
|
||||
|
||||
private const val CUSTOM_PREFIX = "CUSTOM:"
|
||||
@@ -2,23 +2,28 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.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.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.Coffee
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -34,9 +39,13 @@ import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -45,17 +54,27 @@ 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.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||
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.EventDimAlpha
|
||||
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 de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -75,21 +94,25 @@ fun AgendaScreen(
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
|
||||
val isOnToday = when (val s = state) {
|
||||
is AgendaUiState.Success -> s.anchor == s.today
|
||||
else -> true
|
||||
}
|
||||
val successState = state as? AgendaUiState.Success
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -119,6 +142,7 @@ fun AgendaScreen(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
@@ -131,21 +155,134 @@ fun AgendaScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
AgendaContent(
|
||||
state = state,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
start = s.anchor,
|
||||
end = s.rangeEnd,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AgendaRangePill(
|
||||
range = s.range,
|
||||
isOverride = s.rangeIsOverride,
|
||||
onClick = { showRangePicker = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showRangePicker) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.agenda_range_override_hint),
|
||||
selected = successState?.range ?: AgendaRange.Month,
|
||||
onSelect = viewModel::setRangeOverride,
|
||||
onDismiss = { showRangePicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
||||
* range picker as a session-only override. Filled with the primary container
|
||||
* while an override is active, so the temporary state is obvious.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangePill(
|
||||
range: AgendaRange,
|
||||
isOverride: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val container = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
val content = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
color = container,
|
||||
contentColor = content,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A header naming the concrete window currently shown, e.g. "Showing all events
|
||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangeBanner(
|
||||
range: AgendaRange,
|
||||
start: LocalDate,
|
||||
end: LocalDate,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val window = agendaRangeWindowSummary(range, start, end, locale)
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_range_showing_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "${agendaRangeLabel(range)}, $window",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -155,19 +292,42 @@ private fun AgendaContent(
|
||||
is AgendaUiState.Failure -> Box(modifier) {
|
||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||
}
|
||||
is AgendaUiState.Success ->
|
||||
if (state.days.isEmpty()) {
|
||||
is AgendaUiState.Success -> {
|
||||
val now by rememberCurrentMinute()
|
||||
// Hiding drops finished events — and any day they leave empty; dimming
|
||||
// keeps them but fades the row. Recomputed each minute so events fall
|
||||
// away (or fade) as they end while the screen stays open.
|
||||
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
state.days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
state.days
|
||||
}
|
||||
if (days.isEmpty()) {
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
||||
AgendaList(
|
||||
days = days,
|
||||
today = state.today,
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AgendaList(
|
||||
state: AgendaUiState.Success,
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -176,9 +336,9 @@ private fun AgendaList(
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = state.today)
|
||||
AgendaDayHeader(date = day.date, today = today)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
@@ -187,6 +347,8 @@ private fun AgendaList(
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = calendarAnimateItem(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
@@ -218,11 +380,14 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
position = position,
|
||||
@@ -247,7 +412,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.EventAvailable,
|
||||
imageVector = Icons.Filled.Coffee,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
@@ -258,13 +423,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +432,7 @@ private fun AgendaTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -292,6 +451,12 @@ private fun AgendaTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
@@ -324,15 +489,17 @@ 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 is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
"${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant): String {
|
||||
private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String {
|
||||
val t = instant.toLocalDateTime(zone).time
|
||||
return "%02d:%02d".format(t.hour, t.minute)
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
|
||||
@@ -53,5 +53,13 @@ sealed interface AgendaUiState {
|
||||
val anchor: LocalDate,
|
||||
val today: LocalDate,
|
||||
val days: List<AgendaDay>,
|
||||
/** The range currently in effect — the saved default or a session override. */
|
||||
val range: AgendaRange,
|
||||
/** True when [range] is a temporary in-view override of the saved default. */
|
||||
val rangeIsOverride: Boolean,
|
||||
/** Last day the current [range] covers (inclusive), for the range header. */
|
||||
val rangeEnd: LocalDate,
|
||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||
val showRangeBar: Boolean,
|
||||
) : AgendaUiState
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ 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.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -17,10 +20,13 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
@@ -30,16 +36,36 @@ 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,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
// The saved agenda range plus the range-bar visibility toggle.
|
||||
private val agendaSettings = combine(
|
||||
settingsPrefs.agendaScreenRange,
|
||||
settingsPrefs.agendaShowRangeBar,
|
||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||
|
||||
// First day of the week, for the calendar-aligned "this week" range.
|
||||
private val weekStartDay = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
|
||||
/**
|
||||
* How to treat events that already ended today (show / dim / hide). A display
|
||||
* concern only, so it rides alongside the data state rather than re-querying;
|
||||
* the screen combines it with a per-minute "now" to fade or drop past rows.
|
||||
*/
|
||||
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = PastEventDisplay.SHOW,
|
||||
)
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
@@ -48,14 +74,32 @@ class AgendaViewModel @Inject constructor(
|
||||
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)
|
||||
// A transient, in-view override of the saved agenda range. Held in memory
|
||||
// (not persisted), so it survives view switches and rotation within the
|
||||
// session but resets to the saved default when the app is relaunched.
|
||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||
|
||||
val state: StateFlow<AgendaUiState> =
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
||||
AgendaParams(
|
||||
anchor = anchor,
|
||||
range = override ?: settings.range,
|
||||
rangeIsOverride = override != null && override != settings.range,
|
||||
weekStart = weekStart,
|
||||
showRangeBar = settings.showBar,
|
||||
)
|
||||
}
|
||||
.flatMapLatest { params ->
|
||||
val window = agendaRange(
|
||||
params.anchor,
|
||||
params.range.dayCount(params.anchor, params.weekStart) - 1,
|
||||
zone,
|
||||
)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
repository.instances(window),
|
||||
) { calendars, instances ->
|
||||
buildState(anchor, calendars, instances)
|
||||
buildState(params, calendars, instances)
|
||||
}
|
||||
}
|
||||
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
@@ -75,16 +119,47 @@ class AgendaViewModel @Inject constructor(
|
||||
_anchor.value = date
|
||||
}
|
||||
|
||||
/** Temporarily override the agenda range for this session (the bottom-left pill). */
|
||||
fun setRangeOverride(range: AgendaRange) {
|
||||
_rangeOverride.value = range
|
||||
}
|
||||
|
||||
private data class AgendaSettings(
|
||||
val range: AgendaRange,
|
||||
val showBar: Boolean,
|
||||
)
|
||||
|
||||
private data class AgendaParams(
|
||||
val anchor: LocalDate,
|
||||
val range: AgendaRange,
|
||||
val rangeIsOverride: Boolean,
|
||||
val weekStart: DayOfWeek,
|
||||
val showRangeBar: Boolean,
|
||||
)
|
||||
|
||||
private fun buildState(
|
||||
anchor: LocalDate,
|
||||
params: AgendaParams,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): AgendaUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val anchor = params.anchor
|
||||
val days = groupAgendaDays(anchor, instances, zone)
|
||||
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
return AgendaUiState.Success(
|
||||
anchor = anchor,
|
||||
today = todayDate,
|
||||
days = days,
|
||||
range = params.range,
|
||||
rangeIsOverride = params.rangeIsOverride,
|
||||
rangeEnd = rangeEnd,
|
||||
showRangeBar = params.showRangeBar,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,17 @@ import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
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.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -27,25 +30,33 @@ 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.automirrored.filled.OpenInNew
|
||||
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.Cloud
|
||||
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.MoreVert
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -59,27 +70,43 @@ 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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
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". */
|
||||
@@ -98,8 +125,10 @@ fun CalendarsScreen(
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.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
|
||||
@@ -113,7 +142,7 @@ fun CalendarsScreen(
|
||||
sessionKey = editorSession,
|
||||
isNew = editorId == NEW_CALENDAR_ID,
|
||||
initialName = editing?.displayName.orEmpty(),
|
||||
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
|
||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||
initialDescription = editing?.description.orEmpty(),
|
||||
onSave = { name, color, description ->
|
||||
val id = editorId
|
||||
@@ -134,14 +163,21 @@ fun CalendarsScreen(
|
||||
CalendarsList(
|
||||
local = calendars.filter { it.isLocal },
|
||||
synced = calendars.filterNot { it.isLocal },
|
||||
disabledIds = disabledIds,
|
||||
error = error,
|
||||
onConsumeError = viewModel::consumeError,
|
||||
backupResult = backupResult,
|
||||
onExportBackup = viewModel::exportBackup,
|
||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||
autoBackup = autoBackup,
|
||||
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
||||
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
|
||||
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
|
||||
onBack = onBack,
|
||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||
onSetDisabled = viewModel::setDisabled,
|
||||
onSetAccountDisabled = viewModel::setAccountDisabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -150,17 +186,28 @@ fun CalendarsScreen(
|
||||
private fun CalendarsList(
|
||||
local: List<CalendarSource>,
|
||||
synced: List<CalendarSource>,
|
||||
disabledIds: Set<Long>,
|
||||
error: Boolean,
|
||||
onConsumeError: () -> Unit,
|
||||
backupResult: BackupResult?,
|
||||
onExportBackup: (android.net.Uri) -> Unit,
|
||||
onConsumeBackupResult: () -> Unit,
|
||||
autoBackup: AutoBackupUiState,
|
||||
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
||||
onSetAutoBackupInterval: (Long) -> Unit,
|
||||
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onEdit: (CalendarSource) -> Unit,
|
||||
onSetDisabled: (Long, Boolean) -> Unit,
|
||||
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
// Accounts the user has folded shut; empty = all expanded (keeps every
|
||||
// calendar visible by default, the section is collapsible for tidiness).
|
||||
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) }
|
||||
var localExpanded by remember { mutableStateOf(true) }
|
||||
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||
|
||||
LaunchedEffect(error) {
|
||||
@@ -176,6 +223,13 @@ private fun CalendarsList(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let(onExportBackup) }
|
||||
|
||||
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||
// write grant so background runs can keep writing to it.
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(onSetAutoBackupFolder) }
|
||||
var showInterval by remember { mutableStateOf(false) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
when (val r = backupResult) {
|
||||
@@ -200,36 +254,51 @@ private fun CalendarsList(
|
||||
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),
|
||||
// What the per-calendar / per-account switches below actually do.
|
||||
HintText(stringResource(R.string.calendars_disable_hint))
|
||||
|
||||
// Local (device-only) calendars — one collapsible group. The header's
|
||||
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||
// tapping a calendar row opens its editor.
|
||||
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds }
|
||||
CalendarGroup(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
expanded = localExpanded,
|
||||
bodyHasRows = local.isNotEmpty(),
|
||||
headerDisabled = localDisabled,
|
||||
leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } },
|
||||
manageIcon = Icons.Default.Add,
|
||||
manageLabel = stringResource(R.string.calendars_add),
|
||||
onManage = onAdd,
|
||||
onToggleExpand = { localExpanded = !localExpanded },
|
||||
showToggleAll = local.isNotEmpty(),
|
||||
allEnabled = local.none { it.id in disabledIds },
|
||||
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
|
||||
) {
|
||||
if (local.isEmpty()) {
|
||||
HintText(stringResource(R.string.calendars_local_empty))
|
||||
} else {
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||
)
|
||||
},
|
||||
onClick = { onEdit(calendar) },
|
||||
)
|
||||
},
|
||||
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.
|
||||
@@ -237,42 +306,106 @@ private fun CalendarsList(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
// One connected card: the one-time export on top, then automatic
|
||||
// backup (and its folder/interval rows when on).
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Alone,
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||
trailing = {
|
||||
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
|
||||
},
|
||||
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
|
||||
)
|
||||
if (autoBackup.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||
summary = rememberFolderName(autoBackup.folderUri)
|
||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||
position = Position.Middle,
|
||||
onClick = { runCatching { pickFolder.launch(null) } },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { showInterval = true },
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Synced calendars — read-only, grouped by account, each with a
|
||||
// per-account "manage in source app" link.
|
||||
// Synced calendars — read-only, grouped by account. Each account is a
|
||||
// collapsible group whose header opens the source app (icon) and toggles
|
||||
// all of its calendars at once (switch).
|
||||
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) },
|
||||
)
|
||||
val expanded = account !in collapsedAccounts
|
||||
val accountType = cals.first().accountType
|
||||
val accountDisabled = cals.all { it.id in disabledIds }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CalendarGroup(
|
||||
title = account,
|
||||
expanded = expanded,
|
||||
bodyHasRows = true,
|
||||
headerDisabled = accountDisabled,
|
||||
leading = { Box(dimIf(accountDisabled)) { SourceLogo(accountType) } },
|
||||
manageIcon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
manageLabel = stringResource(R.string.calendars_manage_in_app),
|
||||
onManage = {
|
||||
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
|
||||
},
|
||||
onToggleExpand = {
|
||||
collapsedAccounts = if (expanded) {
|
||||
collapsedAccounts + account
|
||||
} else {
|
||||
collapsedAccounts - account
|
||||
}
|
||||
},
|
||||
showToggleAll = true,
|
||||
allEnabled = cals.none { it.id in disabledIds },
|
||||
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) },
|
||||
) {
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)) }
|
||||
},
|
||||
}
|
||||
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
currentMinutes = autoBackup.intervalMinutes,
|
||||
onConfirm = onSetAutoBackupInterval,
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -295,10 +428,9 @@ private fun CalendarEditor(
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
BackHandler(onBack = onClose)
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.predictiveBack(onBack = onClose)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
topBar = {
|
||||
@@ -375,7 +507,7 @@ private fun CalendarEditor(
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ColorSwatchRow(
|
||||
colors = CALENDAR_COLOR_PALETTE,
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = color,
|
||||
onSelect = { color = it },
|
||||
dark = dark,
|
||||
@@ -425,6 +557,30 @@ private fun CalendarEditor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row enable/disable control. Checked = the calendar is shown in the
|
||||
* app; unchecking disables it (events, filters and pickers all drop it) without
|
||||
* touching any provider data. Carries its own content description so the toggle
|
||||
* is self-describing to screen readers even on a dimmed row.
|
||||
*/
|
||||
@Composable
|
||||
private fun EnableSwitch(
|
||||
calendarName: String,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
modifier = Modifier.semantics { contentDescription = label },
|
||||
)
|
||||
}
|
||||
|
||||
/** Fade a leading element to the M3 disabled emphasis when [disabled]. */
|
||||
private fun dimIf(disabled: Boolean): Modifier =
|
||||
if (disabled) Modifier.alpha(0.38f) else Modifier
|
||||
|
||||
/** Tonal field card matching the event editor's design (icon + content). */
|
||||
@Composable
|
||||
private fun EditorCard(
|
||||
@@ -456,32 +612,178 @@ private fun EditorCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible calendar group rendered as a connected card. The header row is
|
||||
* the card's top (tap to expand/collapse): a [leading] source mark (the account
|
||||
* app's logo, or a device chip for local), the group [title] and a trailing
|
||||
* overflow (⋮) menu holding the account-level actions — enable/disable every
|
||||
* calendar at once (when [showToggleAll]) and the manage/add action
|
||||
* ([manageIcon] / [manageLabel] → [onManage]). The group's calendars render as
|
||||
* the rows below via [body].
|
||||
*
|
||||
* The header keeps the standard row colour; the calendars it reveals sit one tone
|
||||
* darker, so the group reads as a header over nested content. Once [headerDisabled]
|
||||
* (every calendar switched off) the header fades to the disabled emphasis so the
|
||||
* whole group reads as off — not just via the menu. [bodyHasRows] is false when no
|
||||
* calendar rows follow (e.g. an empty local group), so the header stays a
|
||||
* standalone card rather than a top edge with nothing beneath it.
|
||||
*/
|
||||
@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,
|
||||
private fun CalendarGroup(
|
||||
title: String,
|
||||
expanded: Boolean,
|
||||
bodyHasRows: Boolean,
|
||||
headerDisabled: Boolean,
|
||||
leading: @Composable () -> Unit,
|
||||
manageIcon: ImageVector,
|
||||
manageLabel: String,
|
||||
onManage: () -> Unit,
|
||||
onToggleExpand: () -> Unit,
|
||||
showToggleAll: Boolean,
|
||||
allEnabled: Boolean,
|
||||
onToggleAll: (Boolean) -> Unit,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
position = if (expanded && bodyHasRows) Position.Top else Position.Alone,
|
||||
dimmed = headerDisabled,
|
||||
leading = leading,
|
||||
trailing = {
|
||||
CalendarGroupMenu(
|
||||
title = title,
|
||||
showToggleAll = showToggleAll,
|
||||
allEnabled = allEnabled,
|
||||
onToggleAll = onToggleAll,
|
||||
manageIcon = manageIcon,
|
||||
manageLabel = manageLabel,
|
||||
onManage = onManage,
|
||||
)
|
||||
},
|
||||
onClick = onToggleExpand,
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
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))
|
||||
Column(content = body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account header's overflow (⋮) menu: the two account-level actions that
|
||||
* don't fit on one line — "Enable/Disable all" (when [showToggleAll]) and the
|
||||
* manage/add action. A rounded, tonal dropdown matching the app's surfaces, with
|
||||
* the two actions divided for clear separation.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarGroupMenu(
|
||||
title: String,
|
||||
showToggleAll: Boolean,
|
||||
allEnabled: Boolean,
|
||||
onToggleAll: (Boolean) -> Unit,
|
||||
manageIcon: ImageVector,
|
||||
manageLabel: String,
|
||||
onManage: () -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { open = true }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.calendars_account_menu_a11y, title),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = open,
|
||||
onDismissRequest = { open = false },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
// A distinct tone + a lifted shadow so the menu reads as floating
|
||||
// above the cards (which sit at surfaceContainerHigh) rather than
|
||||
// blending into them.
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 6.dp,
|
||||
) {
|
||||
if (showToggleAll) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (allEnabled) R.string.calendars_disable_all
|
||||
else R.string.calendars_enable_all,
|
||||
),
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (allEnabled) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
open = false
|
||||
onToggleAll(!allEnabled)
|
||||
},
|
||||
)
|
||||
HorizontalDivider(Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(manageLabel) },
|
||||
leadingIcon = { Icon(manageIcon, contentDescription = null) },
|
||||
onClick = {
|
||||
open = false
|
||||
onManage()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */
|
||||
/**
|
||||
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
|
||||
* round 40dp chip, so each synced account is recognisable at a glance. We load
|
||||
* whatever app owns the account from [PackageManager] rather than bundling brand
|
||||
* logos — always accurate, nothing to license. Falls back to a neutral cloud
|
||||
* chip when no installed app resolves for the account.
|
||||
*/
|
||||
@Composable
|
||||
private fun SourceLogo(accountType: String) {
|
||||
val context = LocalContext.current
|
||||
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
|
||||
if (logo != null) {
|
||||
Image(
|
||||
bitmap = logo,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
LeadingAvatar(Icons.Default.Cloud)
|
||||
}
|
||||
}
|
||||
|
||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||
val pm = context.packageManager
|
||||
val candidates = buildList {
|
||||
curatedSourcePackage(accountType)?.let { add(it) }
|
||||
AccountManager.get(context).authenticatorTypes
|
||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||
?.packageName
|
||||
?.let { add(it) }
|
||||
}
|
||||
for (pkg in candidates) {
|
||||
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||
if (bitmap != null) return bitmap.asImageBitmap()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
|
||||
@Composable
|
||||
private fun LeadingAvatar(icon: ImageVector) {
|
||||
Box(
|
||||
@@ -500,25 +802,6 @@ private fun LeadingAvatar(icon: ImageVector) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(
|
||||
@@ -539,6 +822,95 @@ private fun HintText(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||
@Composable
|
||||
private fun rememberFolderName(uriString: String?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(uriString) {
|
||||
uriString?.let {
|
||||
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||
@Composable
|
||||
private fun backupIntervalLabel(minutes: Long): String {
|
||||
val duration = when {
|
||||
minutes % MINUTES_PER_WEEK == 0L ->
|
||||
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||
minutes % MINUTES_PER_DAY == 0L ->
|
||||
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||
minutes % 60L == 0L ->
|
||||
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||
else ->
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||
}
|
||||
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||
}
|
||||
|
||||
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||
@Composable
|
||||
private fun backupStatusText(status: BackupStatus): String {
|
||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||
val relative = DateUtils.getRelativeTimeSpanString(
|
||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
return if (status.lastSuccess) {
|
||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||
} else {
|
||||
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||
}
|
||||
}
|
||||
|
||||
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||
@Composable
|
||||
private fun BackupIntervalDialog(
|
||||
currentMinutes: Long,
|
||||
onConfirm: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
||||
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||
onDismiss()
|
||||
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440L
|
||||
private const val MINUTES_PER_WEEK = 10_080L
|
||||
|
||||
/**
|
||||
* Pick the app to open for managing a synced calendar's account. The account's
|
||||
* own authenticator package (resolved from [AccountManager], no permission
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
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.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -15,6 +22,8 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -31,8 +40,11 @@ import javax.inject.Inject
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CalendarsViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
private val prefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -46,6 +58,36 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Calendars the user has disabled in the app. This screen is the only
|
||||
* surface that lists them, so it both reads the set (to dim the rows) and
|
||||
* toggles it. Every other surface simply excludes these ids.
|
||||
*/
|
||||
val disabledCalendarIds: StateFlow<Set<Long>> =
|
||||
prefs.disabledCalendarIds
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
settingsPrefs.autoBackupIntervalMinutes,
|
||||
settingsPrefs.autoBackupFolderUri,
|
||||
settingsPrefs.autoBackupStatus,
|
||||
) { enabled, interval, folder, status ->
|
||||
AutoBackupUiState(enabled, interval, folder, status)
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = AutoBackupUiState(),
|
||||
)
|
||||
|
||||
private val _error = MutableStateFlow(false)
|
||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||
|
||||
@@ -93,6 +135,76 @@ class CalendarsViewModel @Inject constructor(
|
||||
repository.deleteCalendar(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a calendar app-side. Disabling removes it from every
|
||||
* surface but Settings → Calendars (and hides its events) without touching
|
||||
* provider data — purely a reversible Calendula-local view choice.
|
||||
*/
|
||||
fun setDisabled(id: Long, disabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + id else current - id
|
||||
if (next != current) prefs.setDisabledCalendarIds(next)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable every calendar of one account in a single write — the
|
||||
* "toggle all" affordance on an account header. Done as one set update so the
|
||||
* per-calendar [setDisabled] calls can't race each other.
|
||||
*/
|
||||
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + ids else current - ids.toSet()
|
||||
if (next != current) prefs.setDisabledCalendarIds(next)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Automatic backup (issue #8) ------------------------------------
|
||||
|
||||
fun setAutoBackupEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsPrefs.setAutoBackupEnabled(enabled)
|
||||
reschedule()
|
||||
// Give immediate feedback when turning it on with a folder already set.
|
||||
if (enabled && settingsPrefs.autoBackupFolderUri.first() != null) {
|
||||
BackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoBackupIntervalMinutes(minutes: Long) {
|
||||
viewModelScope.launch {
|
||||
settingsPrefs.setAutoBackupIntervalMinutes(minutes)
|
||||
reschedule()
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the chosen destination folder (taking a durable write grant) and run once. */
|
||||
fun setAutoBackupFolder(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
settingsPrefs.setAutoBackupFolderUri(uri.toString())
|
||||
reschedule()
|
||||
if (settingsPrefs.autoBackupEnabled.first()) BackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reschedule() {
|
||||
BackupScheduler.apply(
|
||||
context = context,
|
||||
enabled = settingsPrefs.autoBackupEnabled.first(),
|
||||
intervalMinutes = settingsPrefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = settingsPrefs.autoBackupFolderUri.first() != null,
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun write(crossinline block: suspend () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -106,6 +218,14 @@ class CalendarsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Automatic-backup settings + last-run status for the Backup section. */
|
||||
data class AutoBackupUiState(
|
||||
val enabled: Boolean = false,
|
||||
val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL,
|
||||
val folderUri: String? = null,
|
||||
val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0),
|
||||
)
|
||||
|
||||
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||
sealed interface BackupResult {
|
||||
data class Success(val eventCount: Int) : BackupResult
|
||||
|
||||
@@ -1,14 +1,56 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.provider.Settings
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Whether the user has asked the system to remove animations (Settings →
|
||||
* Accessibility → "Remove animations", which sets the global animator duration
|
||||
* scale to 0). Compose animations do *not* honour this platform flag on their
|
||||
* own, so the shared motion helpers in this file check it and fall back to a
|
||||
* quick cross-fade — opacity only, no spatial movement — to respect the
|
||||
* vestibular intent of the setting while keeping state changes legible.
|
||||
*
|
||||
* Read once at composition; the scale changes rarely and only takes full effect
|
||||
* after a process restart anyway.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberReduceMotion(): Boolean {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
return remember(resolver) {
|
||||
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||
@@ -24,17 +66,160 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
|
||||
/**
|
||||
* Horizontal slide for navigating between adjacent months/weeks.
|
||||
* The fast effects spec from the active motion scheme, for opacity (fade)
|
||||
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||
* for use in non-composable transition lambdas and as the reduced-motion fallback.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
|
||||
/**
|
||||
* Horizontal slide for navigating between adjacent months/weeks/days.
|
||||
*
|
||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||
* (e.g. "today"); a jump reuses the forward direction.
|
||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||
* (e.g. "today"); a jump reuses the forward direction.
|
||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||
* @param fadeSpec effects spec for the reduced-motion fade, typically
|
||||
* [rememberCalendarFadeSpec].
|
||||
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
|
||||
*/
|
||||
fun calendarSlideTransition(
|
||||
slideDir: Int,
|
||||
spec: FiniteAnimationSpec<IntOffset>,
|
||||
fadeSpec: FiniteAnimationSpec<Float>,
|
||||
reduceMotion: Boolean,
|
||||
): ContentTransform {
|
||||
if (reduceMotion) {
|
||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||
}
|
||||
val dir = if (slideDir == 0) 1 else slideDir
|
||||
return slideInHorizontally(spec) { w -> dir * w }
|
||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
|
||||
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
|
||||
* opacity, so it doubles as its own reduced-motion form.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarFadeThrough(): ContentTransform {
|
||||
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
return fadeIn(fade).togetherWith(fadeOut(fade))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter transition for a vertically-revealed section (expandable rows, inline
|
||||
* fields): height grows from the top while fading in. Under reduced motion the
|
||||
* height growth is dropped, leaving a quick fade.
|
||||
*
|
||||
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
|
||||
* originally inlined in the event edit form, so every expandable surface in the
|
||||
* app reveals the same way.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
||||
}
|
||||
}
|
||||
|
||||
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
|
||||
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
|
||||
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
|
||||
* motion keeps the fade only.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
|
||||
* app's motion scheme instead of Compose's default spring. Returns a bare
|
||||
* [Modifier] under reduced motion so rows snap into place. Requires the list to
|
||||
* supply stable item keys.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
|
||||
if (reduceMotion) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.animateItem(
|
||||
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard Android predictive-back transform for a full-screen overlay: as the
|
||||
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
|
||||
* swiped edge and rounds its corners, previewing what's behind. Completing the
|
||||
* gesture invokes [onBack]; cancelling springs it back.
|
||||
*
|
||||
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
|
||||
* once and apply the returned [Modifier] to that screen's root so the preview
|
||||
* respects the same back semantics. Under reduced motion the visual preview is
|
||||
* skipped (the back still works); on API < 34 the system delivers no progress,
|
||||
* so it degrades to a plain back.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.predictiveBack(
|
||||
onBack: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
reduceMotion: Boolean = rememberReduceMotion(),
|
||||
): Modifier {
|
||||
val progress = remember { Animatable(0f) }
|
||||
var fromLeftEdge by remember { mutableStateOf(true) }
|
||||
|
||||
PredictiveBackHandler(enabled = enabled) { events ->
|
||||
try {
|
||||
events.collect { event ->
|
||||
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
|
||||
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
||||
}
|
||||
onBack()
|
||||
progress.snapTo(0f)
|
||||
} catch (_: CancellationException) {
|
||||
progress.animateTo(0f)
|
||||
}
|
||||
}
|
||||
|
||||
if (reduceMotion) return this
|
||||
return this.graphicsLayer {
|
||||
val p = progress.value
|
||||
val scale = 1f - 0.1f * p
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
|
||||
shape = RoundedCornerShape(32.dp.toPx() * p)
|
||||
clip = p > 0f
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,3 +49,31 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
|
||||
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
||||
return available[(i + 1) % available.size]
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level view back stack (bottom → top): the [default] home view always
|
||||
* sits at the bottom. Pressing back pops one level until only the home view
|
||||
* remains, then the system exits the app. Three operations move it:
|
||||
*
|
||||
* - [selectView] — a lateral switch (the pill or the drawer). It keeps a visit
|
||||
* history so back retraces it: switching to a not-yet-visited view pushes it on
|
||||
* top; switching to one already in the stack pops back to it (collapsing the
|
||||
* loop, so the depth stays bounded by the number of distinct views). Selecting
|
||||
* the home view collapses to just `[default]`.
|
||||
* - [viewBaseStack] — the stack a widget launch resets to: just `[default]` when
|
||||
* the widget's view is the home view, else `[default, source]`. A widget entry
|
||||
* starts a fresh context rather than extending in-app history.
|
||||
* - [drillToDay] — pushes the day view on top (a date tap from the month grid or
|
||||
* a widget), unless the day view is already current.
|
||||
*/
|
||||
fun List<CalendarView>.selectView(target: CalendarView): List<CalendarView> {
|
||||
val existing = indexOf(target)
|
||||
return if (existing >= 0) take(existing + 1) else this + target
|
||||
}
|
||||
|
||||
fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarView> =
|
||||
if (source == default) listOf(default) else listOf(default, source)
|
||||
|
||||
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
||||
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
||||
|
||||
@@ -67,16 +67,3 @@ fun ColorSwatchRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,50 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
/**
|
||||
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
||||
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
||||
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
||||
* call site so it never reaches a release build. Non-interactive: it's a plain
|
||||
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
||||
*
|
||||
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
||||
* so it overlays the UI.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.DebugRibbon() {
|
||||
Text(
|
||||
text = "DEBUG",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.zIndex(1f)
|
||||
// Push the band out so its midline crosses the very corner, then
|
||||
// rotate it to the classic 45° ribbon.
|
||||
.offset(x = 36.dp, y = 24.dp)
|
||||
.rotate(45f)
|
||||
.background(Color(0xFFB23B00))
|
||||
.width(140.dp)
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
|
||||
/**
|
||||
* The shared label and icon for each optional [EventFormField]. Both the event
|
||||
* editor (which shows these fields) and the settings screen (which toggles their
|
||||
* defaults) draw from this one mapping, so a field reads the same wherever it
|
||||
* appears.
|
||||
*/
|
||||
@StringRes
|
||||
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
EventFormField.Availability -> R.string.event_edit_availability
|
||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||
EventFormField.Color -> R.string.event_edit_color
|
||||
EventFormField.Attendees -> R.string.event_edit_attendees
|
||||
}
|
||||
|
||||
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
EventFormField.Location -> Icons.Default.Place
|
||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||
EventFormField.Reminders -> Icons.Default.Notifications
|
||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
EventFormField.Visibility -> Icons.Default.Lock
|
||||
EventFormField.Color -> Icons.Default.Palette
|
||||
EventFormField.Attendees -> Icons.Default.People
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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
|
||||
@@ -73,11 +72,11 @@ fun CollapsingScaffold(
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
val scrollBehavior =
|
||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.predictiveBack(onBack = onBack)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
@@ -128,7 +127,9 @@ fun CollapsingScaffold(
|
||||
* 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).
|
||||
* row non-interactive (e.g. read-only entries). [dimmed] fades the headline and
|
||||
* summary to the M3 disabled emphasis while leaving the [trailing] control at
|
||||
* full opacity — for rows that are present but switched off.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -138,6 +139,8 @@ fun GroupedRow(
|
||||
modifier: Modifier = Modifier,
|
||||
summary: String? = null,
|
||||
selected: Boolean = false,
|
||||
dimmed: Boolean = false,
|
||||
container: Color? = null,
|
||||
minHeight: Dp = 72.dp,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
@@ -169,6 +172,16 @@ fun GroupedRow(
|
||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
} else if (dimmed) {
|
||||
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
|
||||
// stays full-opacity so the toggle reads as live even on a faded row.
|
||||
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = muted,
|
||||
leadingIconColor = muted,
|
||||
supportingColor = muted,
|
||||
)
|
||||
} else {
|
||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||
}
|
||||
@@ -186,10 +199,10 @@ fun GroupedRow(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.then(gap)
|
||||
val containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val containerColor = when {
|
||||
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||
container != null -> container
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
if (onClick != null) {
|
||||
Surface(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Whether the week/day timeline draws an hour separator line, from the
|
||||
* `showHourLines` preference. Provided once at the app root (like
|
||||
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
|
||||
* Defaults to off — the historical clean look.
|
||||
*/
|
||||
val LocalShowHourLines = staticCompositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* Draw a faint separator line at the top of each hour (1..23) when [show] is
|
||||
* true. Applied to a day column's content so each line sits over the column's
|
||||
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
|
||||
* height; [color] is resolved by the caller from the theme.
|
||||
*/
|
||||
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
|
||||
if (!show) {
|
||||
this
|
||||
} else {
|
||||
drawBehind {
|
||||
for (hour in 1 until 24) {
|
||||
val y = hour * hourHeightPx
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(0f, y),
|
||||
end = Offset(size.width, y),
|
||||
strokeWidth = 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -12,6 +13,7 @@ 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.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -34,26 +36,36 @@ fun InlineTextField(
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||
imeAction: ImeAction = ImeAction.Default,
|
||||
/** Invoked when the IME action key (e.g. Done) is pressed. */
|
||||
onImeAction: (() -> Unit)? = null,
|
||||
) {
|
||||
val resolvedStyle = textStyle.copy(
|
||||
color = if (textStyle.color.isSpecified) {
|
||||
textStyle.color
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
color = when {
|
||||
// A disabled field reads as dimmed, like a locked value.
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
textStyle.color.isSpecified -> textStyle.color
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
enabled = enabled,
|
||||
textStyle = resolvedStyle,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
capitalization = capitalization,
|
||||
imeAction = imeAction,
|
||||
),
|
||||
keyboardActions = onImeAction?.let { action ->
|
||||
KeyboardActions(onAny = { action() })
|
||||
} ?: KeyboardActions.Default,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
private val DotSize = 8.dp
|
||||
private val LineThickness = 2.dp
|
||||
|
||||
/**
|
||||
* Current wall-clock instant that updates once a minute, re-aligning to each
|
||||
* minute boundary (rather than ticking a fixed 60 s) so it never drifts toward
|
||||
* the middle of a minute. Drives the "now" line in the day/week grids.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberCurrentMinute(): State<Instant> {
|
||||
val instant = remember { mutableStateOf(Clock.System.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
val now = Clock.System.now()
|
||||
instant.value = now
|
||||
delay(60_000L - now.toEpochMilliseconds() % 60_000L)
|
||||
}
|
||||
}
|
||||
return instant
|
||||
}
|
||||
|
||||
/**
|
||||
* A thin "current time" indicator — a leading dot plus a line — drawn across a
|
||||
* day column. Positioned on the same [hourHeight] scale the event blocks use so
|
||||
* it lines up with the grid, and refreshed each minute. Renders nothing unless
|
||||
* the wall clock is on [date]; callers mount it only for the column showing
|
||||
* today, so the per-minute tick runs on a single column.
|
||||
*/
|
||||
@Composable
|
||||
fun NowLine(
|
||||
date: LocalDate,
|
||||
hourHeight: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val now by rememberCurrentMinute()
|
||||
val local = now.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
if (local.date != date) return
|
||||
|
||||
val minutes = local.hour * 60 + local.minute
|
||||
val top = hourHeight * (minutes / 60f)
|
||||
val color = MaterialTheme.colorScheme.primary
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(DotSize)
|
||||
.offset(y = top - DotSize / 2),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(LineThickness)
|
||||
.background(color),
|
||||
)
|
||||
// The dot anchors the line to the gutter edge, mirroring the standard
|
||||
// calendar "now" marker; drawn after the line so it sits on top.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(DotSize)
|
||||
.background(color, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The cut-off instant before which a calendar event counts as "completed" and is
|
||||
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
|
||||
* the "dim completed events" setting is on, or `null` when it is off (nothing
|
||||
* dims). Provided per grid screen so only the event chips that read it recompose
|
||||
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
|
||||
*/
|
||||
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||
|
||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||
const val EventDimAlpha = 0.4f
|
||||
@@ -7,11 +7,13 @@ 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.height
|
||||
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.Checkbox
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -25,11 +27,13 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
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.platform.LocalView
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -37,6 +41,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
|
||||
/**
|
||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||
@@ -83,8 +88,10 @@ fun <T> OptionPicker(
|
||||
label: @Composable (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
) {
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
header?.invoke(this)
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
@@ -106,10 +113,15 @@ fun <T> OptionPicker(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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].
|
||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||
* independently, so a default can carry several reminders ("1 week before" *and*
|
||||
* "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use
|
||||
* default reminder" row above the list; choosing it defers to the global default
|
||||
* and clears the checked times. With nothing checked and inherit off, the choice
|
||||
* is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" row
|
||||
* expands an inline number field plus a unit selector to add an arbitrary lead
|
||||
* time to the set. Changes apply live via [onSelect]; the user leaves via back.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderDefaultPicker(
|
||||
@@ -120,68 +132,90 @@ fun ReminderDefaultPicker(
|
||||
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 })
|
||||
// Optimistic local state: the chosen override is authoritative while the
|
||||
// picker is open, so quick successive toggles compose on each other instead
|
||||
// of racing the round-trip through the settings flow (which would drop a
|
||||
// toggle made before the previous write echoes back). Seeded once on open;
|
||||
// the picker leaves composition on dismiss, so reopening re-reads [selected].
|
||||
var current by remember { mutableStateOf(selected) }
|
||||
val inherits = current is CalendarReminderOverride.Inherit
|
||||
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
// Presets plus any selected custom (non-preset) values, each as its own row.
|
||||
val rows = presets + selectedMinutes.filter { it !in presets }.sorted()
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
||||
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
|
||||
val options = buildList {
|
||||
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
||||
add(CalendarReminderOverride.None)
|
||||
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
||||
fun apply(override: CalendarReminderOverride) {
|
||||
current = override
|
||||
onSelect(override)
|
||||
}
|
||||
val rowCount = options.size + 1 // + the custom row
|
||||
// Emit the override for a new set of minutes: empty collapses to None, which
|
||||
// (with inherit unchosen) reads as an explicit "no reminder".
|
||||
fun emit(minutes: List<Int>) {
|
||||
val norm = minutes.distinct().sorted()
|
||||
apply(
|
||||
if (norm.isEmpty()) {
|
||||
CalendarReminderOverride.None
|
||||
} else {
|
||||
CalendarReminderOverride.Minutes(norm)
|
||||
},
|
||||
)
|
||||
}
|
||||
fun toggle(minute: Int) =
|
||||
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
// Exclusive "use default" choice (per-calendar only): its own group above
|
||||
// the multi-select list, since inheriting is mutually exclusive with
|
||||
// picking specific times.
|
||||
if (allowInherit) {
|
||||
GroupedRow(
|
||||
title = reminderOverrideLabel(option),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
title = stringResource(R.string.reminder_use_default),
|
||||
position = Position.Alone,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
val rowCount = rows.size + 1 // + the custom row
|
||||
rows.forEachIndexed { index, minute ->
|
||||
val checked = minute in selectedMinutes
|
||||
GroupedRow(
|
||||
title = reminderLeadTimeLabel(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = checked,
|
||||
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||
onClick = { toggle(minute) },
|
||||
)
|
||||
}
|
||||
// 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
|
||||
},
|
||||
title = stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(visible = customExpanded) {
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
CustomReminderEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = { amountText = it },
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
onConfirm = { minutes ->
|
||||
onSelect(CalendarReminderOverride.Minutes(minutes))
|
||||
onDismiss()
|
||||
emit(selectedMinutes + minutes)
|
||||
amountText = ""
|
||||
customExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -256,6 +290,17 @@ private fun CustomReminderEditor(
|
||||
}
|
||||
}
|
||||
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||
@Composable
|
||||
private fun PickerDescription(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedCheck() {
|
||||
Icon(
|
||||
@@ -265,18 +310,146 @@ private fun SelectedCheck() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
||||
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||
*/
|
||||
@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)
|
||||
fun AgendaRangePicker(
|
||||
title: String,
|
||||
description: String,
|
||||
selected: AgendaRange,
|
||||
onSelect: (AgendaRange) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth)
|
||||
val rolling = listOf(AgendaRange.Week, AgendaRange.Month)
|
||||
val rollingRowCount = rolling.size + 1 // + the custom row
|
||||
val customSelected = selected is AgendaRange.Custom
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable {
|
||||
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
||||
}
|
||||
|
||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = agendaRangeLabel(option),
|
||||
position = position,
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
PickerDescription(description)
|
||||
|
||||
// Calendar-aligned windows.
|
||||
calendarAligned.forEachIndexed { index, option ->
|
||||
rangeRow(option, positionOf(index, calendarAligned.size))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Rolling windows + custom.
|
||||
rolling.forEachIndexed { index, option ->
|
||||
rangeRow(option, positionOf(index, rollingRowCount))
|
||||
}
|
||||
GroupedRow(
|
||||
title = if (customSelected) {
|
||||
agendaRangeLabel(selected)
|
||||
} else {
|
||||
stringResource(R.string.agenda_range_custom)
|
||||
},
|
||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
CustomDaysEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = { amountText = it },
|
||||
onConfirm = { days ->
|
||||
onSelect(AgendaRange.Custom(days))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */
|
||||
@Composable
|
||||
private fun CustomDaysEditor(
|
||||
amountText: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
onConfirm: (Int) -> Unit,
|
||||
) {
|
||||
val days = amountText.toIntOrNull()
|
||||
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = onAmountChange,
|
||||
placeholder = "30",
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||
?: stringResource(R.string.agenda_range_custom_hint),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { days?.let(onConfirm) },
|
||||
enabled = days != null,
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_custom_set))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||
@Composable
|
||||
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||
AgendaRange.Day -> stringResource(R.string.agenda_range_day)
|
||||
AgendaRange.ThisWeek -> stringResource(R.string.agenda_range_this_week)
|
||||
AgendaRange.ThisMonth -> stringResource(R.string.agenda_range_this_month)
|
||||
AgendaRange.Week -> stringResource(R.string.agenda_range_week)
|
||||
AgendaRange.Month -> stringResource(R.string.agenda_range_month)
|
||||
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
|
||||
* `false` = 12-hour ("2:00 PM"). Provided once at the app root from the
|
||||
* `TimeFormatPref` preference resolved against the device's 24-hour system
|
||||
* setting, so every time label reads it without per-screen plumbing. Defaults
|
||||
* to 24-hour for previews and any composition that forgets to provide it.
|
||||
*/
|
||||
val LocalUse24HourFormat = staticCompositionLocalOf { true }
|
||||
|
||||
private const val PATTERN_24 = "HH:mm"
|
||||
private const val PATTERN_12 = "h:mm a"
|
||||
private const val HOUR_PATTERN_12 = "h a"
|
||||
|
||||
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
||||
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern(if (is24Hour) PATTERN_24 else PATTERN_12, locale)
|
||||
|
||||
/**
|
||||
* Format a wall-clock time (hour 0..23, minute 0..59) honouring the resolved
|
||||
* [is24Hour] convention and [locale]: 24h → "14:00", 12h → "2:00 PM". Pure, so
|
||||
* it can be unit-tested and used off the main thread (widget, notifications).
|
||||
*/
|
||||
fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
|
||||
.format(timeOfDayFormatter(is24Hour, locale))
|
||||
|
||||
/**
|
||||
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
|
||||
* 1440 renders as "24:00" in 24h (its established reading) and as midnight
|
||||
* ("12:00 AM") in 12h, which has no 24:00 equivalent.
|
||||
*/
|
||||
fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String = when {
|
||||
minutes >= MINUTES_PER_DAY && is24Hour -> "24:00"
|
||||
minutes >= MINUTES_PER_DAY -> formatTimeOfDay(0, 0, is24Hour = false, locale)
|
||||
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
||||
* the prior look); 12h → "1 PM".
|
||||
*/
|
||||
fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
if (is24Hour) {
|
||||
"%02d".format(hour)
|
||||
} else {
|
||||
LocalTime.of(hour.coerceIn(0, 23), 0)
|
||||
.format(DateTimeFormatter.ofPattern(HOUR_PATTERN_12, locale))
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.jeanlucmakiola.calendula.ui.crash
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
|
||||
/**
|
||||
* A deliberately minimal, standalone surface for a captured crash report.
|
||||
* `MainActivity` routes here when it detects a startup crash-loop (see
|
||||
* [CrashReporter.isCrashLoop]): the main UI can't be trusted to start, so this
|
||||
* screen stays clear of the app's Hilt graph, DataStore-backed theme and
|
||||
* Compose content — it only reads the report file and shows the report dialog.
|
||||
* Plain [CalendulaTheme] defaults (follow-system, dynamic colour) avoid touching
|
||||
* anything that might be the cause of the crash.
|
||||
*/
|
||||
class CrashReportActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val report = CrashReporter.pendingReport(this)
|
||||
if (report == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
CalendulaTheme {
|
||||
// Opaque backdrop so the dialog doesn't float over a bare task.
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||
CrashReportDialog(
|
||||
report = report,
|
||||
onSend = {
|
||||
submitCrashReport(this, report)
|
||||
CrashReporter.clearReport(this)
|
||||
finish()
|
||||
},
|
||||
onDismiss = {
|
||||
CrashReporter.clearReport(this)
|
||||
finish()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Reaching this screen breaks the loop; reset the timing trail so a
|
||||
// later ordinary crash isn't mistaken for a loop.
|
||||
CrashReporter.markHealthy(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package de.jeanlucmakiola.calendula.ui.crash
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.filled.BugReport
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* Asks the user to send a captured crash report as an issue. The full report is
|
||||
* shown verbatim in a scrollable panel — the user sees exactly what will leave
|
||||
* the device before choosing to share it (the privacy backstop). [onSend] hands
|
||||
* off to [submitCrashReport]; [onDismiss] declines.
|
||||
*/
|
||||
@Composable
|
||||
fun CrashReportDialog(
|
||||
report: String,
|
||||
onSend: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
|
||||
title = { Text(stringResource(R.string.crash_dialog_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.crash_dialog_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = report,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.heightIn(max = 220.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.calendula.ui.crash
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.core.net.toUri
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* Hand the captured crash report off to the user's chosen channel: the report
|
||||
* is copied to the clipboard (the reliable path for a full stack trace) and the
|
||||
* project's Gitea "new issue" page is opened with the body prefilled. Nothing is
|
||||
* sent automatically — the app has no network access; the user reviews and
|
||||
* submits the issue themselves.
|
||||
*/
|
||||
fun submitCrashReport(context: Context, report: String) {
|
||||
copyReportToClipboard(context, report)
|
||||
val opened = runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report)))
|
||||
}.isSuccess
|
||||
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
/** Open the issue tracker's template chooser for a manual (non-crash) report. */
|
||||
fun openIssueTracker(context: Context) {
|
||||
val uri = context.getString(R.string.report_issue_choose_url).toUri()
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
|
||||
}
|
||||
|
||||
private fun copyReportToClipboard(context: Context, report: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||
val label = context.getString(R.string.crash_report_clip_label)
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
|
||||
}
|
||||
|
||||
/**
|
||||
* The Gitea `issues/new` URL with `title` and `body` prefilled. A full report
|
||||
* can blow past URL-length limits, so an over-long one is left out of the link
|
||||
* (with a "paste from clipboard" placeholder) — the clipboard copy is the
|
||||
* source of truth in that case.
|
||||
*/
|
||||
private fun buildIssueUri(context: Context, report: String) =
|
||||
context.getString(R.string.report_issue_url).toUri().buildUpon()
|
||||
.appendQueryParameter("title", context.getString(R.string.crash_report_issue_title))
|
||||
.appendQueryParameter("body", buildIssueBody(context, report))
|
||||
.build()
|
||||
|
||||
private fun buildIssueBody(context: Context, report: String): String {
|
||||
val block = if (report.length > MAX_URL_REPORT_CHARS) {
|
||||
context.getString(R.string.crash_report_body_paste)
|
||||
} else {
|
||||
"```\n$report\n```"
|
||||
}
|
||||
return context.getString(R.string.crash_report_body_template, block)
|
||||
}
|
||||
|
||||
/** Keep the prefilled body comfortably under common URL-length ceilings. */
|
||||
private const val MAX_URL_REPORT_CHARS = 6_000
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
@@ -71,12 +72,20 @@ 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.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -105,6 +114,7 @@ fun DayScreen(
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
initialDateIso: String? = null,
|
||||
@@ -188,6 +198,7 @@ fun DayScreen(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
@@ -233,6 +244,8 @@ private fun DayContent(
|
||||
val threshold = with(density) { 24.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
// Hoisted above the per-day AnimatedContent so the vertical scroll position
|
||||
// survives day-to-day swipes. We only centre on noon once, on first entry
|
||||
@@ -283,7 +296,7 @@ private fun DayContent(
|
||||
DayUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "day-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
@@ -340,6 +353,7 @@ private fun DayTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -358,6 +372,12 @@ private fun DayTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
@@ -451,6 +471,8 @@ private fun Timeline(
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||
@@ -473,7 +495,7 @@ private fun Timeline(
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = "%02d".format(h),
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
@@ -496,6 +518,7 @@ private fun Timeline(
|
||||
blocks = state.timed,
|
||||
dark = dark,
|
||||
date = state.date,
|
||||
today = state.today,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
modifier = Modifier
|
||||
@@ -512,11 +535,14 @@ private fun DayColumnCard(
|
||||
blocks: List<TimedBlock>,
|
||||
dark: Boolean,
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
Card(
|
||||
// Plain rectangular column — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -529,6 +555,9 @@ private fun DayColumnCard(
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Faint hour separators sit over the column background but under
|
||||
// the event blocks (drawBehind paints before the children).
|
||||
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
||||
// 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.
|
||||
@@ -556,6 +585,10 @@ private fun DayColumnCard(
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
}
|
||||
// Current-time line, on top of the events, only on today's column.
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,7 +601,10 @@ private fun EventBlock(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -614,8 +650,8 @@ private fun DayLoading() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun minToHm(min: Int): String =
|
||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
private fun formatDayTitle(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
@@ -92,9 +91,12 @@ 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.predictiveBack
|
||||
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.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
@@ -132,8 +134,6 @@ fun EventDetailScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -214,6 +214,7 @@ fun EventDetailScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = onBack),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -590,10 +591,22 @@ private fun AttendeeRow(attendee: Attendee) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
val hasName = attendee.name.isNotBlank()
|
||||
Text(
|
||||
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
||||
text = if (hasName) attendee.name else attendee.email.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
// Email as supporting text — only when the name is the headline, so we
|
||||
// don't repeat it on rows that already fall back to the email above.
|
||||
if (hasName) {
|
||||
attendee.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||
Text(
|
||||
text = email,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
attendeeRoleLabel(attendee)?.let { roleRes ->
|
||||
Text(
|
||||
text = stringResource(roleRes),
|
||||
@@ -770,7 +783,7 @@ private fun formatWhen(
|
||||
val zid = ZoneId.of(zone.id)
|
||||
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale)
|
||||
|
||||
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Patterns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -30,7 +31,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
@@ -38,10 +38,13 @@ import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Contacts
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
@@ -49,12 +52,11 @@ import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -79,16 +81,22 @@ 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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -97,6 +105,7 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
@@ -107,18 +116,27 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
@@ -186,8 +204,6 @@ fun EventEditScreen(
|
||||
viewModel.reset()
|
||||
onClose()
|
||||
}
|
||||
BackHandler(onBack = close)
|
||||
|
||||
// The event vanished between the detail screen and the edit tap — fall
|
||||
// back to the detail screen, which shows its own failure state.
|
||||
LaunchedEffect(loadFailed) {
|
||||
@@ -237,6 +253,7 @@ fun EventEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = close),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -394,7 +411,6 @@ private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
|
||||
* spring open with the expressive spatial spec instead of popping in.
|
||||
* (Initially-visible sections render without animating.)
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun OptionalFormSection(
|
||||
visible: Boolean,
|
||||
@@ -402,10 +418,8 @@ private fun OptionalFormSection(
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
|
||||
fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()),
|
||||
exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
|
||||
fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()),
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
||||
}
|
||||
@@ -421,6 +435,10 @@ private fun EventEditContent(
|
||||
val form = state.form
|
||||
val locale = currentLocale()
|
||||
val dark = isSystemInDarkTheme()
|
||||
// The title, date and recurrence are managed by the special-dates sync, so
|
||||
// they're locked here; everything else (reminders, location, notes) is the
|
||||
// user's to edit.
|
||||
val locked = state.isManaged
|
||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -429,6 +447,37 @@ private fun EventEditContent(
|
||||
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
||||
// email row (email + display name). No READ_CONTACTS — the result Intent
|
||||
// grants temporary read access, so Calendula stays no-network/no-permission.
|
||||
val context = LocalContext.current
|
||||
val pickContact = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
readContactEmail(context, uri)?.let { (email, name) ->
|
||||
viewModel.addAttendee(email, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pick a postal address from contacts: the same one-shot picker, scoped to
|
||||
// address rows, dropping the chosen contact's formatted address into the
|
||||
// location field. Same no-permission guarantee as the guest picker above.
|
||||
val pickContactAddress = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
readContactAddress(context, uri)?.let { address ->
|
||||
viewModel.setLocation(address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
||||
// The accent ties the form to the detail screen's design language: the
|
||||
// bar under the title takes the target calendar's colour.
|
||||
@@ -436,6 +485,17 @@ private fun EventEditContent(
|
||||
?: MaterialTheme.colorScheme.primary
|
||||
val gap = 12.dp
|
||||
|
||||
// Issue #10: on a fresh create form (setting on, nothing typed yet) focus the
|
||||
// title and raise the keyboard so the user can type straight away. Keyed on
|
||||
// Unit so it runs once per open — not on every keystroke — and guarded so
|
||||
// editing or an imported/prefilled form never grabs focus.
|
||||
val titleFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
if (state.autofocusTitle && !state.isEditing && form.title.isBlank()) {
|
||||
runCatching { titleFocusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
// Per-event colour applicability for the resolved calendar:
|
||||
// - palette calendars (Google, …) and local calendars always support it;
|
||||
// - synced calendars with no palette only when the user opted in, and even
|
||||
@@ -463,6 +523,11 @@ private fun EventEditContent(
|
||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||
textStyle = MaterialTheme.typography.headlineMedium
|
||||
.copy(fontWeight = FontWeight.SemiBold),
|
||||
enabled = !locked,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.focusRequester(titleFocusRequester),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
@@ -471,6 +536,17 @@ private fun EventEditContent(
|
||||
.height(3.dp)
|
||||
.background(accent, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
if (locked) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.event_edit_managed_hint,
|
||||
selectedCalendar?.displayName.orEmpty(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
@@ -489,7 +565,11 @@ private fun EventEditContent(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay)
|
||||
Switch(
|
||||
checked = form.isAllDay,
|
||||
onCheckedChange = viewModel::setAllDay,
|
||||
enabled = !locked,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
@@ -501,6 +581,7 @@ private fun EventEditContent(
|
||||
locale = locale,
|
||||
onDateClick = { picker = PickerTarget.StartDate },
|
||||
onTimeClick = { picker = PickerTarget.StartTime },
|
||||
enabled = !locked,
|
||||
)
|
||||
ScheduleRow(
|
||||
label = stringResource(R.string.event_edit_ends),
|
||||
@@ -510,6 +591,7 @@ private fun EventEditContent(
|
||||
onDateClick = { picker = PickerTarget.EndDate },
|
||||
onTimeClick = { picker = PickerTarget.EndTime },
|
||||
isError = EventFormProblem.EndBeforeStart in state.problems,
|
||||
enabled = !locked,
|
||||
)
|
||||
if (EventFormProblem.EndBeforeStart in state.problems) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
@@ -560,11 +642,39 @@ private fun EventEditContent(
|
||||
icon = Icons.Default.Place,
|
||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||
) {
|
||||
InlineField(
|
||||
value = form.location,
|
||||
onValueChange = viewModel::setLocation,
|
||||
placeholder = stringResource(R.string.event_detail_location),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
InlineField(
|
||||
value = form.location,
|
||||
onValueChange = viewModel::setLocation,
|
||||
placeholder = stringResource(R.string.event_detail_location),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 4.dp),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
pickContactAddress.launch(
|
||||
Intent(
|
||||
Intent.ACTION_PICK,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal
|
||||
.CONTENT_URI,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Contacts,
|
||||
contentDescription = stringResource(
|
||||
R.string.event_edit_location_from_contacts,
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,33 +697,24 @@ private fun EventEditContent(
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
// Reminders stack one per row (remove on the right edge); the
|
||||
// add chip closes the card at the bottom. The card icon centres
|
||||
// on the first row, whatever that is.
|
||||
EditCard(
|
||||
icon = Icons.Default.Notifications,
|
||||
iconContentDescription = stringResource(R.string.event_detail_reminders),
|
||||
header = {
|
||||
when (val first = form.reminders.firstOrNull()) {
|
||||
null -> AddReminderChip(onClick = { showReminderPicker = true })
|
||||
else -> ReminderRow(
|
||||
label = reminderLabel(first),
|
||||
onRemove = { viewModel.removeReminder(first) },
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
form.reminders.drop(1).forEach { minutes ->
|
||||
ReminderRow(
|
||||
label = reminderLabel(minutes),
|
||||
onRemove = { viewModel.removeReminder(minutes) },
|
||||
)
|
||||
}
|
||||
if (form.reminders.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
AddReminderChip(onClick = { showReminderPicker = true })
|
||||
}
|
||||
// A grouped list like guests: each reminder is its own tonal card
|
||||
// with connected corners, the last row being "Add reminder".
|
||||
val reminders = form.reminders
|
||||
val rows = reminders.size + 1
|
||||
reminders.forEachIndexed { index, minutes ->
|
||||
GroupedItemCard(
|
||||
position = positionOf(index, rows),
|
||||
icon = Icons.Default.Notifications,
|
||||
title = reminderLabel(minutes),
|
||||
removeContentDescription = stringResource(R.string.event_edit_remove_reminder),
|
||||
onRemove = { viewModel.removeReminder(minutes) },
|
||||
)
|
||||
}
|
||||
AddActionCard(
|
||||
label = stringResource(R.string.event_edit_add_reminder),
|
||||
position = positionOf(reminders.size, rows),
|
||||
onClick = { showReminderPicker = true },
|
||||
)
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
|
||||
@@ -621,7 +722,7 @@ private fun EventEditContent(
|
||||
EditCard(
|
||||
icon = Icons.Default.Repeat,
|
||||
iconContentDescription = null,
|
||||
onClick = { showRecurrencePicker = true },
|
||||
onClick = if (locked) null else ({ showRecurrencePicker = true }),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -776,6 +877,52 @@ private fun EventEditContent(
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Attendees in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
// Guests are an inline grouped list: each is a tonal card with a
|
||||
// role toggle + remove, and the trailing card is an inline email
|
||||
// field — type an address, press Done, it commits. Below sits an
|
||||
// honest note — Calendula never sends invitations, so whether a
|
||||
// guest is notified is the calendar backend's call.
|
||||
val guests = form.attendees
|
||||
val rows = guests.size + 1
|
||||
guests.forEachIndexed { index, attendee ->
|
||||
GuestCard(
|
||||
attendee = attendee,
|
||||
position = positionOf(index, rows),
|
||||
onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) },
|
||||
onRemove = { viewModel.removeAttendee(attendee) },
|
||||
)
|
||||
}
|
||||
AddGuestInlineCard(
|
||||
position = positionOf(guests.size, rows),
|
||||
onAdd = { viewModel.addAttendee(it) },
|
||||
onPickContact = {
|
||||
runCatching {
|
||||
pickContact.launch(
|
||||
Intent(
|
||||
Intent.ACTION_PICK,
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (isLocalCalendar) {
|
||||
R.string.event_edit_attendees_note_local
|
||||
} else {
|
||||
R.string.event_edit_attendees_note_synced
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
TextButton(
|
||||
@@ -840,6 +987,7 @@ private fun EventEditContent(
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (showRecurrencePicker) {
|
||||
RecurrencePickerDialog(
|
||||
current = form.rrule,
|
||||
@@ -911,9 +1059,9 @@ private fun FieldPickerDialog(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
hiddenFields.forEach { field ->
|
||||
OptionCard(
|
||||
label = stringResource(fieldLabel(field)),
|
||||
label = stringResource(eventFormFieldLabel(field)),
|
||||
onClick = { onSelect(field) },
|
||||
icon = fieldIcon(field),
|
||||
icon = eventFormFieldIcon(field),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1262,64 +1410,273 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
|
||||
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
||||
}
|
||||
|
||||
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */
|
||||
@Composable
|
||||
private fun ReminderRow(label: String, onRemove: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */
|
||||
private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape =
|
||||
when (position) {
|
||||
Position.Alone -> RoundedCornerShape(full)
|
||||
Position.Top -> RoundedCornerShape(
|
||||
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
||||
)
|
||||
IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) {
|
||||
Position.Middle -> RoundedCornerShape(small)
|
||||
Position.Bottom -> RoundedCornerShape(
|
||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||
)
|
||||
}
|
||||
|
||||
/** The 2dp gap that visually separates grouped cards (none after the last). */
|
||||
private fun groupedGap(position: Position): Modifier = when (position) {
|
||||
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||
Position.Bottom, Position.Alone -> Modifier
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in a grouped form list (a guest, a reminder): a leading [icon],
|
||||
* a [title] with optional [supporting] line, and a remove button — all on a
|
||||
* tonal card whose corners come from its [position] in the run.
|
||||
*/
|
||||
@Composable
|
||||
private fun GroupedItemCard(
|
||||
position: Position,
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
removeContentDescription: String,
|
||||
onRemove: () -> Unit,
|
||||
supporting: String? = null,
|
||||
titleMaxLines: Int = Int.MAX_VALUE,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = groupedShape(position),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(groupedGap(position)),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.event_edit_remove_reminder),
|
||||
modifier = Modifier.size(18.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (supporting != null) {
|
||||
Text(
|
||||
text = supporting,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (trailing != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
trailing()
|
||||
}
|
||||
IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = removeContentDescription,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One guest as a grouped-list card: avatar glyph, name/email, a tappable
|
||||
* Required/Optional role chip, and remove. The email shows as the supporting
|
||||
* line when a name is present, otherwise as the title.
|
||||
*/
|
||||
@Composable
|
||||
private fun GuestCard(
|
||||
attendee: EventAttendee,
|
||||
position: Position,
|
||||
onToggleOptional: (Boolean) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val hasName = attendee.name.isNotBlank()
|
||||
GroupedItemCard(
|
||||
position = position,
|
||||
icon = Icons.Default.Person,
|
||||
title = if (hasName) attendee.name else attendee.email,
|
||||
supporting = attendee.email.takeIf { hasName },
|
||||
titleMaxLines = 1,
|
||||
removeContentDescription = stringResource(R.string.event_edit_remove_guest),
|
||||
onRemove = onRemove,
|
||||
trailing = {
|
||||
FilterChip(
|
||||
selected = attendee.optional,
|
||||
onClick = { onToggleOptional(!attendee.optional) },
|
||||
label = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (attendee.optional) {
|
||||
R.string.event_edit_attendee_optional
|
||||
} else {
|
||||
R.string.event_edit_attendee_required
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The trailing "Add …" action card of a grouped form list (opens a picker). */
|
||||
@Composable
|
||||
private fun AddActionCard(label: String, position: Position, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = groupedShape(position),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(groupedGap(position)),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailing card of the guests list: an inline email field plus a "from
|
||||
* contacts" button. Type an address and press Done (or tap +) to commit a guest;
|
||||
* or pick from contacts to fill email + name at once. The field clears after,
|
||||
* ready for the next. Matches the form's inline-field input style — no dialog.
|
||||
*/
|
||||
@Composable
|
||||
private fun AddReminderChip(onClick: () -> Unit) {
|
||||
AssistChip(
|
||||
onClick = onClick,
|
||||
label = { Text(stringResource(R.string.event_edit_add)) },
|
||||
leadingIcon = {
|
||||
private fun AddGuestInlineCard(
|
||||
position: Position,
|
||||
onAdd: (String) -> Unit,
|
||||
onPickContact: () -> Unit,
|
||||
) {
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() }
|
||||
fun commit() {
|
||||
if (valid) {
|
||||
onAdd(text.trim())
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = groupedShape(position),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(groupedGap(position)),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
},
|
||||
border = null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
InlineTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
placeholder = stringResource(R.string.event_edit_add_guest_hint),
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = { commit() },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
if (valid) {
|
||||
IconButton(onClick = { commit() }, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.event_edit_add_guest),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onPickContact, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Contacts,
|
||||
contentDescription = stringResource(R.string.event_edit_add_guest_from_contacts),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
EventFormField.Availability -> R.string.event_edit_availability
|
||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||
EventFormField.Color -> R.string.event_edit_color
|
||||
}
|
||||
/**
|
||||
* Read the email + display name from a contact-pick result URI. No
|
||||
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
||||
*/
|
||||
private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>? =
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
arrayOf(
|
||||
ContactsContract.CommonDataKinds.Email.ADDRESS,
|
||||
ContactsContract.Contacts.DISPLAY_NAME,
|
||||
),
|
||||
null, null, null,
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
val email = cursor.getString(0).orEmpty().trim()
|
||||
val name = cursor.getString(1).orEmpty().trim()
|
||||
email.takeIf { it.isNotEmpty() }?.let { it to name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the formatted postal address from a contact-pick result URI. No
|
||||
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
||||
* The provider's formatted address is multi-line; collapse it to one line so
|
||||
* it sits cleanly in the single-line location field.
|
||||
*/
|
||||
private fun readContactAddress(context: Context, uri: Uri): String? =
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
arrayOf(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS),
|
||||
null, null, null,
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
cursor.getString(0).orEmpty()
|
||||
.lines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(", ")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
EventFormField.Location -> Icons.Default.Place
|
||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||
EventFormField.Reminders -> Icons.Default.Notifications
|
||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
EventFormField.Visibility -> Icons.Default.Lock
|
||||
EventFormField.Color -> Icons.Default.Palette
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility selector: one card per level, each with its own icon; the
|
||||
@@ -1388,7 +1745,7 @@ private fun ColorPickerDialog(
|
||||
)
|
||||
} else {
|
||||
ColorSwatchRow(
|
||||
colors = CALENDAR_COLOR_PALETTE,
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = selected,
|
||||
onSelect = onPickRaw,
|
||||
dark = dark,
|
||||
@@ -1523,6 +1880,7 @@ private fun InlineField(
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1536,6 +1894,7 @@ private fun InlineField(
|
||||
textStyle = textStyle,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
enabled = enabled,
|
||||
keyboardType = keyboardType,
|
||||
)
|
||||
}
|
||||
@@ -1550,19 +1909,22 @@ private fun ScheduleRow(
|
||||
onDateClick: () -> Unit,
|
||||
onTimeClick: () -> Unit,
|
||||
isError: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val dateFormat = remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
}
|
||||
val timeFormat = remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeFormat = remember(locale, use24Hour) {
|
||||
timeOfDayFormatter(use24Hour, locale)
|
||||
}
|
||||
// Tappable values read as links (primary), like the location on the
|
||||
// detail screen; errors flip them to the error colour.
|
||||
val valueColor = if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
// detail screen; errors flip them to the error colour; a locked (managed)
|
||||
// row dims to a plain, non-tappable value.
|
||||
val valueColor = when {
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
isError -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1579,7 +1941,7 @@ private fun ScheduleRow(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onDateClick)
|
||||
.clickable(enabled = enabled, onClick = onDateClick)
|
||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
if (!isAllDay) {
|
||||
@@ -1588,7 +1950,7 @@ private fun ScheduleRow(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onTimeClick)
|
||||
.clickable(enabled = enabled, onClick = onTimeClick)
|
||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,18 @@ data class EventEditUiState(
|
||||
val hiddenFields: List<EventFormField> = emptyList(),
|
||||
/** True while editing an existing event (the calendar is then fixed). */
|
||||
val isEditing: Boolean = false,
|
||||
/**
|
||||
* True when the event's calendar is a contact special-dates calendar the app
|
||||
* manages. While editing such an event the title, date and recurrence are
|
||||
* locked (sync overwrites them); reminders, location and notes stay editable.
|
||||
*/
|
||||
val isManaged: Boolean = false,
|
||||
/**
|
||||
* Whether to focus the title and raise the keyboard when this is a fresh
|
||||
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||
* on it for a new event with an empty title, never when editing/importing.
|
||||
*/
|
||||
val autofocusTitle: Boolean = true,
|
||||
/**
|
||||
* 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).
|
||||
|
||||
@@ -13,6 +13,7 @@ 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.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
@@ -107,10 +108,10 @@ class EventEditViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
private data class ReminderDefaults(
|
||||
val timed: Int?,
|
||||
val allDay: Int?,
|
||||
val timedOverrides: Map<Long, Int?>,
|
||||
val allDayOverrides: Map<Long, Int?>,
|
||||
val timed: List<Int>,
|
||||
val allDay: List<Int>,
|
||||
val timedOverrides: Map<Long, List<Int>>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
)
|
||||
|
||||
private data class ExternalInputs(
|
||||
@@ -118,12 +119,21 @@ class EventEditViewModel @Inject constructor(
|
||||
val lastUsed: Long?,
|
||||
val defaultFields: Set<EventFormField>,
|
||||
val allowColorOnUnsupported: Boolean,
|
||||
val autofocusTitle: 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()) }
|
||||
/**
|
||||
* Writable calendars — the only valid event targets. Disabled calendars are
|
||||
* excluded, so you can't create into a calendar you've removed from the app;
|
||||
* a last-used preselect landing on a now-disabled calendar falls back to the
|
||||
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
||||
*/
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
||||
repository.calendars(),
|
||||
prefs.disabledCalendarIds,
|
||||
) { calendars, disabled ->
|
||||
calendars.filter { it.canModifyContents && it.id !in disabled }
|
||||
}.catch { emit(emptyList()) }
|
||||
|
||||
/** The target calendar id, resolved exactly as the form shows it. */
|
||||
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||
@@ -151,10 +161,12 @@ class EventEditViewModel @Inject constructor(
|
||||
prefs.lastUsedCalendarId,
|
||||
settingsPrefs.defaultFormFields,
|
||||
settingsPrefs.allowColorOnUnsupportedCalendars,
|
||||
settingsPrefs.autofocusEventTitle,
|
||||
::ExternalInputs,
|
||||
).flowOn(io),
|
||||
colorPalette,
|
||||
) { local, external, palette ->
|
||||
settingsPrefs.managedCalendarIds,
|
||||
) { local, external, palette, managedIds ->
|
||||
val form = local.form ?: return@combine null
|
||||
val resolvedId = form.calendarId
|
||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||
@@ -169,6 +181,9 @@ class EventEditViewModel @Inject constructor(
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
isEditing = local.editTarget != null,
|
||||
// Editing an event in a managed calendar locks its synced fields.
|
||||
isManaged = local.editTarget != null && resolvedId != null && resolvedId in managedIds,
|
||||
autofocusTitle = external.autofocusTitle,
|
||||
// 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 &&
|
||||
@@ -248,7 +263,7 @@ class EventEditViewModel @Inject constructor(
|
||||
// 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(
|
||||
val reminders = resolveDefaultReminder(
|
||||
timedGlobal = defaults.timed,
|
||||
allDayGlobal = defaults.allDay,
|
||||
timedOverrides = defaults.timedOverrides,
|
||||
@@ -256,7 +271,6 @@ class EventEditViewModel @Inject constructor(
|
||||
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.
|
||||
@@ -353,6 +367,33 @@ class EventEditViewModel @Inject constructor(
|
||||
update { it.copy(reminders = it.reminders - minutes) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a guest, keyed on a trimmed lower-cased email so re-adding the same
|
||||
* address just refreshes it (no duplicate row). A blank email is ignored —
|
||||
* the email is what we write and dedup on.
|
||||
*/
|
||||
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
|
||||
val trimmed = email.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
update { form ->
|
||||
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
|
||||
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
|
||||
form.copy(attendees = rest + attendee)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAttendee(attendee: EventAttendee) =
|
||||
update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
|
||||
|
||||
/** Flip a guest between required and optional (the per-row role toggle). */
|
||||
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form ->
|
||||
form.copy(
|
||||
attendees = form.attendees.map { a ->
|
||||
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 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) }
|
||||
|
||||
@@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor(
|
||||
combine(
|
||||
repository.calendars(),
|
||||
prefs.hiddenCalendarIds,
|
||||
) { calendars, hidden ->
|
||||
if (calendars.isEmpty()) {
|
||||
prefs.disabledCalendarIds,
|
||||
) { calendars, hidden, disabled ->
|
||||
// Disabled calendars are gone from the app entirely — they don't
|
||||
// belong in the drawer's hide/show list (you can't hide what's
|
||||
// already disabled). They live only in Settings → Calendars.
|
||||
val enabled = calendars.filterNot { it.id in disabled }
|
||||
if (enabled.isEmpty()) {
|
||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
} else {
|
||||
FilterUiState.Success(groupByAccount(calendars, hidden))
|
||||
FilterUiState.Success(groupByAccount(enabled, hidden))
|
||||
}
|
||||
}
|
||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -39,6 +38,7 @@ 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.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
|
||||
/**
|
||||
@@ -57,7 +57,6 @@ fun ImportScreen(
|
||||
) {
|
||||
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) {
|
||||
@@ -65,7 +64,9 @@ fun ImportScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.predictiveBack(onBack = onClose)
|
||||
.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.import_title)) },
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
@@ -56,6 +57,7 @@ sealed interface ImportUiState {
|
||||
class ImportViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val importer: IcsImporter,
|
||||
private val prefs: CalendarPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -79,11 +81,17 @@ class ImportViewModel @Inject constructor(
|
||||
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 },
|
||||
)
|
||||
else -> {
|
||||
// A disabled calendar is removed from the app, so it can't be
|
||||
// an import target — exclude it alongside the read-only ones.
|
||||
val disabled = prefs.disabledCalendarIds.first()
|
||||
ImportUiState.Many(
|
||||
events = parsed.events,
|
||||
warnings = parsed.warnings,
|
||||
calendars = repository.calendars().first()
|
||||
.filter { it.canModifyContents && it.id !in disabled },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -35,6 +36,8 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -43,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -59,12 +63,18 @@ 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.hasEnded
|
||||
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.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
@@ -86,6 +96,7 @@ fun MonthScreen(
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MonthViewModel = hiltViewModel(),
|
||||
@@ -93,6 +104,14 @@ fun MonthScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
val nowState = rememberCurrentMinute()
|
||||
val dimCutoff by remember(dimCompleted) {
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -161,6 +180,7 @@ fun MonthScreen(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
@@ -188,14 +208,16 @@ fun MonthScreen(
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
WeekdayHeader(weekStart = weekStart)
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,6 +236,8 @@ private fun MonthContent(
|
||||
val threshold = with(density) { 6.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
@@ -240,7 +264,7 @@ private fun MonthContent(
|
||||
MonthUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "month-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
@@ -261,6 +285,7 @@ private fun MonthTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -279,6 +304,12 @@ private fun MonthTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
@@ -544,6 +575,8 @@ private fun MonthBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
@@ -551,7 +584,7 @@ private fun MonthBar(
|
||||
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(event.color, dark), shape)
|
||||
.padding(horizontal = 4.dp)
|
||||
.semantics { contentDescription = title },
|
||||
|
||||
@@ -59,6 +59,14 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = DayOfWeek.MONDAY,
|
||||
)
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package de.jeanlucmakiola.calendula.ui.search
|
||||
|
||||
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.imePadding
|
||||
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.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.SearchOff
|
||||
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.Surface
|
||||
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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
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.calendarAnimateItem
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
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.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import java.time.Instant as JavaInstant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/**
|
||||
* Full-text event search (top-bar entry). Type a query → matching events
|
||||
* (title / location / description) across the whole calendar, newest-relevant
|
||||
* first; tap a result to open its detail. A full-screen overlay hosted by
|
||||
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
onBack: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SearchViewModel = hiltViewModel(),
|
||||
) {
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
// Each fresh open starts blank and straight into typing. The ViewModel is
|
||||
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
|
||||
// resets a previous search. Peeking a result doesn't re-run this (the screen
|
||||
// stays composed under the detail), so backing out keeps the query.
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.setQuery("")
|
||||
focusRequester.requestFocus()
|
||||
keyboard?.show()
|
||||
}
|
||||
Scaffold(
|
||||
modifier = modifier.predictiveBack(onBack = onBack),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
InlineTextField(
|
||||
value = query,
|
||||
onValueChange = viewModel::setQuery,
|
||||
placeholder = stringResource(R.string.search_hint),
|
||||
imeAction = ImeAction.Search,
|
||||
onImeAction = { keyboard?.hide() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.search_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.setQuery("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.search_clear),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
// imePadding shrinks the content by the keyboard, so the centered
|
||||
// idle/empty message re-centres in the space above it (and the results
|
||||
// list lifts clear of the keyboard too).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding(),
|
||||
) {
|
||||
when (val s = state) {
|
||||
SearchUiState.Idle -> SearchMessage(
|
||||
icon = null,
|
||||
text = stringResource(R.string.search_idle_hint),
|
||||
)
|
||||
is SearchUiState.Empty -> SearchMessage(
|
||||
icon = Icons.Default.SearchOff,
|
||||
text = stringResource(R.string.search_empty, s.query),
|
||||
)
|
||||
is SearchUiState.Results -> SearchResults(
|
||||
events = s.events,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResults(
|
||||
events: List<EventInstance>,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = events,
|
||||
key = { _, event -> event.eventId },
|
||||
) { index, event ->
|
||||
SearchResultRow(
|
||||
event = event,
|
||||
position = positionOf(index, events.size),
|
||||
modifier = calendarAnimateItem(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
GroupedRow(
|
||||
modifier = modifier,
|
||||
title = event.title,
|
||||
summary = searchSummary(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,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
||||
@Composable
|
||||
private fun searchSummary(event: EventInstance): String {
|
||||
val locale = currentLocale()
|
||||
val zone = remember { ZoneId.systemDefault() }
|
||||
val start = remember(event.start, zone) {
|
||||
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
||||
}
|
||||
val dateText = remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
}.format(start)
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeText = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||
}
|
||||
val base = "$dateText · $timeText"
|
||||
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchMessage(
|
||||
icon: ImageVector?,
|
||||
text: String,
|
||||
) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.jeanlucmakiola.calendula.ui.search
|
||||
|
||||
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.EventInstance
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
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.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlin.time.Clock
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Shortest query that triggers a search; one character matches almost everything. */
|
||||
private const val MIN_QUERY_LENGTH = 2
|
||||
|
||||
sealed interface SearchUiState {
|
||||
/** No query yet, or one too short to search — show the prompt. */
|
||||
data object Idle : SearchUiState
|
||||
|
||||
/** A query ran but matched nothing. */
|
||||
data class Empty(val query: String) : SearchUiState
|
||||
|
||||
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
||||
data class Results(val events: List<EventInstance>) : SearchUiState
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@HiltViewModel
|
||||
class SearchViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _query = MutableStateFlow("")
|
||||
val query: StateFlow<String> = _query.asStateFlow()
|
||||
|
||||
val state: StateFlow<SearchUiState> = _query
|
||||
.debounce(250L)
|
||||
.map { it.trim() }
|
||||
.distinctUntilChanged()
|
||||
.mapLatest { q ->
|
||||
if (q.length < MIN_QUERY_LENGTH) {
|
||||
SearchUiState.Idle
|
||||
} else {
|
||||
val results = repository.searchEvents(q)
|
||||
if (results.isEmpty()) SearchUiState.Empty(q)
|
||||
else SearchUiState.Results(sortNearestFirst(results))
|
||||
}
|
||||
}
|
||||
.catch { emit(SearchUiState.Idle) }
|
||||
.flowOn(io)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
||||
|
||||
fun setQuery(value: String) {
|
||||
_query.value = value
|
||||
}
|
||||
|
||||
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
||||
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
||||
val now = Clock.System.now()
|
||||
val (upcoming, past) = events.partition { it.end >= now }
|
||||
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
|
||||
/**
|
||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||
@@ -16,28 +22,48 @@ data class SettingsUiState(
|
||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
val dynamicColor: Boolean = true,
|
||||
val dynamicColorAvailable: Boolean = true,
|
||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
||||
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
||||
val showHourLines: Boolean = false,
|
||||
/** How the Agenda screen treats events that already ended today. */
|
||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
/** Whether the month/week grids fade events that have already finished. */
|
||||
val dimCompletedEvents: Boolean = false,
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||
val agendaShowRangeBar: Boolean = true,
|
||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||
val defaultView: CalendarView = CalendarView.Week,
|
||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||
val autofocusEventTitle: Boolean = true,
|
||||
/** 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.
|
||||
* The default reminder lead times (minutes) prefilled on new timed events;
|
||||
* empty = 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,
|
||||
val defaultReminderMinutes: List<Int> = emptyList(),
|
||||
/** The default reminder lead times prefilled on new all-day events; empty = none. */
|
||||
val defaultAllDayReminderMinutes: List<Int> = emptyList(),
|
||||
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
||||
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
|
||||
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
|
||||
/**
|
||||
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
||||
* calendar present in the map overrides the global default (null value = no
|
||||
* calendar present in the map overrides the global default (empty value = no
|
||||
* reminder); absent = inherit the global default.
|
||||
*/
|
||||
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||
/**
|
||||
@@ -45,4 +71,30 @@ data class SettingsUiState(
|
||||
* colour palette (the colour may then not survive their next sync).
|
||||
*/
|
||||
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||
/**
|
||||
* Ids of the contact special-dates calendars. Their reminders are owned by
|
||||
* the Contact special dates section, so the per-calendar override list links
|
||||
* out to it instead of offering an override here.
|
||||
*/
|
||||
val managedCalendarIds: Set<Long> = emptySet(),
|
||||
)
|
||||
|
||||
/**
|
||||
* State for the contact special-dates sub-page (issue #15). Exposed as its own
|
||||
* flow rather than folded into [SettingsUiState] (whose combine is already full).
|
||||
* [titleTemplates] is always populated — a blank stored template resolves to the
|
||||
* localized default so the editor never shows an empty field.
|
||||
*/
|
||||
data class SpecialDatesUiState(
|
||||
val enabled: Boolean = false,
|
||||
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
|
||||
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
||||
/** The reminder choice per type's managed calendar (applied to all its events). */
|
||||
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(),
|
||||
/** Whether {year} resolves in titles (the source year is static and always correct). */
|
||||
val showYear: Boolean = true,
|
||||
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
|
||||
val stalledPermission: Boolean = false,
|
||||
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||
val lastRun: Long = 0L,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
|
||||
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -19,12 +43,18 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
repository: CalendarRepository,
|
||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
@@ -59,25 +89,56 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.defaultReminderMinutes,
|
||||
prefs.defaultAllDayReminderMinutes,
|
||||
prefs.allDayReminderTimeMinutes,
|
||||
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime ->
|
||||
ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime)
|
||||
prefs.snoozeMinutes,
|
||||
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze ->
|
||||
ReminderDefaults(
|
||||
allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze,
|
||||
)
|
||||
},
|
||||
combine(
|
||||
prefs.perCalendarReminderOverride,
|
||||
prefs.perCalendarAllDayReminderOverride,
|
||||
writableCalendars,
|
||||
) { overrides, allDayOverrides, calendars ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||
prefs.managedCalendarIds,
|
||||
) { overrides, allDayOverrides, calendars, managedIds ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
|
||||
},
|
||||
) { base, defaults, overrides ->
|
||||
combine(
|
||||
prefs.defaultView,
|
||||
prefs.agendaScreenRange,
|
||||
prefs.agendaWidgetRange,
|
||||
prefs.timeFormat,
|
||||
prefs.showHourLines,
|
||||
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
||||
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
||||
},
|
||||
combine(
|
||||
prefs.agendaShowRangeBar,
|
||||
prefs.autofocusEventTitle,
|
||||
prefs.pastEventDisplay,
|
||||
prefs.dimCompletedEvents,
|
||||
::MiscSettings,
|
||||
),
|
||||
) { base, defaults, overrides, views, misc ->
|
||||
base.copy(
|
||||
defaultView = views.defaultView,
|
||||
agendaScreenRange = views.agendaScreenRange,
|
||||
agendaWidgetRange = views.agendaWidgetRange,
|
||||
timeFormat = views.timeFormat,
|
||||
showHourLines = views.showHourLines,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
dimCompletedEvents = misc.dimCompletedEvents,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
allDayReminderTimeMinutes = defaults.allDayReminderTime,
|
||||
snoozeMinutes = defaults.snoozeMinutes,
|
||||
perCalendarReminderOverride = overrides.timed,
|
||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||
writableCalendars = overrides.calendars,
|
||||
managedCalendarIds = overrides.managedIds,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
@@ -85,19 +146,131 @@ class SettingsViewModel @Inject constructor(
|
||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||
)
|
||||
|
||||
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
|
||||
// updateAll calls at once — concurrent calls coalesce around a stale read
|
||||
// and can leave the widget on the intermediate value. Held sequentially,
|
||||
// the last call reads the final committed pref and renders it.
|
||||
private val widgetRefreshMutex = Mutex()
|
||||
|
||||
private data class ReminderDefaults(
|
||||
val allowColor: Boolean,
|
||||
val defaultReminder: Int?,
|
||||
val allDayReminder: Int?,
|
||||
val defaultReminder: List<Int>,
|
||||
val allDayReminder: List<Int>,
|
||||
val allDayReminderTime: Int,
|
||||
val snoozeMinutes: Int,
|
||||
)
|
||||
|
||||
private data class ReminderOverrides(
|
||||
val timed: Map<Long, Int?>,
|
||||
val allDay: Map<Long, Int?>,
|
||||
val timed: Map<Long, List<Int>>,
|
||||
val allDay: Map<Long, List<Int>>,
|
||||
val calendars: List<CalendarSource>,
|
||||
val managedIds: Set<Long>,
|
||||
)
|
||||
|
||||
private data class ViewSettings(
|
||||
val defaultView: CalendarView,
|
||||
val agendaScreenRange: AgendaRange,
|
||||
val agendaWidgetRange: AgendaRange,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val showHourLines: Boolean,
|
||||
)
|
||||
|
||||
private data class MiscSettings(
|
||||
val showRangeBar: Boolean,
|
||||
val autofocusEventTitle: Boolean,
|
||||
val pastEventDisplay: PastEventDisplay,
|
||||
val dimCompletedEvents: Boolean,
|
||||
)
|
||||
|
||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||
val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
|
||||
combine(
|
||||
prefs.specialDatesEnabled,
|
||||
prefs.specialDatesTypes,
|
||||
prefs.specialDatesTitleTemplates,
|
||||
prefs.specialDatesShowYear,
|
||||
prefs.specialDatesStatus,
|
||||
) { enabled, types, templates, showYear, status ->
|
||||
SpecialDatesUiState(
|
||||
enabled = enabled,
|
||||
types = types,
|
||||
// A blank stored template resolves to the localized default so the
|
||||
// editor always shows something sensible.
|
||||
titleTemplates = SpecialDateType.entries.associateWith { type ->
|
||||
specialDatesSpec.resolveTitleTemplate(type, templates)
|
||||
},
|
||||
showYear = showYear,
|
||||
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
|
||||
lastRun = status.lastRun,
|
||||
)
|
||||
},
|
||||
prefs.specialDatesCalendars,
|
||||
prefs.perCalendarAllDayReminderOverride,
|
||||
) { base, calendars, allDayOverrides ->
|
||||
base.copy(
|
||||
// An override is always seeded on creation; present-empty = None.
|
||||
reminderChoices = calendars.mapValues { (_, calendarId) ->
|
||||
allDayOverrides.choiceFor(calendarId)
|
||||
},
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = SpecialDatesUiState(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Turn the mirror on or off. Enabling schedules the daily job and runs an
|
||||
* immediate sync (which creates the calendars); disabling cancels the job and
|
||||
* tears the managed calendars down. The caller must have READ_CONTACTS granted
|
||||
* before enabling.
|
||||
*/
|
||||
fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesEnabled(enabled)
|
||||
if (enabled) {
|
||||
SpecialDatesScheduler.apply(appContext, enabled = true)
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
} else {
|
||||
SpecialDatesScheduler.apply(appContext, enabled = false)
|
||||
withContext(io) { specialDatesEngine.teardown() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDateTypeEnabled(type, enabled)
|
||||
// Reconcile now: create the new type's calendar, or delete a disabled one.
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesTitleTemplate(type, template.trim())
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesShowYear(enabled)
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a type's managed-calendar reminder default and apply it to all its
|
||||
* existing events (managed calendars own their reminders calendar-wide).
|
||||
*/
|
||||
fun setSpecialDatesReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
||||
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
|
||||
}
|
||||
|
||||
/** Kick off an immediate reconcile (the "Sync now" action). */
|
||||
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
}
|
||||
@@ -107,7 +280,72 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun setWeekStart(pref: WeekStartPref) {
|
||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||
viewModelScope.launch {
|
||||
prefs.setWeekStart(pref)
|
||||
// Both widgets depend on the week start (month weekday header; the
|
||||
// agenda widget's "this week" range).
|
||||
widgetRefreshMutex.withLock {
|
||||
AgendaWidget().updateAll(appContext)
|
||||
MonthWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaScreenRange(range: AgendaRange) {
|
||||
viewModelScope.launch { prefs.setAgendaScreenRange(range) }
|
||||
}
|
||||
|
||||
fun setAgendaWidgetRange(range: AgendaRange) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAgendaWidgetRange(range)
|
||||
widgetRefreshMutex.withLock {
|
||||
// Push the range into each instance's Glance state and recompose.
|
||||
// The widget reads it via currentState, so this reflects reliably
|
||||
// even when updateAll only recomposes a live session (it does not
|
||||
// re-run provideGlance's data preamble).
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaShowRangeBar(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||
}
|
||||
|
||||
fun setTimeFormat(pref: TimeFormatPref) {
|
||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||
}
|
||||
|
||||
fun setShowHourLines(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||
}
|
||||
|
||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
viewModelScope.launch {
|
||||
prefs.setPastEventDisplay(mode)
|
||||
// The agenda widget honours this setting too, so push it into each
|
||||
// instance's Glance state and recompose — same reliable-update path as
|
||||
// setAgendaWidgetRange (updateAll alone won't re-run the data preamble).
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDimCompletedEvents(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setDimCompletedEvents(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultView(view: CalendarView) {
|
||||
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||
}
|
||||
|
||||
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||
@@ -118,11 +356,15 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultReminderMinutes(minutes: Int?) {
|
||||
fun setAutofocusEventTitle(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
@@ -130,6 +372,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
||||
}
|
||||
|
||||
fun setSnoozeMinutes(minutes: Int) {
|
||||
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||
}
|
||||
|
||||
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
@@ -44,7 +45,9 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -54,6 +57,7 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -72,13 +76,25 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.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.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
@@ -113,12 +129,21 @@ fun WeekScreen(
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
val nowState = rememberCurrentMinute()
|
||||
val dimCutoff by remember(dimCompleted) {
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -193,6 +218,7 @@ fun WeekScreen(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
@@ -210,19 +236,21 @@ fun WeekScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,6 +271,8 @@ private fun WeekContent(
|
||||
val threshold = with(density) { 24.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
// Hoisted above the per-week AnimatedContent so the vertical scroll position
|
||||
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
|
||||
@@ -296,7 +326,7 @@ private fun WeekContent(
|
||||
WeekUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "week-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
@@ -351,6 +381,7 @@ private fun WeekTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -369,6 +400,12 @@ private fun WeekTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
@@ -530,8 +567,10 @@ private fun AllDayBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
@@ -557,6 +596,8 @@ private fun Timeline(
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||
@@ -580,7 +621,7 @@ private fun Timeline(
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = "%02d".format(h),
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
@@ -610,6 +651,7 @@ private fun Timeline(
|
||||
blocks = state.timedByDay[day].orEmpty(),
|
||||
dark = dark,
|
||||
date = day,
|
||||
today = state.today,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
modifier = Modifier
|
||||
@@ -628,11 +670,14 @@ private fun DayColumnCard(
|
||||
blocks: List<TimedBlock>,
|
||||
dark: Boolean,
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
Card(
|
||||
// Plain rectangular columns — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -645,6 +690,9 @@ private fun DayColumnCard(
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Faint hour separators sit over the column background but under
|
||||
// the event blocks (drawBehind paints before the children).
|
||||
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
||||
// 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) {
|
||||
@@ -663,6 +711,7 @@ private fun DayColumnCard(
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
@@ -671,6 +720,10 @@ private fun DayColumnCard(
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
}
|
||||
// Current-time line, on top of the events, only on today's column.
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -679,14 +732,35 @@ private fun DayColumnCard(
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
// Only full-width (non-overlapping) blocks that are tall enough show the time.
|
||||
// On narrow overlapping columns we drop it so the title can wrap to fill the
|
||||
// whole block, mirroring Google Calendar.
|
||||
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
|
||||
val density = LocalDensity.current
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// Wrap the title across as many lines as the block can fit (minus the 2.dp
|
||||
// top/bottom padding and the reserved time line) instead of clipping it to a
|
||||
// single character on slim, overlapping blocks.
|
||||
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
|
||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
@@ -696,18 +770,15 @@ private fun EventBlock(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
// Narrow columns can't fit "13:00–14:00" on one line, so let it
|
||||
// wrap to a second line (after the dash) instead of clipping the
|
||||
// end time.
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
)
|
||||
@@ -752,8 +823,8 @@ private fun WeekLoading() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun minToHm(min: Int): String =
|
||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
|
||||
@@ -50,6 +50,14 @@ class WeekViewModel @Inject constructor(
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
private val locale: Locale = Locale.getDefault()
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
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.agendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -22,9 +25,7 @@ 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
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* How far either side of today the month widget pre-loads. The displayed month
|
||||
@@ -46,7 +47,28 @@ internal fun Context.hasCalendarPermission(): Boolean =
|
||||
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
|
||||
data class Ready(
|
||||
val today: LocalDate,
|
||||
/**
|
||||
* Days with events across the **widest** selectable window. The displayed
|
||||
* range is sliced from this in the composition (read reactively from
|
||||
* Glance state), so a range change is pure recomposition rather than a
|
||||
* flaky widget session restart — the same approach the month widget uses.
|
||||
* Kept unfiltered: hiding/dimming finished events also happens reactively
|
||||
* in the composition, against [now], for the same reliable-update reason.
|
||||
*/
|
||||
val days: List<AgendaDay>,
|
||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||
val is24Hour: Boolean,
|
||||
/** First day of the week, for the calendar-aligned "this week" range. */
|
||||
val weekStart: DayOfWeek,
|
||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||
val savedRange: AgendaRange,
|
||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||
val savedPastDisplay: PastEventDisplay,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,9 +113,27 @@ 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))
|
||||
val ep = widgetEntryPoint()
|
||||
val prefs = ep.settingsPrefs()
|
||||
val savedRange = prefs.agendaWidgetRange.first()
|
||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||
// Load the widest selectable window once; the displayed range is sliced in
|
||||
// the composition from Glance state, so changing the range is a plain
|
||||
// recomposition and never depends on the widget session restarting.
|
||||
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
||||
val instances = ep.calendarRepository().instances(window).first()
|
||||
val is24Hour = prefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||
return AgendaWidgetData.Ready(
|
||||
today = anchor,
|
||||
days = groupAgendaDays(anchor, instances, zone),
|
||||
is24Hour = is24Hour,
|
||||
weekStart = weekStart,
|
||||
savedRange = savedRange,
|
||||
savedPastDisplay = savedPastDisplay,
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.stringPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
@@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
@@ -40,7 +43,16 @@ import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
@@ -61,8 +73,28 @@ import java.util.Locale
|
||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
/**
|
||||
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
||||
* The range is read reactively in the composition ([currentState]) so a settings
|
||||
* change is reflected by plain recomposition — `updateAll` does NOT reliably
|
||||
* re-run the `provideGlance` preamble for a live widget session, so loading the
|
||||
* range there and slicing it would silently keep the stale value (mirrors the
|
||||
* month widget's MONTH_INDEX_KEY approach).
|
||||
*/
|
||||
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
||||
|
||||
/**
|
||||
* Per-instance Glance state key holding the past-event display mode (as
|
||||
* [PastEventDisplay.name]). Read reactively in the composition for the same
|
||||
* reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
|
||||
* widget without depending on the `provideGlance` preamble re-running.
|
||||
*/
|
||||
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
||||
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val data = context.loadAgendaWidgetData()
|
||||
val dark = (context.resources.configuration.uiMode and
|
||||
@@ -100,12 +132,36 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
when (data) {
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
||||
is AgendaWidgetData.Ready ->
|
||||
if (data.days.isEmpty()) {
|
||||
is AgendaWidgetData.Ready -> {
|
||||
// Range read reactively from per-instance Glance state (falls back
|
||||
// to the saved pref for a freshly placed widget), then the wide
|
||||
// pre-loaded window is sliced to it — pure recomposition.
|
||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||
val pastDisplay =
|
||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||
val rangeEnd = data.today.plus(
|
||||
range.dayCount(data.today, data.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
// Slice to the chosen range, then drop finished events (and any day
|
||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||
val visibleDays = data.days
|
||||
.filter { it.date <= rangeEnd }
|
||||
.let { days ->
|
||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
days
|
||||
}
|
||||
}
|
||||
if (visibleDays.isEmpty()) {
|
||||
WidgetMessage(R.string.agenda_empty_title)
|
||||
} else {
|
||||
val rows = buildList {
|
||||
data.days.forEach { day ->
|
||||
visibleDays.forEach { day ->
|
||||
add(AgendaRow.Header(day.date, data.today))
|
||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||
}
|
||||
@@ -114,11 +170,18 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
items(rows.size) { index ->
|
||||
when (val row = rows[index]) {
|
||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||
is AgendaRow.Event -> EventRow(row.event, dark)
|
||||
is AgendaRow.Event -> EventRow(
|
||||
event = row.event,
|
||||
dark = dark,
|
||||
is24Hour = data.is24Hour,
|
||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||
row.event.hasEnded(data.now),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,25 +248,36 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
||||
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date))),
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: EventInstance, dark: Boolean) {
|
||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||
val stripeColor = pastelize(event.color, dark).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.eventDetailIntent(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = CalendarView.Agenda,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -214,17 +288,17 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
|
||||
.width(5.dp)
|
||||
.height(36.dp)
|
||||
.cornerRadius(3.dp)
|
||||
.background(pastelize(event.color, dark)),
|
||||
.background(stripeColor),
|
||||
) {}
|
||||
Spacer(GlanceModifier.width(10.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event),
|
||||
text = eventTimeSummary(context, event, is24Hour),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||
)
|
||||
@@ -263,17 +337,17 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
private fun eventTimeSummary(context: Context, event: EventInstance): String {
|
||||
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String {
|
||||
val time = if (event.isAllDay) {
|
||||
context.getString(R.string.event_detail_all_day)
|
||||
} else {
|
||||
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||
"${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant): String {
|
||||
private fun formatTime(instant: Instant, is24Hour: Boolean): String {
|
||||
val t = instant.toLocalDateTime(zone()).time
|
||||
return "%02d:%02d".format(t.hour, t.minute)
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ 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.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||
@@ -209,7 +210,9 @@ private fun MonthHeader(label: String) {
|
||||
resId = R.drawable.ic_widget_today,
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
||||
),
|
||||
),
|
||||
)
|
||||
HeaderIcon(
|
||||
@@ -300,8 +303,16 @@ private fun WeekRow(
|
||||
|
||||
@Composable
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Box(
|
||||
modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT),
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(DAY_NUMBER_HEIGHT)
|
||||
// Tap a day number to open that day, rooted in the month view so back
|
||||
// returns to the month grid.
|
||||
.clickable(
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
@@ -353,7 +364,24 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||
@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
|
||||
.width(width)
|
||||
.height(LANE_HEIGHT)
|
||||
.padding(horizontal = 1.dp)
|
||||
// Tap an event bar to open its detail, rooted in the month view.
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = CalendarView.Month,
|
||||
),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
|
||||
12
app/src/main/res/drawable/ic_notification_dismiss.xml
Normal file
12
app/src/main/res/drawable/ic_notification_dismiss.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Monochrome notification-action mark: Material "close" 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,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
|
||||
</vector>
|
||||
12
app/src/main/res/drawable/ic_notification_snooze.xml
Normal file
12
app/src/main/res/drawable/ic_notification_snooze.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Monochrome notification-action mark: Material "snooze" 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="M7.88,3.39L6.6,1.86 2,5.71l1.29,1.53 4.59,-3.85zM22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7zM9,11h3.63L9,15.2L9,17h6v-2h-3.63L15,10.8L15,9L9,9z" />
|
||||
</vector>
|
||||
18
app/src/main/res/drawable/ic_qs_new_event.xml
Normal file
18
app/src/main/res/drawable/ic_qs_new_event.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Quick Settings tile icon ("New event"): a flat calendar+plus glyph. QS tile
|
||||
icons are tinted by the system from their alpha, so this is a bare white
|
||||
glyph on a transparent background (no brand circle, unlike the launcher
|
||||
shortcut icon). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
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,19H5V8h14V19z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M11.5,10.5h1v2h2v1h-2v2h-1v-2h-2v-1h2z" />
|
||||
</vector>
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<!-- Permission-Flow (F1) -->
|
||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App zu Beginn nicht — und nichts verlässt je dein Gerät.</string>
|
||||
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
||||
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
||||
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
||||
@@ -77,6 +77,16 @@
|
||||
<string name="event_edit_add">Hinzufügen</string>
|
||||
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
|
||||
<string name="event_edit_remove_reminder">Erinnerung entfernen</string>
|
||||
<string name="event_edit_attendees">Gäste</string>
|
||||
<string name="event_edit_add_guest">Gast hinzufügen</string>
|
||||
<string name="event_edit_add_guest_hint">Gast per E-Mail hinzufügen…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Aus Kontakten hinzufügen</string>
|
||||
<string name="event_edit_location_from_contacts">Adresse aus Kontakten wählen</string>
|
||||
<string name="event_edit_remove_guest">Gast entfernen</string>
|
||||
<string name="event_edit_attendee_required">Erforderlich</string>
|
||||
<string name="event_edit_attendee_optional">Optional</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula versendet keine Einladungen. Dein Kalenderkonto sendet Gästen bei der Synchronisierung unter Umständen eine E-Mail.</string>
|
||||
<string name="event_edit_attendees_note_local">Auf diesem Gerät gespeichert. Niemand wird benachrichtigt.</string>
|
||||
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
|
||||
<string name="reminder_unit_minutes">Minuten</string>
|
||||
<string name="reminder_unit_hours">Stunden</string>
|
||||
@@ -177,6 +187,15 @@
|
||||
<item quantity="one">%d Woche vorher</item>
|
||||
<item quantity="other">%d Wochen vorher</item>
|
||||
</plurals>
|
||||
<!-- Reine Zeitdauern (ohne „vorher"), z. B. die Schlummerdauer. -->
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="one">%d Minute</item>
|
||||
<item quantity="other">%d Minuten</item>
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d Stunde</item>
|
||||
<item quantity="other">%d Stunden</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Geteilte Event-Strings -->
|
||||
<string name="event_untitled">(Ohne Titel)</string>
|
||||
@@ -194,6 +213,8 @@
|
||||
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
|
||||
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
|
||||
<string name="reminder_onboarding_skip_button">Später</string>
|
||||
<string name="reminder_action_snooze">Schlummern</string>
|
||||
<string name="reminder_action_dismiss">Verwerfen</string>
|
||||
|
||||
<!-- View-Switcher (M1) -->
|
||||
<string name="view_month">Monat</string>
|
||||
@@ -209,8 +230,15 @@
|
||||
<string name="agenda_today_action">Heute</string>
|
||||
<string name="agenda_header_today">Heute</string>
|
||||
<string name="agenda_header_tomorrow">Morgen</string>
|
||||
<string name="agenda_empty_title">Nichts geplant</string>
|
||||
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
|
||||
<string name="agenda_empty_title">Alles erledigt</string>
|
||||
|
||||
<!-- Terminsuche -->
|
||||
<string name="search_action">Suchen</string>
|
||||
<string name="search_hint">Termine suchen</string>
|
||||
<string name="search_back">Zurück</string>
|
||||
<string name="search_clear">Löschen</string>
|
||||
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
|
||||
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
|
||||
|
||||
<!-- Startbildschirm-Widgets -->
|
||||
<string name="widget_agenda_title">Anstehend</string>
|
||||
@@ -227,6 +255,11 @@
|
||||
<string name="shortcut_new_event_short">Neuer Termin</string>
|
||||
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
|
||||
|
||||
<!-- Schnelleinstellungen-Kachel -->
|
||||
<string name="qs_tile_new_event_label">Neuer Termin</string>
|
||||
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
|
||||
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
|
||||
|
||||
<!-- Kalender-Filter (M3) -->
|
||||
<string name="filter_title">Kalender</string>
|
||||
|
||||
@@ -242,10 +275,35 @@
|
||||
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
|
||||
<string name="settings_week_start">Wochenstart</string>
|
||||
<string name="settings_week_start_auto">Automatisch</string>
|
||||
<string name="settings_week_start_monday">Montag</string>
|
||||
<string name="settings_week_start_sunday">Sonntag</string>
|
||||
<string name="settings_time_format">Zeitformat</string>
|
||||
<string name="settings_time_format_auto">Automatisch</string>
|
||||
<string name="settings_time_format_12h">12-Stunden (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24-Stunden (14:00)</string>
|
||||
<string name="settings_hour_lines">Stundenlinien</string>
|
||||
<string name="settings_hour_lines_summary">Trennlinie zu jeder vollen Stunde in Wochen- und Tagesansicht anzeigen</string>
|
||||
<string name="settings_agenda_range">Agenda-Zeitraum</string>
|
||||
<string name="settings_agenda_range_hint">Wie weit im Voraus die Agenda-Ansicht Termine anzeigt.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda-Widget-Zeitraum</string>
|
||||
<string name="settings_agenda_widget_range_hint">Wie weit im Voraus das Agenda-Widget auf dem Startbildschirm Termine anzeigt.</string>
|
||||
<string name="settings_agenda_range_bar">Zeitraum-Leiste</string>
|
||||
<string name="settings_agenda_range_bar_hint">Eine Leiste oben in der Agenda anzeigen, die den dargestellten Zeitraum benennt, mit einer Schaltfläche zum Wechseln des Zeitraums für die Sitzung</string>
|
||||
<string name="agenda_range_day">Heute</string>
|
||||
<string name="agenda_range_this_week">Diese Woche</string>
|
||||
<string name="agenda_range_this_month">Dieser Monat</string>
|
||||
<string name="agenda_range_week">Nächste 7 Tage</string>
|
||||
<string name="agenda_range_month">Nächste 30 Tage</string>
|
||||
<string name="agenda_range_custom">Benutzerdefiniert…</string>
|
||||
<string name="agenda_range_custom_hint">Tage</string>
|
||||
<string name="agenda_range_override_hint">Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt.</string>
|
||||
<string name="agenda_range_showing_label">Alle anstehenden Termine für</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d Tag</item>
|
||||
<item quantity="other">%d Tage</item>
|
||||
</plurals>
|
||||
<string name="settings_section_event_form">Termin-Formular</string>
|
||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||
<string name="settings_autofocus_title">Titel bei neuem Termin fokussieren</string>
|
||||
<string name="settings_autofocus_title_hint">Beim Anlegen eines neuen Termins den Cursor direkt ins Titelfeld setzen und die Tastatur öffnen.</string>
|
||||
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
||||
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
|
||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||
@@ -258,13 +316,13 @@
|
||||
<string name="reminder_none">Keine</string>
|
||||
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
|
||||
<string name="reminder_custom_amount">Anzahl</string>
|
||||
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
|
||||
<string name="reminder_custom_set">Übernehmen</string>
|
||||
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
|
||||
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
|
||||
<string name="settings_snooze_duration">Schlummerdauer</string>
|
||||
<string name="settings_section_calendars">Kalender</string>
|
||||
<string name="settings_manage_calendars">Kalender verwalten</string>
|
||||
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
||||
@@ -280,8 +338,11 @@
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">von Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Quellcode</string>
|
||||
<string name="settings_about_support">Entwicklung unterstützen</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
||||
<string name="settings_report_problem">Problem melden</string>
|
||||
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
|
||||
|
||||
<!-- Calendar manager -->
|
||||
<string name="calendars_title">Kalender</string>
|
||||
@@ -337,4 +398,50 @@
|
||||
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
|
||||
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
|
||||
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
|
||||
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
|
||||
<string name="crash_dialog_report">Melden</string>
|
||||
<string name="crash_dialog_dismiss">Nicht jetzt</string>
|
||||
<string name="crash_report_issue_title">Absturzbericht</string>
|
||||
<string name="crash_report_clip_label">Calendula-Absturzbericht</string>
|
||||
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
|
||||
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
||||
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||
|
||||
<!-- Contact special dates (issue #15) -->
|
||||
<string name="special_dates_calendar_birthday">Geburtstage</string>
|
||||
<string name="special_dates_calendar_anniversary">Jahrestage</string>
|
||||
<string name="special_dates_calendar_custom">Besondere Tage</string>
|
||||
<string name="special_dates_default_title_birthday">{name}s Geburtstag ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">{name}s Jahrestag ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
<string name="event_edit_managed_hint">Verwaltet von „%1$s“ — Titel, Datum und Wiederholung werden aus deinen Kontakten synchron gehalten. Erinnerungen, Ort und Notizen kannst du selbst bearbeiten.</string>
|
||||
<string name="settings_special_dates_subtitle">Geburtstage & Jahrestage aus Kontakten</string>
|
||||
<string name="settings_section_special_dates">Besondere Kontakttage</string>
|
||||
<string name="settings_special_dates_enable">Kontakttage anzeigen</string>
|
||||
<string name="settings_special_dates_enable_hint">Spiegelt Geburtstage und weitere Daten deiner Kontakte in lokale Kalender. Liest Kontakte nur auf diesem Gerät — nichts wird hochgeladen, und deine Kontakte werden nie verändert.</string>
|
||||
<string name="settings_special_dates_type_birthday">Geburtstage</string>
|
||||
<string name="settings_special_dates_type_anniversary">Jahrestage</string>
|
||||
<string name="settings_special_dates_type_custom">Weitere Tage</string>
|
||||
<string name="settings_special_dates_template">Titelformat</string>
|
||||
<string name="settings_special_dates_template_hint">Verwende {name} für den Kontakt und {year} für das Jahr (Geburtsjahr bzw. Startjahr eines Jahrestags; ausgeblendet, wenn unbekannt).</string>
|
||||
<string name="settings_special_dates_reminders">Erinnerungen</string>
|
||||
<string name="settings_special_dates_show_year">Jahr anzeigen</string>
|
||||
<string name="settings_special_dates_show_year_hint">{year} in Titeln anzeigen, wenn es bekannt ist</string>
|
||||
<string name="settings_special_dates_sync_now">Jetzt synchronisieren</string>
|
||||
<string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string>
|
||||
<string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Farbe und Sichtbarkeit jedes Kalenders legst du in den Kalender-Einstellungen fest.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">In Besondere Kontakttage festgelegt</string>
|
||||
<string name="settings_special_dates_paused_title">Pausiert</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.</string>
|
||||
<string name="settings_special_dates_grant">Zugriff erlauben</string>
|
||||
<string name="settings_special_dates_disable_title">Kontakttage deaktivieren?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Dadurch werden die Kontakttage-Kalender und ihre Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
|
||||
<string name="dialog_save">Speichern</string>
|
||||
</resources>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<!-- Permission flow (F1) -->
|
||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
|
||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s all it asks for up front — and nothing ever leaves your device.</string>
|
||||
<string name="permission_request_button">Grant calendar access</string>
|
||||
<string name="permission_denied_title">Calendar access denied</string>
|
||||
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
||||
@@ -68,6 +68,8 @@
|
||||
<string name="event_edit_close">Close</string>
|
||||
<string name="event_edit_save">Save</string>
|
||||
<string name="event_edit_title_hint">Add title</string>
|
||||
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
|
||||
<string name="event_edit_managed_hint">Managed by “%1$s” — the title, date and repeat are kept in sync from your contacts. Reminders, location and notes are yours to edit.</string>
|
||||
<string name="event_edit_starts">Starts</string>
|
||||
<string name="event_edit_ends">Ends</string>
|
||||
<string name="event_edit_error_end_before_start">Ends before it starts</string>
|
||||
@@ -78,6 +80,16 @@
|
||||
<string name="event_edit_add">Add</string>
|
||||
<string name="event_edit_add_reminder">Add reminder</string>
|
||||
<string name="event_edit_remove_reminder">Remove reminder</string>
|
||||
<string name="event_edit_attendees">Guests</string>
|
||||
<string name="event_edit_add_guest">Add guest</string>
|
||||
<string name="event_edit_add_guest_hint">Add a guest by email…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Add from contacts</string>
|
||||
<string name="event_edit_location_from_contacts">Pick address from contacts</string>
|
||||
<string name="event_edit_remove_guest">Remove guest</string>
|
||||
<string name="event_edit_attendee_required">Required</string>
|
||||
<string name="event_edit_attendee_optional">Optional</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula doesn\'t send invitations. Your calendar account may email guests when it syncs.</string>
|
||||
<string name="event_edit_attendees_note_local">Stored on this device. No one is notified.</string>
|
||||
<string name="event_edit_reminder_custom">Custom</string>
|
||||
<string name="reminder_unit_minutes">minutes</string>
|
||||
<string name="reminder_unit_hours">hours</string>
|
||||
@@ -178,6 +190,23 @@
|
||||
<item quantity="one">%d week before</item>
|
||||
<item quantity="other">%d weeks before</item>
|
||||
</plurals>
|
||||
<!-- Plain durations (no "before"), e.g. the snooze delay. -->
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="one">%d minute</item>
|
||||
<item quantity="other">%d minutes</item>
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d hour</item>
|
||||
<item quantity="other">%d hours</item>
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d week</item>
|
||||
<item quantity="other">%d weeks</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Shared event strings -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
@@ -195,6 +224,8 @@
|
||||
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
||||
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||
<string name="reminder_action_snooze">Snooze</string>
|
||||
<string name="reminder_action_dismiss">Dismiss</string>
|
||||
|
||||
<!-- View switcher (M1) -->
|
||||
<string name="view_month">Month</string>
|
||||
@@ -210,8 +241,15 @@
|
||||
<string name="agenda_today_action">Today</string>
|
||||
<string name="agenda_header_today">Today</string>
|
||||
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||
<string name="agenda_empty_title">Nothing scheduled</string>
|
||||
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
|
||||
<string name="agenda_empty_title">You\'re all caught up</string>
|
||||
|
||||
<!-- Event search -->
|
||||
<string name="search_action">Search</string>
|
||||
<string name="search_hint">Search events</string>
|
||||
<string name="search_back">Back</string>
|
||||
<string name="search_clear">Clear</string>
|
||||
<string name="search_idle_hint">Search your events by title, location or notes.</string>
|
||||
<string name="search_empty">No events match “%1$s”.</string>
|
||||
|
||||
<!-- Home-screen widgets -->
|
||||
<string name="widget_agenda_title">Upcoming</string>
|
||||
@@ -235,14 +273,47 @@
|
||||
<string name="settings_theme_system">System</string>
|
||||
<string name="settings_theme_light">Light</string>
|
||||
<string name="settings_theme_dark">Dark</string>
|
||||
<string name="settings_default_view">Default view</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||
<string name="settings_week_start">Week starts on</string>
|
||||
<string name="settings_week_start_auto">Automatic</string>
|
||||
<string name="settings_week_start_monday">Monday</string>
|
||||
<string name="settings_week_start_sunday">Sunday</string>
|
||||
<string name="settings_time_format">Time format</string>
|
||||
<string name="settings_time_format_auto">Automatic</string>
|
||||
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
||||
<string name="settings_hour_lines">Hour lines</string>
|
||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||
<string name="settings_dim_completed">Dim completed events</string>
|
||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||
<string name="settings_past_events">Past events</string>
|
||||
<string name="settings_past_events_show">Show</string>
|
||||
<string name="settings_past_events_dim">Dim</string>
|
||||
<string name="settings_past_events_hide">Hide</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Agenda range</string>
|
||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
|
||||
<string name="settings_agenda_range_bar">Range bar</string>
|
||||
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
|
||||
<string name="agenda_range_day">Today</string>
|
||||
<string name="agenda_range_this_week">This week</string>
|
||||
<string name="agenda_range_this_month">This month</string>
|
||||
<string name="agenda_range_week">Next 7 days</string>
|
||||
<string name="agenda_range_month">Next 30 days</string>
|
||||
<string name="agenda_range_custom">Custom…</string>
|
||||
<string name="agenda_range_custom_hint">Days</string>
|
||||
<string name="agenda_range_override_hint">Just for now — resets to your saved range when you reopen Calendula.</string>
|
||||
<string name="agenda_range_showing_label">Showing all upcoming events for</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
</plurals>
|
||||
<string name="settings_section_event_form">New event form</string>
|
||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||
<string name="settings_autofocus_title">Focus title on new event</string>
|
||||
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
|
||||
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
||||
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
||||
<string name="settings_section_notifications">Notifications</string>
|
||||
@@ -255,39 +326,79 @@
|
||||
<string name="reminder_none">None</string>
|
||||
<string name="reminder_use_default">Use default reminder</string>
|
||||
<string name="reminder_custom_amount">Amount</string>
|
||||
<string name="reminder_custom_with_value">Custom (%1$s)</string>
|
||||
<string name="reminder_custom_set">Set</string>
|
||||
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
|
||||
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
|
||||
<string name="settings_snooze_duration">Snooze duration</string>
|
||||
<string name="settings_section_calendars">Calendars</string>
|
||||
<string name="settings_manage_calendars">Manage calendars</string>
|
||||
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
||||
<string name="settings_section_language">Language</string>
|
||||
<string name="settings_language">App language</string>
|
||||
<string name="settings_language_auto">System default</string>
|
||||
<string name="settings_translate">Help translate</string>
|
||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
|
||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||
|
||||
<!-- Contact special dates (issue #15) -->
|
||||
<string name="settings_section_special_dates">Contact special dates</string>
|
||||
<string name="settings_special_dates_enable">Show contact dates</string>
|
||||
<string name="settings_special_dates_enable_hint">Mirror your contacts\' birthdays and other dates into local calendars. Reads contacts on this device only — nothing is uploaded, and your contacts are never changed.</string>
|
||||
<string name="settings_special_dates_type_birthday">Birthdays</string>
|
||||
<string name="settings_special_dates_type_anniversary">Anniversaries</string>
|
||||
<string name="settings_special_dates_type_custom">Other dates</string>
|
||||
<string name="settings_special_dates_template">Title format</string>
|
||||
<string name="settings_special_dates_template_hint">Use {name} for the contact and {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown).</string>
|
||||
<string name="settings_special_dates_reminders">Reminders</string>
|
||||
<string name="settings_special_dates_show_year">Show year</string>
|
||||
<string name="settings_special_dates_show_year_hint">Include {year} in titles when it is known</string>
|
||||
<string name="settings_special_dates_sync_now">Sync now</string>
|
||||
<string name="settings_special_dates_never_synced">Not synced yet</string>
|
||||
<!-- %1$s is a relative time, e.g. "5 minutes ago". -->
|
||||
<string name="settings_special_dates_last_synced">Last synced %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Set each calendar\'s colour and visibility in Calendars settings.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">Set in Contact special dates</string>
|
||||
<string name="settings_special_dates_paused_title">Paused</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string>
|
||||
<string name="settings_special_dates_grant">Grant access</string>
|
||||
<string name="settings_special_dates_disable_title">Turn off contact dates?</string>
|
||||
<string name="settings_special_dates_disable_all_message">This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost.</string>
|
||||
<!-- %1$s is the date type, e.g. "Birthdays". -->
|
||||
<string name="settings_special_dates_disable_type_message">This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Turn off</string>
|
||||
<string name="dialog_save">Save</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
<string name="settings_license">License</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Source</string>
|
||||
<string name="settings_about_support">Support development</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_about_logo_desc">Calendula app icon</string>
|
||||
<string name="settings_report_problem">Report a problem</string>
|
||||
<string name="settings_report_problem_hint">Send a crash report or open the issue tracker</string>
|
||||
|
||||
<!-- Calendar manager -->
|
||||
<string name="calendars_title">Calendars</string>
|
||||
<string name="calendars_local_header">Your calendars</string>
|
||||
<string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string>
|
||||
<string name="calendars_add">Add calendar</string>
|
||||
<string name="calendars_disable_hint">Turn a calendar off to remove it from the app — its events, filters and pickers. Nothing is deleted, and you can turn it back on here anytime.</string>
|
||||
<string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string>
|
||||
<string name="calendars_synced_header">Synced calendars</string>
|
||||
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
||||
<string name="calendars_manage_in_app">Manage</string>
|
||||
<string name="calendars_manage_in_app">Manage in app</string>
|
||||
<string name="calendars_account_menu_a11y">More options for %1$s</string>
|
||||
<string name="calendars_enable_all">Enable all</string>
|
||||
<string name="calendars_disable_all">Disable all</string>
|
||||
<string name="calendars_add_account">Add account</string>
|
||||
<string name="calendars_new_title">New calendar</string>
|
||||
<string name="calendars_edit_title">Edit calendar</string>
|
||||
@@ -301,6 +412,26 @@
|
||||
<string name="calendars_backup_header">Backup</string>
|
||||
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
|
||||
<string name="calendars_backup_action">Export as .ics file</string>
|
||||
<string name="calendars_auto_backup">Automatic backup</string>
|
||||
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Tap to choose a folder</string>
|
||||
<string name="calendars_auto_backup_interval">Interval</string>
|
||||
<string name="calendars_auto_backup_every">Every %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||
<string name="calendars_auto_backup_status_never">No automatic backup yet</string>
|
||||
<string name="calendars_auto_backup_status_ok">Last backup: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">Last backup failed: %1$s</string>
|
||||
<string name="backup_channel_name">Backup</string>
|
||||
<string name="backup_channel_description">Warns if automatic backups fail repeatedly.</string>
|
||||
<string name="backup_failed_title">Automatic backup failed</string>
|
||||
<string name="backup_failed_text">Calendula couldn\'t write the backup file. Check the backup folder in Settings.</string>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="calendars_backup_failed">Couldn\'t export the backup.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">Exported %d event.</item>
|
||||
@@ -338,6 +469,37 @@
|
||||
<string name="shortcut_new_event_short">New event</string>
|
||||
<string name="shortcut_new_event_long">Create a new event</string>
|
||||
|
||||
<!-- Quick Settings tile -->
|
||||
<string name="qs_tile_new_event_label">New event</string>
|
||||
<string name="settings_qs_tile">Add Quick Settings tile</string>
|
||||
<string name="settings_qs_tile_hint">Add a “New event” tile to the Quick Settings panel.</string>
|
||||
|
||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
||||
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/calendula/</string>
|
||||
|
||||
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
||||
Gitea issue (the app sends nothing automatically). -->
|
||||
<string name="crash_dialog_title">Calendula crashed</string>
|
||||
<string name="crash_dialog_message">Calendula closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
|
||||
<string name="crash_dialog_report">Report</string>
|
||||
<string name="crash_dialog_dismiss">Not now</string>
|
||||
<string name="crash_report_issue_title">Crash report</string>
|
||||
<string name="crash_report_clip_label">Calendula crash report</string>
|
||||
<string name="crash_report_copied">Report copied to your clipboard</string>
|
||||
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
||||
<string name="crash_report_body_template">Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n</string>
|
||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
||||
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||
|
||||
<!-- Contact special dates (issue #15). {name} is the contact, {age} the age
|
||||
at the upcoming date (dropped when the year is unknown or Show age is off). -->
|
||||
<string name="special_dates_calendar_birthday">Birthdays</string>
|
||||
<string name="special_dates_calendar_anniversary">Anniversaries</string>
|
||||
<string name="special_dates_calendar_custom">Special dates</string>
|
||||
<string name="special_dates_default_title_birthday">{name}\'s birthday ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">{name}\'s anniversary ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
</resources>
|
||||
|
||||
@@ -170,6 +170,74 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances drops events whose calendar the user disabled`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
prefs.setDisabledCalendarIds(setOf(2L))
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
instancesResult = { _, _ ->
|
||||
listOf(
|
||||
makeEvent(10L, "Enabled", calendarId = 1L),
|
||||
makeEvent(11L, "Disabled", calendarId = 2L),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("Enabled")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances applies the union of hidden and disabled sets`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
prefs.setHiddenCalendarIds(setOf(2L))
|
||||
prefs.setDisabledCalendarIds(setOf(3L))
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
instancesResult = { _, _ ->
|
||||
listOf(
|
||||
makeEvent(10L, "Shown", calendarId = 1L),
|
||||
makeEvent(11L, "Hidden", calendarId = 2L),
|
||||
makeEvent(12L, "Disabled", calendarId = 3L),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("Shown")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances re-emits when the disabled set changes`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
instancesResult = { _, _ ->
|
||||
listOf(
|
||||
makeEvent(10L, "A", calendarId = 1L),
|
||||
makeEvent(11L, "B", calendarId = 2L),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
|
||||
|
||||
prefs.setDisabledCalendarIds(setOf(2L))
|
||||
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("A")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
||||
|
||||
@@ -199,11 +199,12 @@ class EventWriteMapperTest {
|
||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
||||
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.DTEND])
|
||||
.isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||
// A single occurrence never carries its own rule.
|
||||
// The occurrence's length travels as DURATION, never DTEND — the provider
|
||||
// rejects DTEND on an exception ("Exceptions can't overwrite dtend") and
|
||||
// derives the end from DTSTART + DURATION, clearing the inherited RRULE.
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user