Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| d20d446cbe | |||
| 6e14d5964b | |||
| 3dfc96718c | |||
| e1c2e9f2e5 | |||
| 90b219bdad | |||
| 233a9b03a3 | |||
| 0b683d374f | |||
| 64d0a89b28 | |||
| 7285e274df | |||
| 788ca3906e | |||
| bab6fd175a | |||
| 3d5cc55ef1 | |||
| 111b3782b0 | |||
| cf380b6eab | |||
| 9177a926df |
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
|
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:
|
on:
|
||||||
push:
|
pull_request:
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
tags-ignore:
|
|
||||||
- '**'
|
|
||||||
|
|
||||||
# Cancel superseded runs on the same branch.
|
# Cancel superseded runs for the same PR.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
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:
|
ci:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
env:
|
env:
|
||||||
@@ -21,14 +26,53 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Setup Java
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
# Default ("tools platform-tools") drags in the Android Emulator
|
# Default ("tools platform-tools") drags in the Android Emulator
|
||||||
@@ -36,12 +80,14 @@ jobs:
|
|||||||
packages: ''
|
packages: ''
|
||||||
|
|
||||||
- name: Setup Android SDK cache
|
- name: Setup Android SDK cache
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: /opt/android-sdk
|
path: /opt/android-sdk
|
||||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
- name: Install Android SDK packages
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
sdkmanager \
|
sdkmanager \
|
||||||
@@ -50,6 +96,7 @@ jobs:
|
|||||||
"build-tools;36.0.0"
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
- name: Setup Gradle cache
|
- name: Setup Gradle cache
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -60,21 +107,25 @@ jobs:
|
|||||||
${{ runner.os }}-gradle-
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
# No --no-daemon: the daemon lives only as long as this job container
|
# No --no-daemon: the daemon lives only as long as this job container
|
||||||
# and lets the following steps skip JVM startup + reconfiguration.
|
# and lets the following steps skip JVM startup + reconfiguration.
|
||||||
- name: Lint (debug variant only)
|
- name: Lint (debug variant only)
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew lintDebug
|
run: ./gradlew lintDebug
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew testDebugUnitTest
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Assemble debug APK
|
- name: Assemble debug APK
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew assembleDebug
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
- name: Trivy filesystem scan
|
- name: Trivy filesystem scan
|
||||||
if: github.ref == 'refs/heads/main'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
|||||||
@@ -1,75 +1,84 @@
|
|||||||
name: Release — F-Droid repo + Gitea release
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
branches: [main]
|
||||||
- '*'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
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
|
runs-on: docker
|
||||||
env:
|
outputs:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
is_release: ${{ steps.v.outputs.is_release }}
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
version: ${{ steps.v.outputs.version }}
|
||||||
|
version_code: ${{ steps.v.outputs.version_code }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Resolve version and whether it is a new release
|
||||||
uses: actions/setup-java@v4
|
id: v
|
||||||
with:
|
env:
|
||||||
distribution: 'zulu'
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
java-version: '17'
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
|
||||||
- name: Setup Android SDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
with:
|
|
||||||
packages: ''
|
|
||||||
|
|
||||||
- name: Setup Android SDK cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: /opt/android-sdk
|
|
||||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
set -e
|
||||||
sdkmanager \
|
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||||
"platform-tools" \
|
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
||||||
"platforms;android-37.0" \
|
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||||
"build-tools;36.0.0"
|
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
|
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||||
uses: actions/cache@v4
|
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||||
with:
|
# re-uploads the existing index (recovery path).
|
||||||
path: |
|
release:
|
||||||
~/.gradle/caches
|
needs: detect
|
||||||
~/.gradle/wrapper
|
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
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
|
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
ANDROID_HOME: /opt/android-sdk
|
||||||
ANDROID_SDK_ROOT: /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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -121,31 +130,26 @@ jobs:
|
|||||||
$SUDO apk add --no-cache jq
|
$SUDO apk add --no-cache jq
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Tag-only build steps. On a manual workflow_dispatch (ref = a branch,
|
- name: Grant execute permission for gradlew
|
||||||
# not a tag) these are skipped: the job then just re-signs the existing
|
run: chmod +x ./gradlew
|
||||||
# index with the configured repo key and re-uploads — used for key
|
|
||||||
# rotation / repo recovery without publishing a new APK.
|
# The committed versionName is the source of truth. Pin versionCode to the
|
||||||
- name: Set version from git tag
|
# value derived from it so the published APK's code is always
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
||||||
|
- name: Pin versionCode to versionName
|
||||||
|
if: env.IS_RELEASE == 'true'
|
||||||
run: |
|
run: |
|
||||||
set -e
|
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
|
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
||||||
grep -E 'versionName|versionCode' app/build.gradle.kts
|
grep -E 'versionName|versionCode' app/build.gradle.kts
|
||||||
# Export for later steps (F-Droid changelog, mapping asset name).
|
|
||||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
# Test the exact commit being shipped (only on a real release).
|
||||||
echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV"
|
- name: Unit tests
|
||||||
|
if: env.IS_RELEASE == 'true'
|
||||||
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Setup Android Keystore
|
- name: Setup Android Keystore
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: env.IS_RELEASE == 'true'
|
||||||
env:
|
env:
|
||||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
@@ -160,11 +164,8 @@ jobs:
|
|||||||
storeFile=upload-keystore.jks
|
storeFile=upload-keystore.jks
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
|
||||||
run: chmod +x ./gradlew
|
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: env.IS_RELEASE == 'true'
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Setup F-Droid Server Tools
|
- name: Setup F-Droid Server Tools
|
||||||
@@ -202,8 +203,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Fail loudly if the repo key is not configured. NEVER auto-generate
|
# Fail loudly if the repo key is not configured. NEVER auto-generate
|
||||||
# one: a fresh key changes the repo fingerprint and breaks every
|
# one: a fresh key changes the repo fingerprint and breaks every
|
||||||
# user's pinned repo. (Replaces the old `fdroid update --create-key`
|
# user's pinned repo.
|
||||||
# path, which silently rotated the key on a wiped server.)
|
|
||||||
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
|
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
|
||||||
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
|
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
|
echo "Refusing to continue — will not auto-generate a new repo key." >&2
|
||||||
@@ -216,42 +216,33 @@ jobs:
|
|||||||
mkdir -p fdroid/repo/icons
|
mkdir -p fdroid/repo/icons
|
||||||
|
|
||||||
- name: Copy new APK to repo
|
- name: Copy new APK to repo
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: env.IS_RELEASE == 'true'
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
mkdir -p fdroid/repo
|
mkdir -p fdroid/repo
|
||||||
REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
||||||
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"
|
|
||||||
|
|
||||||
- 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: |
|
run: |
|
||||||
mkdir -p fdroid/metadata
|
mkdir -p fdroid/metadata
|
||||||
cp -r fdroid-metadata/* fdroid/metadata/
|
# App-level control file (Categories/License/links) for the self-hosted
|
||||||
|
# repo's `fdroid update`.
|
||||||
# Per-version "What's New" for F-Droid clients: the tag's CHANGELOG
|
cp fdroid-metadata/de.jeanlucmakiola.calendula.yml fdroid/metadata/
|
||||||
# section written to changelogs/<versionCode>.txt (same extraction as the
|
# Localized text + graphics + per-version changelogs come from the SAME
|
||||||
# Gitea release notes). en-US only — F-Droid falls back to it for locales
|
# fastlane tree the official F-Droid repo harvests from source,
|
||||||
# without their own changelog. fdroid update bakes this into the index.
|
# transformed into the F-Droid repo "localized" layout. One source of
|
||||||
- name: Generate F-Droid changelog for this version
|
# truth, both channels.
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
bash scripts/fastlane_to_fdroid_localized.sh \
|
||||||
run: |
|
fastlane/metadata/android \
|
||||||
set -e
|
fdroid/metadata/de.jeanlucmakiola.calendula
|
||||||
awk -v ver="$VERSION" '
|
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
|
||||||
/^## \[/ { flag = 0 }
|
|
||||||
flag' CHANGELOG.md > /tmp/changelog.txt
|
|
||||||
sed -i -e '/./,$!d' /tmp/changelog.txt
|
|
||||||
if [ ! -s /tmp/changelog.txt ]; then
|
|
||||||
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
|
|
||||||
fi
|
|
||||||
CL_DIR="fdroid/metadata/de.jeanlucmakiola.calendula/en-US/changelogs"
|
|
||||||
mkdir -p "$CL_DIR"
|
|
||||||
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
|
|
||||||
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
|
|
||||||
|
|
||||||
- name: Generate F-Droid Index
|
- name: Generate F-Droid Index
|
||||||
run: |
|
run: |
|
||||||
@@ -272,97 +263,46 @@ jobs:
|
|||||||
SFTP
|
SFTP
|
||||||
# Publish the signed repo/ plus metadata/ (descriptions, screenshots,
|
# Publish the signed repo/ plus metadata/ (descriptions, screenshots,
|
||||||
# per-version changelogs) so changelog history survives across
|
# per-version changelogs) so changelog history survives across
|
||||||
# releases. keystore.p12 and config.yml are NEVER uploaded, so they
|
# releases. keystore.p12 and config.yml are NEVER uploaded.
|
||||||
# can't re-enter the web-served tree; nginx serves only repo/ anyway.
|
|
||||||
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/"
|
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.
|
# The APK is published and the index re-signed — now record the release.
|
||||||
# Attached to the Gitea release (it's not an APK, so it fits the
|
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
||||||
# no-binaries rule). Best-effort: never fail a release over it.
|
# this commit, so the tag only ever marks a fully-shipped release (and a
|
||||||
- name: Attach R8 mapping to Gitea release
|
# failure before here leaves no tag, so re-running the workflow retries).
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
- name: Create tag + Gitea release
|
||||||
continue-on-error: true
|
if: env.IS_RELEASE == 'true'
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
MAP="app/build/outputs/mapping/release/mapping.txt"
|
TAG="v$VERSION"
|
||||||
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
|
# Notes = this version's CHANGELOG section.
|
||||||
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
|
||||||
ASSET="mapping-${VERSION:-$TAG}.txt.gz"
|
|
||||||
gzip -c "$MAP" > "/tmp/$ASSET"
|
|
||||||
# The release is created by the gitea-release job; ensure it exists
|
|
||||||
# (idempotent) so this job doesn't race it to a 404.
|
|
||||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
|
||||||
if [ -z "$ID" ]; then
|
|
||||||
ID=$(curl -s -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \
|
|
||||||
"$API/releases" | jq -r '.id // empty')
|
|
||||||
fi
|
|
||||||
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
|
|
||||||
# Replace any prior asset of the same name (re-run safe).
|
|
||||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
|
||||||
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
|
|
||||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
|
||||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@/tmp/$ASSET" \
|
|
||||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
|
||||||
|
|
||||||
# A Gitea release per tag, carrying the tag's CHANGELOG section as its
|
|
||||||
# notes. Deliberately no APK assets — distribution stays with the F-Droid
|
|
||||||
# repo; the release is the human-readable record. Gated on the tests-only
|
|
||||||
# ci job (not the deploy) so notes appear even if the F-Droid upload has
|
|
||||||
# an infrastructure hiccup.
|
|
||||||
gitea-release:
|
|
||||||
needs: ci
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Extract changelog section for this tag
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
|
||||||
VERSION="${TAG#v}"
|
|
||||||
# Everything between "## [<version>]" and the next "## [" heading.
|
|
||||||
awk -v ver="$VERSION" '
|
awk -v ver="$VERSION" '
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||||
/^## \[/ { flag = 0 }
|
/^## \[/ { flag = 0 }
|
||||||
flag' CHANGELOG.md > release-notes.md
|
flag' CHANGELOG.md > release-notes.md
|
||||||
# Trim leading blank lines.
|
|
||||||
sed -i -e '/./,$!d' release-notes.md
|
sed -i -e '/./,$!d' release-notes.md
|
||||||
if [ ! -s release-notes.md ]; then
|
if [ ! -s release-notes.md ]; then
|
||||||
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
|
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
|
||||||
fi
|
fi
|
||||||
echo "--- release notes ---"
|
python3 - "$TAG" "$SHA" <<'PY' > payload.json
|
||||||
cat release-notes.md
|
|
||||||
|
|
||||||
- name: Create Gitea release
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
|
||||||
python3 - "$TAG" <<'PY' > payload.json
|
|
||||||
import json, sys
|
import json, sys
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"tag_name": sys.argv[1],
|
"tag_name": sys.argv[1],
|
||||||
|
"target_commitish": sys.argv[2],
|
||||||
"name": sys.argv[1],
|
"name": sys.argv[1],
|
||||||
"body": open("release-notes.md").read(),
|
"body": open("release-notes.md").read(),
|
||||||
"draft": False,
|
"draft": False,
|
||||||
"prerelease": False,
|
"prerelease": False,
|
||||||
}))
|
}))
|
||||||
PY
|
PY
|
||||||
# Upsert: the build-and-deploy job may have created a bare release
|
# Upsert (re-run safe): PATCH if a release for the tag already exists,
|
||||||
# first (to attach the mapping asset), so PATCH the notes if it
|
# else POST a new one (which also creates the tag at target_commitish).
|
||||||
# exists, otherwise POST a new one. Both paths are re-run safe.
|
|
||||||
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
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
|
if [ -n "$ID" ]; then
|
||||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
@@ -376,6 +316,33 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
cat response.json
|
cat response.json
|
||||||
if [ "$CODE" != "$OK" ]; then
|
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
|
exit 1
|
||||||
fi
|
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
|
||||||
37
.gitea/workflows/translations.yaml
Normal file
37
.gitea/workflows/translations.yaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: Translations
|
||||||
|
|
||||||
|
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||||
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
|
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'app/src/main/res/values*/strings.xml'
|
||||||
|
- 'app/src/main/res/xml/locales_config.xml'
|
||||||
|
- 'scripts/check_translations.py'
|
||||||
|
- '.gitea/workflows/translations.yaml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: translations-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure python3
|
||||||
|
run: |
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update && apt-get install -y python3
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
apk add --no-cache python3
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
python3 --version
|
||||||
|
|
||||||
|
- name: Check translation parity
|
||||||
|
run: python3 scripts/check_translations.py
|
||||||
@@ -230,27 +230,59 @@ pass on the existing controls; new toggles ride in with their own features.
|
|||||||
|
|
||||||
**Tier 3 — platform reach (depends on Tier 2)**
|
**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)*
|
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 — interop & bigger-ticket**
|
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||||
9. Share event as .ics + receive/open .ics into a prefilled create form
|
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
||||||
10. Default reminder applied to new events; then snooze/dismiss notification actions
|
default reminder **+ per-calendar override**, bundled with battery-exemption
|
||||||
11. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
|
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
|
||||||
|
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 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)**
|
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||||
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
- 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)
|
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
||||||
|
|
||||||
**Bottom — deprioritised, not important**
|
**Bottom — deprioritised, not important**
|
||||||
- Duplicate event (detail action → prefilled create form) — moved here
|
- Duplicate event (detail action → prefilled create form) — moved here
|
||||||
2026-06-17; cheap but low value, pick up only if asked
|
2026-06-17; cheap but low value, pick up only if asked
|
||||||
|
|
||||||
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
|
||||||
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
Pulled in opportunistically, not sequenced.
|
||||||
|
|
||||||
Debatable calls worth a second look: widget (#7) vs .ics interop (#9) ordering;
|
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
|
||||||
whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
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
|
## Navigation & views
|
||||||
|
|
||||||
@@ -260,21 +292,22 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
|||||||
- Agenda view (fourth view: upcoming events grouped by day; also the
|
- Agenda view (fourth view: upcoming events grouped by day; also the
|
||||||
natural data source for a future widget)
|
natural data source for a future widget)
|
||||||
- Jump to date — drawer date picker (un-cut from V1)
|
- Jump to date — drawer date picker (un-cut from V1)
|
||||||
|
- ~~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
|
- Pinch-to-zoom time scale in day/week
|
||||||
- Tablet / foldable layouts *(was v3.0)*
|
- Tablet / foldable layouts *(was v3.0)*
|
||||||
- Full-text search *(was v3.0)*
|
- ~~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
|
## 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)
|
- Duplicate event (detail action → prefilled create form)
|
||||||
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
|
- ~~**Per-event color**~~ *(shipped v2.4.0)* — `EVENT_COLOR` / `EVENT_COLOR_KEY`
|
||||||
*(next)* — chosen to follow the in-progress tap-to-create + calendar
|
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
|
||||||
management work: reuses the color-picker component and palette plumbing
|
picker in the form, falling back to the calendar color when unset. Reused the
|
||||||
being built for local calendar management, and finishes the create/edit
|
color-picker component and palette plumbing from local calendar management and
|
||||||
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list
|
finished the create/edit theme.
|
||||||
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
|
|
||||||
|
|
||||||
## Calendars & accounts
|
## Calendars & accounts
|
||||||
|
|
||||||
@@ -297,32 +330,192 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
|||||||
go/no-go gate as the OSM/INTERNET item below.
|
go/no-go gate as the OSM/INTERNET item below.
|
||||||
- Move event to another calendar (copy+delete model with a consequences
|
- 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)*
|
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
||||||
|
- ~~**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, round two
|
### Disable a calendar in-app *(captured 2026-06-25)*
|
||||||
|
|
||||||
- Snooze + dismiss actions on the notification (snooze needs an
|
A second, heavier visibility level **above** the existing per-view filter. Today
|
||||||
exact-alarm / WorkManager decision)
|
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
|
||||||
- Settings default reminder applied to new events
|
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.
|
||||||
|
|
||||||
|
**Built in this slice (A + the safe half of B):** global timed default reminder
|
||||||
|
+ a **separate all-day default** (day-scale lead times) + per-calendar override
|
||||||
|
(timed events), applied on create with manual-edit / calendar-switch / all-day-
|
||||||
|
toggle handling; three pickers + per-calendar override list in Settings →
|
||||||
|
Notifications; battery-optimisation exemption row (status + system deep-link, no
|
||||||
|
extra permission). `resolveDefaultReminder` + prefs round-trips unit-tested.
|
||||||
|
Resolution model: all-day events use the all-day global default outright;
|
||||||
|
per-calendar overrides govern timed events only. Reviewed (8-angle), fixes
|
||||||
|
applied: form-reset state race, label-fn consolidation with the detail screen,
|
||||||
|
inline wrapper + single combined flow read.
|
||||||
|
|
||||||
|
**Deliberately deferred (documented decisions, not oversights):**
|
||||||
|
- *Absolute time-of-day for all-day reminders* — the all-day default is still
|
||||||
|
minutes-before-midnight (day-scale presets), not "9am the day before" (open
|
||||||
|
decision #2's richer half). Per-calendar all-day overrides also deferred.
|
||||||
|
- *Self-scheduled alarms* — kept the existing provider-broadcast architecture
|
||||||
|
(open decision #1). The battery exemption is the reliability lever; no
|
||||||
|
`AlarmManager`/`USE_EXACT_ALARM` subsystem was added.
|
||||||
|
- *Test-reminder diagnostic* and *battery prompt inside onboarding* — the
|
||||||
|
exemption lives only in Settings for now (onboarding flow untouched to keep
|
||||||
|
the change reviewable).
|
||||||
|
|
||||||
|
### A. Default reminders (global + per-calendar override)
|
||||||
|
|
||||||
|
**No provider backing.** `CalendarContract` has no column that auto-applies a
|
||||||
|
default reminder per calendar — Google's per-calendar defaults live server-side.
|
||||||
|
So both the global default *and* the per-calendar override are **app-side
|
||||||
|
preferences**, applied by us at event-insert time. We inherit nothing from the
|
||||||
|
synced calendar.
|
||||||
|
|
||||||
|
- **Storage (DataStore):**
|
||||||
|
- `defaultReminderMinutes: Int?` — global default; `null` = "no reminder".
|
||||||
|
- `defaultAllDayReminderMinutes: Int?` — separate all-day default (all-day
|
||||||
|
reminders are expressed as minutes before midnight / day-before-at-time, not
|
||||||
|
minutes before a start instant — they need their own value).
|
||||||
|
- `perCalendarReminderOverride: Map<Long, Int?>` — keyed by calendar id;
|
||||||
|
**absent key = inherit global**, explicit `null` = "no reminder for this
|
||||||
|
calendar". (Same for an all-day override map if we want per-calendar all-day.)
|
||||||
|
- **Apply on create:** a fresh event prefills its reminders list from
|
||||||
|
override-or-global for the preselected calendar. Changing the calendar in the
|
||||||
|
form re-applies the *new* calendar's default **only if the user hasn't manually
|
||||||
|
edited the reminders** — track a dirty flag, mirroring the per-event-color
|
||||||
|
reset pattern (v2.4).
|
||||||
|
- **Edit semantics:** defaults apply to **new events only**; never rewrite
|
||||||
|
reminders on existing events on open or on calendar-switch-during-edit.
|
||||||
|
- **Settings UI (Notifications sub-page):**
|
||||||
|
- Global default via OptionCard (None / at time of event / 5 / 10 / 15 / 30 min
|
||||||
|
/ 1 h / 1 day / custom), plus the separate all-day default.
|
||||||
|
- Per-calendar overrides: a row per writable calendar (in the Calendars screen
|
||||||
|
or a Notifications subsection), each opening the same OptionCard with a
|
||||||
|
leading **"Use global default"** option.
|
||||||
|
|
||||||
|
### B. Delivery reliability (exact alarms + battery)
|
||||||
|
|
||||||
|
The provider broadcasts `EVENT_REMINDER`, but on modern Android (Doze / OEM
|
||||||
|
battery managers) delivery can be silently delayed or dropped. v1.4 deferred this;
|
||||||
|
it directly undermines the feature's premise, so it rides in here.
|
||||||
|
|
||||||
|
- **Exact alarm — decision first:** trust the provider broadcast, or
|
||||||
|
self-schedule via `AlarmManager.setExactAndAllowWhileIdle` for reliability?
|
||||||
|
If we self-schedule, declare `USE_EXACT_ALARM` (API 33+, auto-granted for
|
||||||
|
calendar/alarm-category apps, F-Droid-clean) with a `SCHEDULE_EXACT_ALARM`
|
||||||
|
fallback for API 31–32 (user-revocable → settings deep-link prompt).
|
||||||
|
- **Battery-optimization exemption:** a *soft, optional* prompt via
|
||||||
|
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (settings deep-link — never the
|
||||||
|
auto-grant intent), honest copy: "Android may delay reminders to save battery;
|
||||||
|
exempt Calendula for on-time delivery." Shown once after the existing
|
||||||
|
`POST_NOTIFICATIONS` onboarding step, reversible in Settings → Notifications.
|
||||||
|
- **Diagnostics:** a "send a test reminder in 1 minute" button in Notifications
|
||||||
|
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
||||||
|
are notorious for suppressing it).
|
||||||
|
|
||||||
|
### Decisions made (as shipped in v2.6.0)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Round two
|
||||||
|
|
||||||
|
- ~~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
|
## Sharing & interop
|
||||||
|
|
||||||
- Share event as .ics + open/receive .ics into a prefilled create form
|
- ~~Share event as .ics + open/receive .ics into a prefilled create form~~
|
||||||
(front-runs the import below)
|
**shipped v2.7.0** — single-event share from detail; opening an `.ics` with one
|
||||||
- ICS file import (drag-and-drop) *(was v3.0, optional)*
|
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
|
## Platform & launchers
|
||||||
|
|
||||||
- Home-screen widget *(was v3.0)*
|
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
||||||
- App shortcuts (launcher long-press → New event), maybe a quick-settings tile
|
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
|
||||||
|
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
|
||||||
|
|
||||||
|
## Quality & reliability
|
||||||
|
|
||||||
|
- **Accessibility pass** — TalkBack content descriptions across all screens,
|
||||||
|
dynamic-type / large-font reflow, touch-target audit. Quality bar for an
|
||||||
|
F-Droid app; nothing tracks it yet.
|
||||||
|
- **Reminder delivery reliability** — exact alarms + battery-optimization
|
||||||
|
exemption; specced in the "Reminders — defaults & delivery reliability" slice
|
||||||
|
above (Tier 4 #9).
|
||||||
|
|
||||||
## Locations & People *(go/no-go, captured 2026-06-11)*
|
## Locations & People *(go/no-go, captured 2026-06-11)*
|
||||||
|
|
||||||
Beyond classic calendar-client scope; discussed, deliberately not planned
|
Beyond classic calendar-client scope; discussed, deliberately not planned
|
||||||
in detail yet:
|
in detail yet:
|
||||||
|
|
||||||
- **Contact address picker** for the location field via the system picker
|
- ~~**Contact address picker** for the location field via the system picker
|
||||||
(`ACTION_PICK` on postal addresses) — one-shot, needs no READ_CONTACTS,
|
(`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
|
||||||
fits the privacy story. Same mechanism later for picking emails.
|
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
|
- **OSM address autocomplete** in the location field (type "Brandenburger
|
||||||
Tor" → tap suggestion → resolved address inserted). Backend would be
|
Tor" → tap suggestion → resolved address inserted). Backend would be
|
||||||
Photon (Nominatim's public policy forbids autocomplete). **Requires the
|
Photon (Nominatim's public policy forbids autocomplete). **Requires the
|
||||||
@@ -332,9 +525,57 @@ in detail yet:
|
|||||||
trade-off is an explicit go/no-go decision before any work starts.
|
trade-off is an explicit go/no-go decision before any work starts.
|
||||||
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
|
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
|
||||||
if the picker proves clunky.
|
if the picker proves clunky.
|
||||||
- **Attendee editing / invites from contacts** — own milestone; writing
|
- **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now
|
||||||
`Attendees` rows touches sync-adapter invitation behavior (Google vs
|
Tier 5 #12, high-importance; the no-permission typed-email path is not an
|
||||||
DAVx5 differ).
|
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
|
## Consciously rejected
|
||||||
|
|
||||||
@@ -342,3 +583,5 @@ in detail yet:
|
|||||||
- Natural-language quick entry (high effort, locale-fragile; the prefilled
|
- Natural-language quick entry (high effort, locale-fragile; the prefilled
|
||||||
form already covers fast entry)
|
form already covers fast entry)
|
||||||
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
|
- 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
|
# Calendula — Current State
|
||||||
|
|
||||||
*Last updated: 2026-06-17*
|
*Last updated: 2026-06-22*
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11;
|
**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.x theme-based backlog work (organised in `ROADMAP.md`).
|
||||||
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
|
**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/
|
||||||
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
|
interop) arc is now done or in flight:
|
||||||
the calendar manager and the navigation drawer) both shipped 2026-06-16;
|
- v2.4.0 per-event colors (2026-06-17)
|
||||||
v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen
|
- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New
|
||||||
agenda + month widgets, and a "New event" launcher shortcut) shipped
|
event" launcher shortcut (2026-06-17)
|
||||||
2026-06-17. The backlog is now organised by theme in `ROADMAP.md`.
|
- 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
|
## 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
|
sync" warning on the picker and in Settings. Color writes flow through
|
||||||
insert / dirty-checked update / occurrence-exception; mapper + form tests.
|
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
|
## Next
|
||||||
|
|
||||||
1. Monitor the F-Droid build/publish for the v2.4.0 tag
|
1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the
|
||||||
2. Decide the "Locations & People" and "remote calendar create/edit"
|
merge-driven pipeline) once on-device review signs off
|
||||||
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
|
2. **Attendee editing** — the committed next feature (Tier 5 #12, high-
|
||||||
3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then
|
importance, opened 2026-06-22). Attendees are already read & shown on the
|
||||||
agenda view (strategic, backs a future widget). Full ranked sequence in
|
detail screen since v0.6; the gap is the write side — make guests editable
|
||||||
`ROADMAP.md` → "Near-term sequence".
|
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**.
|
||||||
|
|||||||
235
CHANGELOG.md
235
CHANGELOG.md
@@ -5,7 +5,231 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
- Share a single event as an `.ics` file from the event detail screen — hands a
|
||||||
|
standard calendar file to any app via the system share sheet.
|
||||||
|
- Back up your local (device-only) calendars: Settings → Calendars → Export as
|
||||||
|
`.ics` file writes every event of your on-device calendars to a file you
|
||||||
|
choose. Local calendars aren't synced anywhere, so this is their only backup.
|
||||||
|
- Open or share an `.ics` file into Calendula: a single event opens the create
|
||||||
|
form prefilled for review, while a file with many events (e.g. a backup) opens
|
||||||
|
a bulk import — pick a calendar and import them all. Re-importing a backup
|
||||||
|
won't create duplicates (events are matched by their unique identifier), and
|
||||||
|
anything Calendula can't represent (changed recurring occurrences, guest
|
||||||
|
lists) is reported rather than silently dropped.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- All-day events that cover a single day (e.g. a birthday) no longer show up on
|
||||||
|
the following day as well — in the day, week and month views or on the event
|
||||||
|
detail screen. The extra day came from interpreting the all-day date range in
|
||||||
|
the device's time zone instead of UTC.
|
||||||
|
- Fixed the app crashing immediately on every launch in the optimized release
|
||||||
|
build: release code-shrinking (R8) was stripping a database class the
|
||||||
|
home-screen widget framework needs, so the app died at startup before showing
|
||||||
|
anything. Added the missing keep rule.
|
||||||
|
|
||||||
|
## [2.6.0] — 2026-06-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- App language can now be set from Android's system per-app language settings
|
||||||
|
(Android 13+), in addition to the in-app picker in Settings — and the app is
|
||||||
|
set up so further languages can be added by community translators
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Changing the app language in Settings now takes effect immediately; the
|
||||||
|
picker previously had no effect
|
||||||
|
|
||||||
## [2.5.0] — 2026-06-17
|
## [2.5.0] — 2026-06-17
|
||||||
|
|
||||||
@@ -452,3 +676,12 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
|
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
|
||||||
- F-Droid metadata stubs (DE + EN short/full descriptions)
|
- F-Droid metadata stubs (DE + EN short/full descriptions)
|
||||||
- `.planning/` project-tracking documents
|
- `.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
|
||||||
|
|||||||
77
README.md
77
README.md
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<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>
|
<h1>Calendula</h1>
|
||||||
|
|
||||||
@@ -16,11 +16,17 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
<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>
|
||||||
<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">
|
<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>
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
</p>
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
|
|
||||||
|
<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>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -62,19 +68,27 @@ database, no sync stack reinvented.
|
|||||||
|
|
||||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
||||||
expressive motion and shapes, light/dark theme
|
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
|
- **Zero telemetry, zero analytics, no internet permission** — your data
|
||||||
never leaves the device
|
never leaves the device
|
||||||
|
|
||||||
## 📦 Install
|
## 📦 Install
|
||||||
|
|
||||||
Calendula ships through a self-hosted F-Droid repository; every version tag
|
### F-Droid (recommended)
|
||||||
is built, signed, and published there automatically.
|
|
||||||
|
|
||||||
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo
|
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
||||||
Store, …).
|
just search for **Calendula** in any F-Droid client, or
|
||||||
2. Add the repository — open this link on your phone, or paste it under
|
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
||||||
*Settings → Repositories → Add*:
|
|
||||||
|
### 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
|
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
|
||||||
@@ -84,36 +98,27 @@ is built, signed, and published there automatically.
|
|||||||
fingerprint (SHA-256):
|
fingerprint (SHA-256):
|
||||||
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
||||||
|
|
||||||
3. Refresh, search for **Calendula**, install. Updates arrive like any
|
2. Refresh, search for **Calendula**, install.
|
||||||
other F-Droid app.
|
|
||||||
|
|
||||||
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
|
## 🌍 Translations
|
||||||
./gradlew assembleDebug # debug APK
|
|
||||||
./gradlew test # JVM unit tests
|
|
||||||
./gradlew lint # Android lint
|
|
||||||
```
|
|
||||||
|
|
||||||
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 →
|
No coding needed — register on the Weblate server, pick (or request) a language,
|
||||||
CalendarContract`, observer-driven refresh, JVM-first tests. The full tour —
|
and translate the strings in your browser. You can also reach this link in the
|
||||||
including the recurring-write and reminder pipelines — lives in
|
app from the top of **Settings → App language**.
|
||||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
||||||
|
|
||||||
## 🗺 Roadmap
|
|
||||||
|
|
||||||
Shipped: read (v1.0), write (v1.1–v2.0), reminder delivery (v1.4).
|
|
||||||
Next up: power-user features — widget, search, tablet layouts. The living
|
|
||||||
roadmap is in [.planning/ROADMAP.md](.planning/ROADMAP.md), the release
|
|
||||||
history in [CHANGELOG.md](CHANGELOG.md).
|
|
||||||
|
|
||||||
## 📜 License
|
## 📜 License
|
||||||
|
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ android {
|
|||||||
applicationId = "de.jeanlucmakiola.calendula"
|
applicationId = "de.jeanlucmakiola.calendula"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
// The git tag is the single source of truth for released builds: at
|
// These committed values ARE the source of truth for a release: merging
|
||||||
// release time .gitea/workflows/release.yaml derives both fields from
|
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||||
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 20500
|
versionCode = 21100
|
||||||
versionName = "2.5.0"
|
versionName = "2.11.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,11 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
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
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
@@ -61,6 +66,21 @@ android {
|
|||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
isMinifyEnabled = false
|
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 {
|
compileOptions {
|
||||||
@@ -72,12 +92,31 @@ android {
|
|||||||
compose = true
|
compose = 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 {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
// Community translations are expected to be partial — a missing string
|
||||||
|
// falls back to the English base at runtime — so don't fail the build on
|
||||||
|
// it. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||||
|
// check_translations.py guards the same invariants with clearer,
|
||||||
|
// translator-facing messages.
|
||||||
|
informational += "MissingTranslation"
|
||||||
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests {
|
unitTests {
|
||||||
all { it.useJUnitPlatform() }
|
all { it.useJUnitPlatform() }
|
||||||
@@ -113,6 +152,9 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
implementation(libs.androidx.documentfile)
|
||||||
|
|
||||||
implementation(libs.androidx.glance.appwidget)
|
implementation(libs.androidx.glance.appwidget)
|
||||||
implementation(libs.androidx.glance.material3)
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
||||||
|
|||||||
22
app/proguard-rules.pro
vendored
22
app/proguard-rules.pro
vendored
@@ -4,3 +4,25 @@
|
|||||||
|
|
||||||
# Compose Compiler may keep its own; defaults are fine
|
# Compose Compiler may keep its own; defaults are fine
|
||||||
-dontwarn org.jetbrains.annotations.**
|
-dontwarn org.jetbrains.annotations.**
|
||||||
|
|
||||||
|
# Room database implementations (pulled in transitively via
|
||||||
|
# androidx.glance:glance-appwidget → androidx.work → androidx.room).
|
||||||
|
# The widgets rely on Glance, whose WorkManager backend stores state in a Room
|
||||||
|
# database. Under R8 full mode (AGP 9 default) the generated *_Impl subclasses
|
||||||
|
# of RoomDatabase lose their usable no-arg constructor / are marked abstract,
|
||||||
|
# so Room's reflective instantiation throws InstantiationException and the app
|
||||||
|
# crashes at startup with "Failed to create an instance of ...WorkDatabase".
|
||||||
|
# Keep the generated Room database implementations fully intact.
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
|
# 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>(...); }
|
||||||
|
|||||||
@@ -5,6 +5,24 @@
|
|||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!--
|
||||||
|
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||||
|
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||||
|
delayed by Doze). Used only to launch that dialog; falls back to the
|
||||||
|
battery-optimisation list if the OS declines the direct intent.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
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
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
returns null and the calendar manager's per-account "manage" button can't
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
@@ -21,10 +39,12 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".CalendulaApp"
|
android:name=".CalendulaApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Calendula"
|
android:theme="@style/Theme.Calendula"
|
||||||
@@ -39,12 +59,50 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||||
|
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- Receive a .ics shared from another app. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/calendar" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
android:resource="@xml/shortcuts" />
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</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
|
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||||
no notification itself — a calendar app must (v1.4, Etar model).
|
no notification itself — a calendar app must (v1.4, Etar model).
|
||||||
Exported: the broadcast arrives from the provider's process. -->
|
Exported: the broadcast arrives from the provider's process. -->
|
||||||
@@ -59,6 +117,13 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</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. -->
|
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||||
@@ -104,6 +169,19 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Hands .ics files we stage in the cache to other apps via a content
|
||||||
|
Uri (single-event share). Authority tracks applicationId so the
|
||||||
|
debug suffix doesn't break getUriForFile. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
||||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
package de.jeanlucmakiola.calendula
|
package de.jeanlucmakiola.calendula
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||||
|
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"
|
* Application entry point. Registered as android:name=".CalendulaApp"
|
||||||
* in AndroidManifest.xml. Hilt initializes its component graph here.
|
* in AndroidManifest.xml. Hilt initializes its component graph here.
|
||||||
*/
|
*/
|
||||||
@HiltAndroidApp
|
@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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,29 +2,42 @@ package de.jeanlucmakiola.calendula
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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.ThemeMode
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
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.WidgetNavRequest
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
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.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
// The occurrence a reminder notification was tapped for (eventId, begin,
|
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||||
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||||
@@ -35,11 +48,35 @@ class MainActivity : ComponentActivity() {
|
|||||||
// create). Consumed once by CalendarHost, same pattern as the detail key.
|
// create). Consumed once by CalendarHost, same pattern as the detail key.
|
||||||
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
|
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
|
||||||
|
|
||||||
|
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
|
||||||
|
// by CalendarHost's import flow.
|
||||||
|
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||||
|
|
||||||
|
// 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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()
|
enableEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull()
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
|
requestedImportUri = intent.importUriOrNull()
|
||||||
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
// and the Settings screen, so a theme change applies app-wide at once.
|
// and the Settings screen, so a theme change applies app-wide at once.
|
||||||
@@ -50,9 +87,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
ThemeMode.LIGHT -> false
|
ThemeMode.LIGHT -> false
|
||||||
ThemeMode.DARK -> true
|
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(
|
CalendulaTheme(
|
||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalUse24HourFormat provides use24Hour,
|
||||||
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -60,29 +108,89 @@ class MainActivity : ComponentActivity() {
|
|||||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||||
widgetNavRequest = requestedNav,
|
widgetNavRequest = requestedNav,
|
||||||
onWidgetNavConsumed = { requestedNav = null },
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = { requestedImportUri = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
/**
|
||||||
|
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
||||||
|
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
||||||
|
* app's own `calendula://` deep-links never match.
|
||||||
|
*/
|
||||||
|
private fun Intent.importUriOrNull(): Uri? {
|
||||||
|
val uri = when (action) {
|
||||||
|
Intent.ACTION_VIEW -> data
|
||||||
|
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
else -> null
|
||||||
|
} ?: return null
|
||||||
|
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||||
|
val source = sourceViewOrNull()
|
||||||
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
|
return when {
|
||||||
// Launcher long-press "New event" shortcut. Static shortcut intents
|
// Launcher long-press "New event" shortcut. Static shortcut intents
|
||||||
// can't carry typed extras, so the action alone signals create-on-today.
|
// can't carry typed extras, so the action alone signals create-on-today.
|
||||||
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
||||||
getBooleanExtra(EXTRA_CREATE, false) ->
|
getBooleanExtra(EXTRA_CREATE, false) ->
|
||||||
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
||||||
getStringExtra(EXTRA_DATE_ISO) != null ->
|
// A widget event tap carries both the occurrence and its source view;
|
||||||
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
|
// 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
|
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? {
|
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)
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
if (eventId == -1L) return null
|
if (eventId == -1L) return null
|
||||||
return longArrayOf(
|
return longArrayOf(
|
||||||
@@ -99,6 +207,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
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/
|
// Fired by the launcher long-press "New event" shortcut (res/xml/
|
||||||
// shortcuts.xml hardcodes this string — keep the two in sync).
|
// shortcuts.xml hardcodes this string — keep the two in sync).
|
||||||
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
||||||
@@ -121,14 +233,35 @@ class MainActivity : ComponentActivity() {
|
|||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
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 {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
data = "calendula://date/$date".toUri()
|
data = "calendula://date/$date".toUri()
|
||||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
|
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
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). */
|
/** Open the create-event form prefilled for [date] (home-screen widgets). */
|
||||||
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
||||||
Intent(context, MainActivity::class.java).apply {
|
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,70 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates an all-day reminder between the **semantic** lead time the UI
|
||||||
|
* speaks (whole days before the event — "1 day before") and the **raw**
|
||||||
|
* `CalendarContract.Reminders.MINUTES` offset the provider stores.
|
||||||
|
*
|
||||||
|
* Calendula schedules no alarms itself: the provider fires a reminder at
|
||||||
|
* `DTSTART − MINUTES` (the Etar model). An all-day event's DTSTART is **UTC
|
||||||
|
* midnight** (see [EventWriteTimes]), so a raw `MINUTES = 1440` ("1 day") lands
|
||||||
|
* on UTC-midnight of the previous day — 02:00 local in CEST, not the morning.
|
||||||
|
*
|
||||||
|
* To fire at a chosen wall-clock time we encode that time *into* the offset:
|
||||||
|
* `MINUTES = UTC-midnight(startDate) − (localInstant of [timeOfDayMinutes] on the
|
||||||
|
* day [semanticMinutes] before)`. The single fixed offset can only be tuned for
|
||||||
|
* the event's own date, so a recurring all-day series or a post-creation
|
||||||
|
* timezone change drifts the fire time by the offset delta (±1h across DST) —
|
||||||
|
* an inherent limit of the provider model, shared by Etar.
|
||||||
|
*/
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw provider `MINUTES` for an all-day reminder set [semanticMinutes] before the
|
||||||
|
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
|
||||||
|
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
|
||||||
|
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to −420, meaning
|
||||||
|
* the provider fires *after* DTSTART; this is valid and must not be clamped.
|
||||||
|
* A negative [semanticMinutes] is the "provider default" sentinel and passes
|
||||||
|
* through unchanged.
|
||||||
|
*/
|
||||||
|
internal fun toProviderAllDayMinutes(
|
||||||
|
semanticMinutes: Int,
|
||||||
|
startDate: LocalDate,
|
||||||
|
zone: ZoneId,
|
||||||
|
timeOfDayMinutes: Int,
|
||||||
|
): Int {
|
||||||
|
if (semanticMinutes < 0) return semanticMinutes
|
||||||
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
|
||||||
|
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
||||||
|
.atZone(zone).toInstant().toEpochMilli()
|
||||||
|
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||||
|
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||||
|
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
||||||
|
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
||||||
|
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
||||||
|
* after DTSTART) folds to day 0.
|
||||||
|
*/
|
||||||
|
internal fun fromProviderAllDayMinutes(
|
||||||
|
rawMinutes: Int,
|
||||||
|
startDate: LocalDate,
|
||||||
|
zone: ZoneId,
|
||||||
|
): Int {
|
||||||
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
||||||
|
.atZone(zone).toLocalDate()
|
||||||
|
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -11,17 +13,24 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -38,6 +47,14 @@ interface CalendarDataSource {
|
|||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
fun eventDetail(eventId: Long): EventDetail?
|
fun eventDetail(eventId: Long): EventDetail?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* The event-colour palette the calendar's account publishes
|
||||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
||||||
@@ -47,6 +64,26 @@ interface CalendarDataSource {
|
|||||||
*/
|
*/
|
||||||
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every master/one-off event of the writable local calendars, mapped for a
|
||||||
|
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||||
|
* rows are excluded (see [EventExportProjection]).
|
||||||
|
*/
|
||||||
|
fun exportableEvents(): List<IcsEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||||
|
* dedup an `.ics` import so re-importing a backup doesn't double events.
|
||||||
|
*/
|
||||||
|
fun existingUids(calendarId: Long): Set<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||||
|
* minting one when absent); returns the new `Events._ID`. Reminders are
|
||||||
|
* written as the file's raw lead minutes (METHOD_ALERT).
|
||||||
|
*/
|
||||||
|
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||||
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
||||||
@@ -60,24 +97,40 @@ interface CalendarDataSource {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
fun deleteCalendar(id: Long)
|
fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
/** Insert a new event; returns the new `Events._ID`. */
|
/**
|
||||||
fun insertEvent(form: EventForm): Long
|
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||||
|
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||||
|
* should fire at — encoded into each all-day reminder's provider offset
|
||||||
|
* (ignored for timed events).
|
||||||
|
*/
|
||||||
|
fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing event (for recurring events: the whole series) to
|
* Update an existing event (for recurring events: the whole series) to
|
||||||
* match [updated]. [original] is the form as it was prefilled from the
|
* match [updated]. [original] is the form as it was prefilled from the
|
||||||
* event, so only fields the user actually changed are written and the
|
* event, so only fields the user actually changed are written and the
|
||||||
* reminder rows can be diffed instead of wiped.
|
* reminder rows can be diffed instead of wiped.
|
||||||
|
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
*/
|
*/
|
||||||
fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event by inserting a
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||||
* row's `Events._ID`.
|
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
*/
|
*/
|
||||||
fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a recurring event from the occurrence at [beginMillis] onwards
|
* Change a recurring event from the occurrence at [beginMillis] onwards
|
||||||
@@ -92,6 +145,7 @@ interface CalendarDataSource {
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,14 +174,52 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
) : CalendarDataSource {
|
) : CalendarDataSource {
|
||||||
|
|
||||||
private val resolver: ContentResolver get() = context.contentResolver
|
private val resolver: ContentResolver get() = context.contentResolver
|
||||||
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
|
|
||||||
|
|
||||||
override fun calendars(): List<CalendarSource> = resolver.query(
|
// 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,
|
CalendarContract.Calendars.CONTENT_URI,
|
||||||
CalendarProjection.COLUMNS,
|
CalendarProjection.COLUMNS,
|
||||||
null, null,
|
null, null,
|
||||||
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
||||||
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar-row writes must address the provider as a sync adapter and name
|
* Calendar-row writes must address the provider as a sync adapter and name
|
||||||
@@ -162,7 +254,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
putDescription(description)
|
putDescription(description)
|
||||||
}
|
}
|
||||||
val uri = resolver.insert(localCalendarsUri(), values)
|
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)
|
return ContentUris.parseId(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +293,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||||
|
ensureObserversRegistered()
|
||||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
ContentUris.appendId(this, beginMillis)
|
ContentUris.appendId(this, beginMillis)
|
||||||
ContentUris.appendId(this, endMillis)
|
ContentUris.appendId(this, endMillis)
|
||||||
@@ -212,6 +306,89 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.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? {
|
override fun eventDetail(eventId: Long): EventDetail? {
|
||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
val reminders = queryReminders(eventId)
|
val reminders = queryReminders(eventId)
|
||||||
@@ -247,6 +424,112 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun exportableEvents(): List<IcsEvent> {
|
||||||
|
// Only the local calendars the app owns and can write — synced calendars
|
||||||
|
// already have a backup (their server). Map id → display name for the
|
||||||
|
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||||
|
val names = calendars()
|
||||||
|
.filter { it.isLocal && it.canModifyContents }
|
||||||
|
.associate { it.id to it.displayName }
|
||||||
|
if (names.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val idList = names.keys.joinToString(",")
|
||||||
|
return resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
EventExportProjection.COLUMNS,
|
||||||
|
// Skip soft-deleted rows and exception rows (modified occurrences /
|
||||||
|
// cancellations) — v1 exports masters + one-offs only.
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} IN ($idList) AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.ORIGINAL_ID} IS NULL",
|
||||||
|
null,
|
||||||
|
CalendarContract.Events.DTSTART + " ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
c.mapAll {
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
val eventId = reader.getLong(EventExportProjection.IDX_ID)
|
||||||
|
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID)
|
||||||
|
reader.toIcsEvent(
|
||||||
|
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||||
|
calendarName = names[calendarId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Events.UID_2445),
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||||
|
arrayOf(calendarId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
||||||
|
} ?: emptySet()
|
||||||
|
|
||||||
|
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||||
|
val startMillis = event.start.toEpochMillis()
|
||||||
|
val endMillis = event.end.toEpochMillis()
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||||
|
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||||
|
// only when the source event carried none.
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, startMillis)
|
||||||
|
if (event.recurrenceRule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, endMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
importDuration(startMillis, endMillis, event.isAllDay),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||||
|
// timed rows keep the event's own zone.
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||||
|
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
|
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||||
|
val eventId = ContentUris.parseId(uri)
|
||||||
|
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
|
||||||
|
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
|
||||||
|
val reminder = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider DURATION for an imported recurring row: whole days / seconds. */
|
||||||
|
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||||
|
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||||
|
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||||
|
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||||
|
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||||
|
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||||
|
}
|
||||||
|
|
||||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||||
@@ -265,13 +548,44 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
private data class CalendarAccount(val name: String, val type: String)
|
private data class CalendarAccount(val name: String, val type: String)
|
||||||
|
|
||||||
override fun insertEvent(form: EventForm): Long {
|
/**
|
||||||
|
* The raw provider `MINUTES` to store for one of [form]'s reminders: an
|
||||||
|
* all-day reminder is shifted to fire at [allDayReminderTimeMinutes] local
|
||||||
|
* (see [toProviderAllDayMinutes]); a timed reminder is its lead time as-is.
|
||||||
|
*/
|
||||||
|
private fun providerReminderMinutes(
|
||||||
|
form: EventForm,
|
||||||
|
minutes: Int,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Int = if (form.isAllDay) {
|
||||||
|
toProviderAllDayMinutes(
|
||||||
|
semanticMinutes = minutes,
|
||||||
|
startDate = form.start.date.toJavaLocalDate(),
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [form]'s reminders as the distinct raw provider offsets to store. */
|
||||||
|
private fun encodedReminders(form: EventForm, allDayReminderTimeMinutes: Int): List<Int> =
|
||||||
|
form.reminders
|
||||||
|
.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(
|
put(
|
||||||
CalendarContract.Events.CALENDAR_ID,
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
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.TITLE, form.title.trim())
|
||||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
@@ -303,7 +617,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val eventId = ContentUris.parseId(uri)
|
val eventId = ContentUris.parseId(uri)
|
||||||
// Best effort (spec §8): the event exists at this point — a reminder
|
// Best effort (spec §8): the event exists at this point — a reminder
|
||||||
// that fails to attach is logged, not surfaced as a failed create.
|
// that fails to attach is logged, not surfaced as a failed create.
|
||||||
form.reminders.distinct().forEach { minutes ->
|
encodedReminders(form, allDayReminderTimeMinutes)
|
||||||
|
.forEach { minutes ->
|
||||||
val reminder = ContentValues().apply {
|
val reminder = ContentValues().apply {
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
@@ -313,10 +628,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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
|
return eventId
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
|
override fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
) {
|
||||||
val values = buildEventUpdateValues(
|
val values = buildEventUpdateValues(
|
||||||
original = original,
|
original = original,
|
||||||
updated = updated,
|
updated = updated,
|
||||||
@@ -332,13 +656,25 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
||||||
}
|
}
|
||||||
// Untouched reminder sets are left alone so unrelated edits can't
|
// Untouched reminder sets are left alone so unrelated edits can't
|
||||||
// disturb provider rows the form never knew about.
|
// disturb provider rows the form never knew about. The diff is on the
|
||||||
|
// form's semantic minutes; reconcile works in encoded provider minutes.
|
||||||
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||||
reconcileReminders(eventId, updated.reminders)
|
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(eventId: Long, beginMillis: Long, form: EventForm): Long {
|
override fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
// The provider clones the series row and applies these values on top.
|
// The provider clones the series row and applies these values on top.
|
||||||
val values = buildOccurrenceExceptionValues(
|
val values = buildOccurrenceExceptionValues(
|
||||||
form = form,
|
form = form,
|
||||||
@@ -352,7 +688,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val exceptionId = ContentUris.parseId(uri)
|
val exceptionId = ContentUris.parseId(uri)
|
||||||
// Whether the provider copied the parent's reminder rows is its
|
// Whether the provider copied the parent's reminder rows is its
|
||||||
// business — reconciling against the actual rows handles both ways.
|
// business — reconciling against the actual rows handles both ways.
|
||||||
reconcileReminders(exceptionId, form.reminders)
|
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||||
|
reconcileAttendees(exceptionId, form.attendees)
|
||||||
return exceptionId
|
return exceptionId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,16 +698,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
// From the first occurrence on (or with no rule to split) this is
|
// From the first occurrence on (or with no rule to split) this is
|
||||||
// just a series update.
|
// just a series update.
|
||||||
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||||
updateEvent(eventId, original, updated)
|
updateEvent(eventId, original, updated, allDayReminderTimeMinutes)
|
||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
// Insert the new series first: if it fails, the original is untouched.
|
// Insert the new series first: if it fails, the original is untouched.
|
||||||
val newEventId = insertEvent(updated)
|
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
||||||
truncateSeries(eventId, row, beginMillis)
|
truncateSeries(eventId, row, beginMillis)
|
||||||
return newEventId
|
return newEventId
|
||||||
}
|
}
|
||||||
@@ -456,9 +794,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make the event's reminder rows match [targetMinutes]: rows with other
|
* Make the event's reminder rows match [targetMinutes] — the raw provider
|
||||||
* lead times are deleted, missing ones inserted as best-effort ALERTs
|
* offsets to store (already encoded via [encodedReminders], so all-day shifts
|
||||||
* (like insertEvent). Rows whose minutes survive keep their method.
|
* are baked in and the diff matches the stored rows). Rows with other offsets
|
||||||
|
* are deleted, missing ones inserted as best-effort ALERTs (like insertEvent).
|
||||||
|
* Rows whose minutes survive keep their method.
|
||||||
*/
|
*/
|
||||||
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
|
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
|
||||||
val target = targetMinutes.toSet()
|
val target = targetMinutes.toSet()
|
||||||
@@ -483,6 +823,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 =
|
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
||||||
ContentValues().also { cv ->
|
ContentValues().also { cv ->
|
||||||
forEach { (column, value) ->
|
forEach { (column, value) ->
|
||||||
@@ -491,7 +960,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
is String -> cv.put(column, value)
|
is String -> cv.put(column, value)
|
||||||
is Long -> cv.put(column, value)
|
is Long -> cv.put(column, value)
|
||||||
is Int -> 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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,16 +994,20 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
listener()
|
listener()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
synchronized(observerLock) {
|
||||||
observers[listener] = obs
|
observers[listener] = obs
|
||||||
resolver.registerContentObserver(
|
// Attach now if we already hold the permission; otherwise it stays
|
||||||
CalendarContract.CONTENT_URI,
|
// pending and re-attaches on the first read after the grant.
|
||||||
/* notifyForDescendants = */ true,
|
registerObserverLocked(obs)
|
||||||
obs,
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unregisterChangeListener(listener: () -> Unit) {
|
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(
|
private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(
|
||||||
@@ -572,5 +1046,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
* together (by account) in the filter sheet and calendar manager.
|
* together (by account) in the filter sheet and calendar manager.
|
||||||
*/
|
*/
|
||||||
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -13,6 +16,13 @@ interface CalendarRepository {
|
|||||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* The event-colour palette a calendar's account publishes; empty when it
|
||||||
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
||||||
@@ -28,6 +38,19 @@ interface CalendarRepository {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
suspend fun deleteCalendar(id: Long)
|
suspend fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every event of the writable local calendars, ready to serialise into a
|
||||||
|
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||||
|
*/
|
||||||
|
suspend fun exportEvents(): List<IcsEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||||
|
* UID already exists in the target are skipped (idempotent restore); the
|
||||||
|
* rest are inserted. See [CalendarDataSource.insertImportedEvent].
|
||||||
|
*/
|
||||||
|
suspend fun importEvents(targetCalendarId: Long, events: List<ParsedIcsEvent>): IcsImportSummary
|
||||||
|
|
||||||
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||||
suspend fun createEvent(form: EventForm): Long
|
suspend fun createEvent(form: EventForm): Long
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
@@ -28,9 +33,14 @@ import javax.inject.Singleton
|
|||||||
class CalendarRepositoryImpl @Inject constructor(
|
class CalendarRepositoryImpl @Inject constructor(
|
||||||
private val dataSource: CalendarDataSource,
|
private val dataSource: CalendarDataSource,
|
||||||
private val prefs: CalendarPrefs,
|
private val prefs: CalendarPrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : CalendarRepository {
|
) : CalendarRepository {
|
||||||
|
|
||||||
|
/** The configured wall-clock fire time for all-day reminders, read per write. */
|
||||||
|
private suspend fun allDayReminderTimeMinutes(): Int =
|
||||||
|
settingsPrefs.allDayReminderTimeMinutes.first()
|
||||||
|
|
||||||
private val ticks = MutableSharedFlow<Unit>(
|
private val ticks = MutableSharedFlow<Unit>(
|
||||||
replay = 0,
|
replay = 0,
|
||||||
extraBufferCapacity = 1,
|
extraBufferCapacity = 1,
|
||||||
@@ -46,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
.reQuery { dataSource.calendars() }
|
.reQuery { dataSource.calendars() }
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
|
|
||||||
// Instances are filtered by the app-side hidden-calendar set (M3): an event
|
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
||||||
// is dropped whenever the user has hidden its calendar. Re-runs when the
|
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
||||||
// provider ticks *or* the hidden set changes — toggling a calendar in the
|
// calendar. Re-runs when the provider ticks *or* either set changes —
|
||||||
// filter sheet updates every view immediately. [calendars] stays unfiltered
|
// toggling a calendar in the filter sheet or the calendar manager updates
|
||||||
// so the filter sheet can list and re-enable hidden calendars.
|
// 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>> =
|
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||||
combine(
|
combine(
|
||||||
ticks
|
ticks
|
||||||
@@ -62,15 +73,29 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
prefs.hiddenCalendarIds,
|
prefs.hiddenCalendarIds,
|
||||||
) { instances, hidden ->
|
prefs.disabledCalendarIds,
|
||||||
if (hidden.isEmpty()) instances
|
) { instances, hidden, disabled ->
|
||||||
else instances.filterNot { it.calendarId in hidden }
|
val excluded = hidden + disabled
|
||||||
}.flowOn(io)
|
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) {
|
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
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> =
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
||||||
|
|
||||||
@@ -92,8 +117,30 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun deleteCalendar(id: Long) =
|
override suspend fun deleteCalendar(id: Long) =
|
||||||
withContext(io) { dataSource.deleteCalendar(id) }
|
withContext(io) { dataSource.deleteCalendar(id) }
|
||||||
|
|
||||||
|
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
|
||||||
|
|
||||||
|
override suspend fun importEvents(
|
||||||
|
targetCalendarId: Long,
|
||||||
|
events: List<ParsedIcsEvent>,
|
||||||
|
): IcsImportSummary = withContext(io) {
|
||||||
|
val existing = dataSource.existingUids(targetCalendarId)
|
||||||
|
var imported = 0
|
||||||
|
var skipped = 0
|
||||||
|
for (event in events) {
|
||||||
|
// A known UID means the event is already in this calendar — skip,
|
||||||
|
// keeping a restore idempotent (no overwrite this pass).
|
||||||
|
if (event.uid != null && event.uid in existing) {
|
||||||
|
skipped++
|
||||||
|
} else {
|
||||||
|
dataSource.insertImportedEvent(event, targetCalendarId)
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
dataSource.insertEvent(form)
|
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateEvent(
|
override suspend fun updateEvent(
|
||||||
@@ -101,7 +148,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
) = withContext(io) {
|
) = withContext(io) {
|
||||||
dataSource.updateEvent(eventId, original, updated)
|
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||||
@@ -113,7 +160,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = withContext(io) {
|
||||||
dataSource.updateOccurrence(eventId, beginMillis, form)
|
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateEventFromOccurrence(
|
override suspend fun updateEventFromOccurrence(
|
||||||
@@ -122,7 +169,9 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = withContext(io) {
|
||||||
dataSource.updateEventFromOccurrence(eventId, beginMillis, original, updated)
|
dataSource.updateEventFromOccurrence(
|
||||||
|
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
private const val TAG = "EventDetailMapper"
|
private const val TAG = "EventDetailMapper"
|
||||||
|
|
||||||
@@ -58,6 +61,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||||
|
|
||||||
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
||||||
|
val isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0
|
||||||
val instance = EventInstance(
|
val instance = EventInstance(
|
||||||
instanceId = eventId,
|
instanceId = eventId,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
@@ -65,11 +69,23 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
title = title,
|
title = title,
|
||||||
start = begin.toKotlinInstantFromEpochMillis(),
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
end = end.toKotlinInstantFromEpochMillis(),
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0,
|
isAllDay = isAllDay,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(EventDetailProjection.IDX_LOCATION),
|
location = getString(EventDetailProjection.IDX_LOCATION),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// All-day reminders are stored as a wall-clock-shifted offset (see
|
||||||
|
// AllDayReminderEncoding); decode back to the whole-day lead time the form
|
||||||
|
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
|
||||||
|
// event's date is its UTC date.
|
||||||
|
val displayReminders = if (isAllDay) {
|
||||||
|
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
|
||||||
|
} else {
|
||||||
|
reminders
|
||||||
|
}
|
||||||
|
|
||||||
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
||||||
// be distinguished from a present 0 — an absent status means "just confirmed".
|
// be distinguished from a present 0 — an absent status means "just confirmed".
|
||||||
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
||||||
@@ -84,7 +100,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
||||||
attendees = attendees,
|
attendees = attendees,
|
||||||
rrule = getString(EventDetailProjection.IDX_RRULE),
|
rrule = getString(EventDetailProjection.IDX_RRULE),
|
||||||
reminders = reminders,
|
reminders = displayReminders,
|
||||||
status = status,
|
status = status,
|
||||||
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
||||||
// default these mappers already return — no isNull guard needed.
|
// default these mappers already return — no isNull guard needed.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||||
|
* for backup. [reminderMinutes] are the row's raw provider reminder offsets and
|
||||||
|
* [calendarName] the display name of its calendar (emitted as
|
||||||
|
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
||||||
|
* MapColumnReader.
|
||||||
|
*/
|
||||||
|
internal fun ColumnReader.toIcsEvent(
|
||||||
|
reminderMinutes: List<Int>,
|
||||||
|
calendarName: String?,
|
||||||
|
): IcsEvent {
|
||||||
|
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||||
|
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
||||||
|
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
||||||
|
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
||||||
|
val end = when {
|
||||||
|
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
|
||||||
|
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
|
||||||
|
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
|
||||||
|
EventStatus.Confirmed
|
||||||
|
} else {
|
||||||
|
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
|
||||||
|
}
|
||||||
|
|
||||||
|
return IcsEvent(
|
||||||
|
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
|
||||||
|
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
||||||
|
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||||
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
|
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
|
||||||
|
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||||
|
?: "UTC",
|
||||||
|
recurrenceRule = rrule,
|
||||||
|
location = getString(EventExportProjection.IDX_LOCATION),
|
||||||
|
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||||
|
reminderMinutes = reminderMinutes,
|
||||||
|
status = status,
|
||||||
|
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||||
|
calendarName = calendarName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -97,6 +97,86 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
||||||
|
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
||||||
|
* backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
|
||||||
|
* occurrence and cancelled-exception rows are filtered out by the query
|
||||||
|
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
|
||||||
|
* exported yet — a documented v1 limit (import skips them too).
|
||||||
|
*/
|
||||||
|
internal object EventExportProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Events._ID,
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.DTEND,
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
|
CalendarContract.Events.DESCRIPTION,
|
||||||
|
CalendarContract.Events.STATUS,
|
||||||
|
CalendarContract.Events.AVAILABILITY,
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_ID = 0
|
||||||
|
const val IDX_UID = 1
|
||||||
|
const val IDX_TITLE = 2
|
||||||
|
const val IDX_DTSTART = 3
|
||||||
|
const val IDX_DTEND = 4
|
||||||
|
const val IDX_DURATION = 5
|
||||||
|
const val IDX_ALL_DAY = 6
|
||||||
|
const val IDX_EVENT_TIMEZONE = 7
|
||||||
|
const val IDX_RRULE = 8
|
||||||
|
const val IDX_LOCATION = 9
|
||||||
|
const val IDX_DESCRIPTION = 10
|
||||||
|
const val IDX_STATUS = 11
|
||||||
|
const val IDX_AVAILABILITY = 12
|
||||||
|
const val IDX_CALENDAR_ID = 13
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
internal object AttendeeProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
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,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")
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Android IO edge of `.ics` export: writes a serialised calendar to a
|
||||||
|
* SAF document (whole-calendar backup) or stages it in a cache file behind a
|
||||||
|
* `FileProvider` content Uri (single-event share). The serialisation itself is
|
||||||
|
* the pure `domain.ics.IcsWriter`; this class only moves bytes.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class IcsExporter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Write [content] to the SAF document at [uri] as UTF-8. Throws on failure. */
|
||||||
|
fun writeDocument(uri: Uri, content: String) {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||||
|
out.write(content.toByteArray(Charsets.UTF_8))
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage [content] in a private cache file and return a shareable content
|
||||||
|
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
||||||
|
* the receiving app. The authority mirrors the manifest's `FileProvider`.
|
||||||
|
*/
|
||||||
|
fun stageShareFile(fileName: String, content: String): Uri {
|
||||||
|
val dir = File(context.cacheDir, SHARE_DIR).apply { mkdirs() }
|
||||||
|
val file = File(dir, fileName)
|
||||||
|
file.writeText(content, Charsets.UTF_8)
|
||||||
|
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SHARE_DIR = "shared_ics"
|
||||||
|
const val MIME_CALENDAR = "text/calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.ics
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android IO edge of `.ics` import: reads the text of a received/opened
|
||||||
|
* document [Uri]. Parsing is the pure `domain.ics.IcsParser`; this class only
|
||||||
|
* pulls bytes off the ContentResolver. Returns null on any read failure.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class IcsImporter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
fun readText(uri: Uri): String? = runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@@ -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
|
* The calendar the user last created an event in; preselected in the
|
||||||
* event form. Null until the first event is created.
|
* event form. Null until the first event is created.
|
||||||
@@ -53,6 +78,7 @@ class CalendarPrefs @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
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")
|
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,14 @@ import androidx.datastore.core.DataStore
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -17,18 +23,39 @@ import javax.inject.Singleton
|
|||||||
/** Light/dark override. SYSTEM follows the device setting. */
|
/** Light/dark override. SYSTEM follows the device setting. */
|
||||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
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
|
* Clock convention for time-of-day labels. AUTO follows the device's 24-hour
|
||||||
* locale's convention (e.g. Monday in DE, Sunday in en-US).
|
* system setting; the others force a 12- or 24-hour clock app-wide.
|
||||||
|
*/
|
||||||
|
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||||
WeekStartPref.MONDAY -> DayOfWeek.MONDAY
|
is WeekStartPref.Day -> day
|
||||||
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
|
|
||||||
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +80,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
|
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) {
|
suspend fun setThemeMode(mode: ThemeMode) {
|
||||||
@@ -65,7 +92,76 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setWeekStart(pref: WeekStartPref) {
|
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 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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,6 +223,157 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default reminder lead time (minutes before start) prefilled on new
|
||||||
|
* **timed** events. `null` = no default reminder — the prior behaviour, kept
|
||||||
|
* as the factory default so existing users aren't surprised by reminders they
|
||||||
|
* never asked for. Stored as a string so "none" is distinct from a numeric
|
||||||
|
* value (and from an unset key, which is also "none"). Per-calendar overrides
|
||||||
|
* in [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||||
|
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
||||||
|
*/
|
||||||
|
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||||
|
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
||||||
|
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default reminder lead time prefilled on new **all-day** events, in
|
||||||
|
* minutes before the start of the day. All-day events want day-scale lead
|
||||||
|
* times ("1 day before"), so they have their own default rather than reusing
|
||||||
|
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
|
||||||
|
* to all-day events — they always use this global value.
|
||||||
|
*/
|
||||||
|
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||||
|
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||||
|
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock time, as minutes from local midnight, at which **all-day**
|
||||||
|
* reminders fire. All-day events live at UTC midnight, so a raw "1 day
|
||||||
|
* before" would fire at an off hour (02:00 local in CEST); this time is
|
||||||
|
* encoded into the provider offset so the reminder lands at, e.g., 09:00 the
|
||||||
|
* day before instead. Global for every all-day reminder; default 09:00.
|
||||||
|
* Stored/clamped to a valid 0..1439 minute-of-day.
|
||||||
|
*/
|
||||||
|
val allDayReminderTimeMinutes: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[ALLDAY_REMINDER_TIME_KEY] ?: DEFAULT_ALLDAY_REMINDER_TIME)
|
||||||
|
.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||||
|
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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].)
|
||||||
|
*/
|
||||||
|
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
|
current.applyOverride(calendarId, override)
|
||||||
|
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultAllDayReminderMinutes] for **all-day**
|
||||||
|
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
||||||
|
* inherit the global all-day default; present null = no reminder).
|
||||||
|
*/
|
||||||
|
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCalendarAllDayReminderOverride(
|
||||||
|
calendarId: Long,
|
||||||
|
override: CalendarReminderOverride,
|
||||||
|
) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current =
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
|
current.applyOverride(calendarId, override)
|
||||||
|
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -138,15 +385,141 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
|
internal val 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 DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||||
booleanPreferencesKey("allow_color_unsupported_calendars")
|
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||||
|
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||||
|
internal val DEFAULT_ALLDAY_REMINDER_KEY =
|
||||||
|
stringPreferencesKey("default_allday_reminder_minutes")
|
||||||
|
internal val ALLDAY_REMINDER_TIME_KEY =
|
||||||
|
intPreferencesKey("allday_reminder_time_minutes")
|
||||||
|
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
||||||
|
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
internal val 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 =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lead time to prefill on a new event: the matching per-calendar override
|
||||||
|
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||||
|
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
||||||
|
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
|
||||||
|
* it can be unit-tested.
|
||||||
|
*/
|
||||||
|
fun resolveDefaultReminder(
|
||||||
|
timedGlobal: Int?,
|
||||||
|
allDayGlobal: Int?,
|
||||||
|
timedOverrides: Map<Long, Int?>,
|
||||||
|
allDayOverrides: Map<Long, Int?>,
|
||||||
|
calendarId: Long?,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
): Int? {
|
||||||
|
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||||
|
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||||
|
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||||
|
overrides[calendarId]
|
||||||
|
} else {
|
||||||
|
global
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
|
||||||
|
private fun MutableMap<Long, Int?>.applyOverride(
|
||||||
|
calendarId: Long,
|
||||||
|
override: CalendarReminderOverride,
|
||||||
|
) {
|
||||||
|
when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||||
|
CalendarReminderOverride.None -> put(calendarId, null)
|
||||||
|
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 fun String?.toReminderMinutes(): Int? = when (this) {
|
||||||
|
null, "", NONE -> null
|
||||||
|
else -> toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
||||||
|
if (stored.isNullOrBlank()) return emptyMap()
|
||||||
|
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||||
|
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||||
|
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||||
|
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||||
|
id to value
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
||||||
|
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
|
||||||
|
|
||||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class EventReminderReceiver : BroadcastReceiver() {
|
|||||||
if (settingsPrefs.remindersEnabled.first()) {
|
if (settingsPrefs.remindersEnabled.first()) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val due = alertStore.dueAlerts(now)
|
val due = alertStore.dueAlerts(now)
|
||||||
due.forEach(notifier::post)
|
due.forEach { notifier.post(it) }
|
||||||
alertStore.markFired(due.map { it.alertId }, now)
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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_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_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),
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ import androidx.core.content.ContextCompat
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
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.time.ZoneId
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -29,6 +32,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class ReminderNotifier @Inject constructor(
|
class ReminderNotifier @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
/** 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()
|
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun post(alert: ReminderAlert) {
|
suspend fun post(alert: ReminderAlert) {
|
||||||
ensureChannel()
|
ensureChannel()
|
||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
|
val is24Hour = settingsPrefs.timeFormat.first()
|
||||||
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||||
val time = reminderTimeText(
|
val time = reminderTimeText(
|
||||||
beginMillis = alert.beginMillis,
|
beginMillis = alert.beginMillis,
|
||||||
endMillis = alert.endMillis,
|
endMillis = alert.endMillis,
|
||||||
isAllDay = alert.isAllDay,
|
isAllDay = alert.isAllDay,
|
||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
locale = Locale.getDefault(),
|
locale = Locale.getDefault(),
|
||||||
|
is24Hour = is24Hour,
|
||||||
)
|
)
|
||||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
@@ -61,6 +68,16 @@ class ReminderNotifier @Inject constructor(
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setContentIntent(detailIntent(alert))
|
.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()
|
.build()
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(context)
|
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(
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
/* requestCode = */ alert.alertId.toInt(),
|
/* 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
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -25,6 +26,7 @@ fun reminderTimeText(
|
|||||||
isAllDay: Boolean,
|
isAllDay: Boolean,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
is24Hour: Boolean,
|
||||||
): String {
|
): String {
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
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 begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||||
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||||
} else {
|
} else {
|
||||||
val dateTimeFormat = DateTimeFormatter
|
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||||
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||||
.withLocale(locale)
|
// applies to the time portion too.
|
||||||
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
|
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 colorKey: String? = null,
|
||||||
val color: Int? = 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,
|
Availability,
|
||||||
Visibility,
|
Visibility,
|
||||||
Color,
|
Color,
|
||||||
|
Attendees,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class EventFormProblem {
|
enum class EventFormProblem {
|
||||||
@@ -108,6 +130,24 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
// calendar's colour.
|
// calendar's colour.
|
||||||
colorKey = eventColorKey,
|
colorKey = eventColorKey,
|
||||||
color = eventColor,
|
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
|
* while the form was open. The raw row times ride along because
|
||||||
* [toEditForm] derives the form's times from the *tapped occurrence*, so
|
* [toEditForm] derives the form's times from the *tapped occurrence*, so
|
||||||
* re-deriving with the same occurrence would mask an externally moved
|
* re-deriving with the same occurrence would mask an externally moved
|
||||||
* event. Not covered (the form can't write them, and the dirty-checked
|
* event. Guests are covered (the form writes editable attendees), so an
|
||||||
* write can't clobber them): attendees, status, the user's own response,
|
* external attendee change now also trips the conflict check. Still not
|
||||||
* reminder methods, and a recurring event's duration.
|
* covered: status, the user's own response, reminder methods, the
|
||||||
|
* organizer/resource rows, and a recurring event's duration.
|
||||||
*/
|
*/
|
||||||
data class EditSnapshot(
|
data class EditSnapshot(
|
||||||
val form: EventForm,
|
val form: EventForm,
|
||||||
@@ -148,6 +189,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
|||||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||||
if (colorKey != null || color != null) add(EventFormField.Color)
|
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||||
|
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the [IcsEvent] for sharing a single event. We export the event the user
|
||||||
|
* is looking at as a **one-off** VEVENT (no RRULE): the detail screen shows one
|
||||||
|
* occurrence, so "share this event" should hand off exactly that instance, not
|
||||||
|
* a whole series anchored to a possibly-different DTSTART. Reminders are the
|
||||||
|
* already-decoded semantic lead times the detail screen holds.
|
||||||
|
*/
|
||||||
|
fun EventDetail.toShareIcsEvent(): IcsEvent {
|
||||||
|
val startMillis = instance.start.toEpochMilliseconds()
|
||||||
|
return IcsEvent(
|
||||||
|
uid = deriveIcsUid(existingUid = null, eventId = instance.eventId, dtStartMillis = startMillis),
|
||||||
|
summary = instance.title,
|
||||||
|
start = instance.start,
|
||||||
|
end = instance.end,
|
||||||
|
isAllDay = instance.isAllDay,
|
||||||
|
zoneId = eventTimezone?.takeIf { it.isNotBlank() } ?: "UTC",
|
||||||
|
recurrenceRule = null,
|
||||||
|
location = instance.location,
|
||||||
|
description = description,
|
||||||
|
reminderMinutes = reminders.map { it.minutes },
|
||||||
|
status = status,
|
||||||
|
availability = availability,
|
||||||
|
calendarName = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
// Android's calendar provider (and Calendula's own writes) use the non-standard
|
||||||
|
// single-unit forms P<n>S / P<n>D / P<n>W — seconds without the RFC-required
|
||||||
|
// leading T. Matched first; anything else falls through to the general grammar.
|
||||||
|
private val DURATION_SINGLE_UNIT = Regex("""([+-]?)P(\d+)([WDS])""")
|
||||||
|
private val DURATION_GENERAL =
|
||||||
|
Regex("""([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds of a DURATION (`P1D`, `P3600S`, `PT1H30M`, `-PT15M`, `P1W`, …),
|
||||||
|
* sign-aware. Calendula writes `P<days>D` / `P<seconds>S`, but the parser also
|
||||||
|
* accepts the general RFC 5545 grammar so backup `DURATION` rows and foreign
|
||||||
|
* `VALARM` triggers round-trip. Unparseable input is treated as zero.
|
||||||
|
*/
|
||||||
|
fun parseRfc2445DurationMillis(duration: String?): Long {
|
||||||
|
if (duration.isNullOrBlank()) return 0L
|
||||||
|
val s = duration.trim()
|
||||||
|
|
||||||
|
DURATION_SINGLE_UNIT.matchEntire(s)?.let { m ->
|
||||||
|
val unitSeconds = when (m.groupValues[3]) {
|
||||||
|
"W" -> 7L * 24 * 60 * 60
|
||||||
|
"D" -> 24L * 60 * 60
|
||||||
|
else -> 1L // S
|
||||||
|
}
|
||||||
|
return m.signum() * m.groupValues[2].toLong() * unitSeconds * 1_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
val m = DURATION_GENERAL.matchEntire(s) ?: return 0L
|
||||||
|
val weeks = m.groupValues[2].toLongOrNull() ?: 0L
|
||||||
|
val days = m.groupValues[3].toLongOrNull() ?: 0L
|
||||||
|
val hours = m.groupValues[4].toLongOrNull() ?: 0L
|
||||||
|
val minutes = m.groupValues[5].toLongOrNull() ?: 0L
|
||||||
|
val seconds = m.groupValues[6].toLongOrNull() ?: 0L
|
||||||
|
val totalSeconds = ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds)
|
||||||
|
return m.signum() * totalSeconds * 1_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign carried by group 1 (`-` → -1, otherwise +1) of a duration match. */
|
||||||
|
private fun MatchResult.signum(): Long = if (groupValues[1] == "-") -1L else 1L
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single event ready to be serialised to a `VEVENT`, decoupled from the
|
||||||
|
* provider so [IcsWriter] stays pure-Kotlin and JVM-testable. [start]/[end] are
|
||||||
|
* absolute instants; [isAllDay] and [recurrenceRule] decide how they are
|
||||||
|
* rendered (see [IcsWriter]'s timezone rule).
|
||||||
|
*/
|
||||||
|
data class IcsEvent(
|
||||||
|
/** RFC 5545 UID — globally stable across exports (see [deriveIcsUid]). */
|
||||||
|
val uid: String,
|
||||||
|
val summary: String,
|
||||||
|
val start: Instant,
|
||||||
|
/** Exclusive end (for all-day: the next-day midnight, as the provider stores it). */
|
||||||
|
val end: Instant,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
/** IANA zone id (`Events.EVENT_TIMEZONE`); only used for recurring timed events. */
|
||||||
|
val zoneId: String,
|
||||||
|
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
||||||
|
val recurrenceRule: String? = null,
|
||||||
|
val location: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
/** Reminder lead times in minutes before start (raw provider offsets). */
|
||||||
|
val reminderMinutes: List<Int> = emptyList(),
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
/** Source calendar name, emitted as `X-CALENDULA-CALENDAR` so a combined backup can fan back out. */
|
||||||
|
val calendarName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UID to export for a provider event. A row that already carries a UID
|
||||||
|
* (`Events.UID_2445`) keeps it; otherwise we synthesise a **stable** one from
|
||||||
|
* the event id and its DTSTART so the same legacy event yields the same UID
|
||||||
|
* across repeated backups — which keeps a later restore from duplicating it.
|
||||||
|
*/
|
||||||
|
fun deriveIcsUid(existingUid: String?, eventId: Long, dtStartMillis: Long): String =
|
||||||
|
existingUid?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: "$eventId-$dtStartMillis@calendula"
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||||
|
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||||
|
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||||
|
* how the writer encoded them.
|
||||||
|
*/
|
||||||
|
data class ParsedIcsEvent(
|
||||||
|
val uid: String?,
|
||||||
|
val summary: String,
|
||||||
|
val start: Instant,
|
||||||
|
val end: Instant,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val zoneId: String,
|
||||||
|
val recurrenceRule: String? = null,
|
||||||
|
val location: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val reminderMinutes: List<Int> = emptyList(),
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
val calendarName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||||
|
enum class IcsParseWarning {
|
||||||
|
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||||
|
ModifiedOccurrenceSkipped,
|
||||||
|
|
||||||
|
/** A `VEVENT` with no parseable `DTSTART`. */
|
||||||
|
EventWithoutStartSkipped,
|
||||||
|
|
||||||
|
/** `ATTENDEE` rows were present; Calendula doesn't import attendees. */
|
||||||
|
AttendeesIgnored,
|
||||||
|
|
||||||
|
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||||
|
UnknownTimezone,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class IcsParseResult(
|
||||||
|
val events: List<ParsedIcsEvent>,
|
||||||
|
val warnings: Set<IcsParseWarning>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Outcome of a bulk `.ics` import into one calendar. */
|
||||||
|
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||||
|
* JVM-testable. Liberal-in/strict-out: unknown properties are ignored, a single
|
||||||
|
* malformed `VEVENT` is skipped (not fatal), and unsupported constructs
|
||||||
|
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||||
|
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||||
|
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||||
|
*/
|
||||||
|
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||||
|
|
||||||
|
fun parse(text: String): IcsParseResult {
|
||||||
|
val lines = unfoldLines(text)
|
||||||
|
val events = mutableListOf<ParsedIcsEvent>()
|
||||||
|
val warnings = mutableSetOf<IcsParseWarning>()
|
||||||
|
var calendarName: String? = null
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line == null) { i++; continue }
|
||||||
|
when {
|
||||||
|
line.isBegin("VEVENT") -> {
|
||||||
|
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
||||||
|
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
||||||
|
?.let(events::add)
|
||||||
|
i = end + 1
|
||||||
|
}
|
||||||
|
line.isBegin("VTIMEZONE") -> {
|
||||||
|
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||||
|
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||||
|
}
|
||||||
|
line.name == "X-WR-CALNAME" -> {
|
||||||
|
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
else -> i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IcsParseResult(events, warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseVevent(
|
||||||
|
body: List<String>,
|
||||||
|
fileCalendarName: String?,
|
||||||
|
warnings: MutableSet<IcsParseWarning>,
|
||||||
|
): ParsedIcsEvent? {
|
||||||
|
var uid: String? = null
|
||||||
|
var summary = ""
|
||||||
|
var dtStart: IcsDateTime? = null
|
||||||
|
var dtEnd: IcsDateTime? = null
|
||||||
|
var duration: String? = null
|
||||||
|
var rrule: String? = null
|
||||||
|
var location: String? = null
|
||||||
|
var description: String? = null
|
||||||
|
var status = EventStatus.Confirmed
|
||||||
|
var availability = Availability.Busy
|
||||||
|
var calendarName = fileCalendarName
|
||||||
|
val reminders = mutableListOf<Int>()
|
||||||
|
var skipAsOverride = false
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < body.size) {
|
||||||
|
val line = parseContentLine(body[i])
|
||||||
|
if (line == null) { i++; continue }
|
||||||
|
when (line.name) {
|
||||||
|
"BEGIN" -> if (line.value.trim().equals("VALARM", true)) {
|
||||||
|
val end = indexOfEnd(body, i + 1, "VALARM")
|
||||||
|
parseAlarmMinutes(body.subList(i + 1, end))?.let(reminders::add)
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||||
|
"SUMMARY" -> summary = unescapeText(line.value)
|
||||||
|
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||||
|
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||||
|
"DURATION" -> duration = line.value.trim()
|
||||||
|
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||||
|
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||||
|
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||||
|
"STATUS" -> status = mapIcsStatus(line.value)
|
||||||
|
"TRANSP" -> availability =
|
||||||
|
if (line.value.trim().equals("TRANSPARENT", true)) Availability.Free
|
||||||
|
else Availability.Busy
|
||||||
|
"RECURRENCE-ID" -> skipAsOverride = true
|
||||||
|
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||||
|
"X-CALENDULA-CALENDAR" ->
|
||||||
|
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipAsOverride) {
|
||||||
|
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val start = dtStart ?: run {
|
||||||
|
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val end = dtEnd
|
||||||
|
?: duration?.let {
|
||||||
|
start.copy(
|
||||||
|
instant = Instant.fromEpochMilliseconds(
|
||||||
|
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: start
|
||||||
|
return ParsedIcsEvent(
|
||||||
|
uid = uid,
|
||||||
|
summary = summary,
|
||||||
|
start = start.instant,
|
||||||
|
end = end.instant,
|
||||||
|
isAllDay = start.isAllDay,
|
||||||
|
zoneId = start.zoneId,
|
||||||
|
recurrenceRule = rrule,
|
||||||
|
location = location,
|
||||||
|
description = description,
|
||||||
|
reminderMinutes = reminders.distinct(),
|
||||||
|
status = status,
|
||||||
|
availability = availability,
|
||||||
|
calendarName = calendarName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
||||||
|
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||||
|
val trigger = body.asSequence()
|
||||||
|
.mapNotNull { parseContentLine(it) }
|
||||||
|
.firstOrNull { it.name == "TRIGGER" }
|
||||||
|
?: return null
|
||||||
|
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||||
|
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||||
|
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||||
|
// Negative = before start (the normal case) → positive lead minutes.
|
||||||
|
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||||
|
val raw = line.value.trim()
|
||||||
|
val isDate = line.params["VALUE"].equals("DATE", true) ||
|
||||||
|
(raw.length == 8 && !raw.contains('T'))
|
||||||
|
if (isDate) {
|
||||||
|
val date = parseBasicDate(raw) ?: return null
|
||||||
|
return IcsDateTime(date.atStartOfDayIn(TimeZone.UTC), isAllDay = true, zoneId = "UTC")
|
||||||
|
}
|
||||||
|
val isUtc = raw.endsWith("Z")
|
||||||
|
val ldt = parseBasicDateTime(raw.removeSuffix("Z")) ?: return null
|
||||||
|
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
|
||||||
|
|
||||||
|
val tzid = line.params["TZID"]
|
||||||
|
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
|
||||||
|
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
|
||||||
|
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
|
||||||
|
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class IcsDateTime(val instant: Instant, val isAllDay: Boolean, val zoneId: String)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun IcsContentLine.isBegin(component: String) =
|
||||||
|
name == "BEGIN" && value.trim().equals(component, true)
|
||||||
|
|
||||||
|
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||||
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
|
var i = from
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line != null && line.name == "END" &&
|
||||||
|
line.value.trim().equals(component, true)
|
||||||
|
) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return lines.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
|
||||||
|
"TENTATIVE" -> EventStatus.Tentative
|
||||||
|
"CANCELLED" -> EventStatus.Cancelled
|
||||||
|
else -> EventStatus.Confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseBasicDate(s: String): LocalDate? = runCatching {
|
||||||
|
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
|
||||||
|
val date = LocalDate(
|
||||||
|
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
|
||||||
|
val time = LocalTime(
|
||||||
|
s.substring(9, 11).toInt(),
|
||||||
|
s.substring(11, 13).toInt(),
|
||||||
|
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
|
||||||
|
)
|
||||||
|
LocalDateTime(date, time)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level RFC 5545 text mechanics, kept separate from [IcsWriter] so the
|
||||||
|
* escaping and folding rules can be tested in isolation. Pure Kotlin — no
|
||||||
|
* Android, no time handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** iCalendar mandates CRLF line breaks, not the platform separator. */
|
||||||
|
const val ICS_CRLF: String = "\r\n"
|
||||||
|
|
||||||
|
/** RFC 5545 §3.1: content lines SHOULD be folded at 75 octets (excluding the break). */
|
||||||
|
private const val MAX_OCTETS = 75
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a TEXT value (SUMMARY, DESCRIPTION, LOCATION, …) per RFC 5545 §3.3.11:
|
||||||
|
* backslash, semicolon and comma are escaped, newlines become the literal `\n`.
|
||||||
|
* Backslash is handled first so it doesn't double-escape the others' markers.
|
||||||
|
*/
|
||||||
|
fun escapeText(value: String): String = buildString(value.length) {
|
||||||
|
for (ch in value) {
|
||||||
|
when (ch) {
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
';' -> append("\\;")
|
||||||
|
',' -> append("\\,")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> Unit // CR is dropped; a lone CRLF in source text folds to one \n
|
||||||
|
else -> append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold a single content line to ≤75 octets per physical line, inserting
|
||||||
|
* `CRLF + space` between segments (the space is part of the 75-octet budget of
|
||||||
|
* the continuation line, so its content caps at 74). Folding counts UTF-8
|
||||||
|
* octets, never splitting a multi-byte character across a boundary.
|
||||||
|
*/
|
||||||
|
fun foldLine(line: String): String {
|
||||||
|
if (line.toByteArray(Charsets.UTF_8).size <= MAX_OCTETS) return line
|
||||||
|
val out = StringBuilder()
|
||||||
|
var octetsThisLine = 0
|
||||||
|
var first = true
|
||||||
|
var i = 0
|
||||||
|
while (i < line.length) {
|
||||||
|
val cp = line.codePointAt(i)
|
||||||
|
val width = Character.charCount(cp)
|
||||||
|
val piece = line.substring(i, i + width)
|
||||||
|
val pieceOctets = piece.toByteArray(Charsets.UTF_8).size
|
||||||
|
// Continuation lines spend one octet on the leading space.
|
||||||
|
val budget = if (first) MAX_OCTETS else MAX_OCTETS - 1
|
||||||
|
if (octetsThisLine + pieceOctets > budget) {
|
||||||
|
out.append(ICS_CRLF).append(' ')
|
||||||
|
octetsThisLine = 0
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
out.append(piece)
|
||||||
|
octetsThisLine += pieceOctets
|
||||||
|
i += width
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse of [escapeText]: turn `\n`/`\N` into newlines and unescape `\\`, `\;`,
|
||||||
|
* `\,`. A backslash before any other character is dropped, keeping the
|
||||||
|
* character (lenient — foreign files escape liberally).
|
||||||
|
*/
|
||||||
|
fun unescapeText(value: String): String = buildString(value.length) {
|
||||||
|
var i = 0
|
||||||
|
while (i < value.length) {
|
||||||
|
val c = value[i]
|
||||||
|
if (c == '\\' && i + 1 < value.length) {
|
||||||
|
when (val next = value[i + 1]) {
|
||||||
|
'n', 'N' -> append('\n')
|
||||||
|
else -> append(next) // \\, \;, \, and any other escaped char
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
append(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse of [foldLine] across a whole document: split into physical lines on
|
||||||
|
* CRLF/LF/CR, then re-join any line that begins with a single space or tab onto
|
||||||
|
* the previous one (RFC 5545 unfolding). Returns the logical content lines.
|
||||||
|
*/
|
||||||
|
fun unfoldLines(text: String): List<String> {
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
for (physical in text.split("\r\n", "\n", "\r")) {
|
||||||
|
if (physical.isEmpty()) continue
|
||||||
|
val isContinuation = physical[0] == ' ' || physical[0] == '\t'
|
||||||
|
if (isContinuation && out.isNotEmpty()) {
|
||||||
|
out[out.lastIndex] = out.last() + physical.substring(1)
|
||||||
|
} else {
|
||||||
|
out.add(physical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One unfolded content line split into its property name, parameters and value:
|
||||||
|
* `SUMMARY;LANGUAGE=en:Lunch` → name `SUMMARY`, params `{LANGUAGE=en}`, value
|
||||||
|
* `Lunch`. The value is everything after the first colon that isn't inside a
|
||||||
|
* quoted parameter; param keys are upper-cased, quoted param values unquoted.
|
||||||
|
* Returns null for a line with no colon.
|
||||||
|
*/
|
||||||
|
data class IcsContentLine(val name: String, val params: Map<String, String>, val value: String)
|
||||||
|
|
||||||
|
fun parseContentLine(line: String): IcsContentLine? {
|
||||||
|
var inQuote = false
|
||||||
|
var colon = -1
|
||||||
|
for (i in line.indices) {
|
||||||
|
when (line[i]) {
|
||||||
|
'"' -> inQuote = !inQuote
|
||||||
|
':' -> if (!inQuote) { colon = i; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colon < 0) return null
|
||||||
|
val head = splitUnquoted(line.substring(0, colon), ';')
|
||||||
|
val name = head.firstOrNull()?.trim()?.uppercase().orEmpty()
|
||||||
|
if (name.isEmpty()) return null
|
||||||
|
val params = buildMap {
|
||||||
|
for (part in head.drop(1)) {
|
||||||
|
val eq = part.indexOf('=')
|
||||||
|
if (eq > 0) {
|
||||||
|
put(
|
||||||
|
part.substring(0, eq).trim().uppercase(),
|
||||||
|
part.substring(eq + 1).trim().removeSurrounding("\""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IcsContentLine(name, params, line.substring(colon + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split on [delimiter] except where it falls inside a double-quoted run. */
|
||||||
|
private fun splitUnquoted(text: String, delimiter: Char): List<String> {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
val current = StringBuilder()
|
||||||
|
var inQuote = false
|
||||||
|
for (c in text) {
|
||||||
|
when {
|
||||||
|
c == '"' -> { inQuote = !inQuote; current.append(c) }
|
||||||
|
c == delimiter && !inQuote -> { parts.add(current.toString()); current.clear() }
|
||||||
|
else -> current.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.add(current.toString())
|
||||||
|
return parts
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Default `PRODID` advertising the writer that produced the file. */
|
||||||
|
const val ICS_PROD_ID: String = "-//Jean-Luc Makiola//Calendula//EN"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-rolled RFC 5545 serialiser for the subset Calendula models. No iCal
|
||||||
|
* library: we stay on `kotlinx-datetime` and own the output, exactly as
|
||||||
|
* `domain/Recurrence.kt` owns RRULE. Pure and JVM-testable — the caller
|
||||||
|
* supplies [dtStamp] (the export moment) so the writer never reads a clock.
|
||||||
|
*
|
||||||
|
* Timezone rule (see plan 05, decision 1):
|
||||||
|
* - all-day → `VALUE=DATE`, no zone;
|
||||||
|
* - timed one-off → UTC instant with a `Z` suffix (an instant is an instant);
|
||||||
|
* - timed recurring → `TZID`-labelled local wall time, so the series stays
|
||||||
|
* anchored to wall-clock across DST. No `VTIMEZONE` block is emitted; import
|
||||||
|
* resolves the `TZID` against the OS tz database.
|
||||||
|
*/
|
||||||
|
class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||||
|
|
||||||
|
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant): String {
|
||||||
|
val lines = buildList {
|
||||||
|
add("BEGIN:VCALENDAR")
|
||||||
|
add("VERSION:2.0")
|
||||||
|
add("PRODID:$prodId")
|
||||||
|
add("CALSCALE:GREGORIAN")
|
||||||
|
events.forEach { appendEvent(it, dtStamp) }
|
||||||
|
add("END:VCALENDAR")
|
||||||
|
}
|
||||||
|
return lines.joinToString(ICS_CRLF, postfix = ICS_CRLF) { foldLine(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
|
||||||
|
add("BEGIN:VEVENT")
|
||||||
|
add("UID:${event.uid}")
|
||||||
|
add("DTSTAMP:${utcStamp(dtStamp)}")
|
||||||
|
add("SUMMARY:${escapeText(event.summary)}")
|
||||||
|
appendTimes(event)
|
||||||
|
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
||||||
|
event.location?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("LOCATION:${escapeText(it)}") }
|
||||||
|
event.description?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("DESCRIPTION:${escapeText(it)}") }
|
||||||
|
add("STATUS:${statusValue(event.status)}")
|
||||||
|
add("TRANSP:${transpValue(event.availability)}")
|
||||||
|
event.calendarName?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("X-CALENDULA-CALENDAR:${escapeText(it)}") }
|
||||||
|
event.reminderMinutes.filter { it >= 0 }.distinct().forEach { minutes ->
|
||||||
|
appendAlarm(minutes, event.summary)
|
||||||
|
}
|
||||||
|
add("END:VEVENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
|
||||||
|
event.isAllDay -> {
|
||||||
|
add("DTSTART;VALUE=DATE:${utcDate(event.start)}")
|
||||||
|
add("DTEND;VALUE=DATE:${utcDate(event.end)}")
|
||||||
|
}
|
||||||
|
// Recurring: anchor to wall-clock in the event's own zone.
|
||||||
|
event.recurrenceRule?.isNotBlank() == true -> {
|
||||||
|
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull()
|
||||||
|
if (zone != null) {
|
||||||
|
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}")
|
||||||
|
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}")
|
||||||
|
} else {
|
||||||
|
// Unknown zone id → fall back to plain UTC instants.
|
||||||
|
add("DTSTART:${utcStamp(event.start)}")
|
||||||
|
add("DTEND:${utcStamp(event.end)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
add("DTSTART:${utcStamp(event.start)}")
|
||||||
|
add("DTEND:${utcStamp(event.end)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
|
||||||
|
add("BEGIN:VALARM")
|
||||||
|
add("ACTION:DISPLAY")
|
||||||
|
add("DESCRIPTION:${escapeText(summary.ifBlank { "Reminder" })}")
|
||||||
|
add("TRIGGER:${triggerValue(minutes)}")
|
||||||
|
add("END:VALARM")
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun statusValue(status: EventStatus): String = when (status) {
|
||||||
|
EventStatus.Confirmed -> "CONFIRMED"
|
||||||
|
EventStatus.Tentative -> "TENTATIVE"
|
||||||
|
EventStatus.Cancelled -> "CANCELLED"
|
||||||
|
}
|
||||||
|
|
||||||
|
// iCal TRANSP is binary; only an explicitly free event is TRANSPARENT.
|
||||||
|
fun transpValue(availability: Availability): String =
|
||||||
|
if (availability == Availability.Free) "TRANSPARENT" else "OPAQUE"
|
||||||
|
|
||||||
|
// A lead time of 0 fires at start (PT0M); anything positive is "before".
|
||||||
|
fun triggerValue(minutes: Int): String =
|
||||||
|
if (minutes <= 0) "PT0M" else "-PT${minutes}M"
|
||||||
|
|
||||||
|
fun utcStamp(instant: Instant): String =
|
||||||
|
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
|
||||||
|
|
||||||
|
fun localStamp(instant: Instant, zone: TimeZone): String =
|
||||||
|
basic(instant.toLocalDateTime(zone))
|
||||||
|
|
||||||
|
fun utcDate(instant: Instant): String {
|
||||||
|
val dt = instant.toLocalDateTime(TimeZone.UTC)
|
||||||
|
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
|
||||||
|
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill the create form from a single parsed `.ics` event (the "open one
|
||||||
|
* event" path). [calendarId] is left null so the form preselects the last-used
|
||||||
|
* calendar, exactly like a fresh create — the user confirms the target and
|
||||||
|
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||||
|
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||||
|
*/
|
||||||
|
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||||
|
val (start, end) = if (isAllDay) {
|
||||||
|
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
|
} else {
|
||||||
|
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
|
||||||
|
}
|
||||||
|
return EventForm(
|
||||||
|
calendarId = null,
|
||||||
|
title = summary,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
location = location.orEmpty(),
|
||||||
|
description = description.orEmpty(),
|
||||||
|
reminders = reminderMinutes.distinct().sorted(),
|
||||||
|
availability = availability,
|
||||||
|
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -12,18 +14,28 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import 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.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
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.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -32,14 +44,24 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Clock
|
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
|
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||||
* pill in their top bars writes back here via [onSelectView].
|
* pill in their top bars writes back here via [onSelectView].
|
||||||
*
|
*
|
||||||
|
* 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
|
* [requestedDetailKey] is an externally requested occurrence (a tapped
|
||||||
* reminder notification routed through MainActivity): it opens the detail
|
* reminder notification routed through MainActivity): it opens the detail
|
||||||
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
|
* 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
|
@Composable
|
||||||
fun CalendarHost(
|
fun CalendarHost(
|
||||||
@@ -48,15 +70,26 @@ fun CalendarHost(
|
|||||||
onDetailKeyConsumed: () -> Unit = {},
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
widgetNavRequest: WidgetNavRequest? = null,
|
widgetNavRequest: WidgetNavRequest? = null,
|
||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
|
requestedImportUri: android.net.Uri? = null,
|
||||||
|
onImportConsumed: () -> Unit = {},
|
||||||
|
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
// Wait for the persisted default view before seeding the stack, so the app
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
// 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.
|
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||||
pendingDayIso = date.toString()
|
pendingDayIso = date.toString()
|
||||||
view = CalendarView.Day
|
viewStack = viewStack.drillToDay()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||||
@@ -93,6 +126,12 @@ fun CalendarHost(
|
|||||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
val onOpenSettings = { showSettings = true }
|
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
|
// Calendar manager (reached from Settings) — its own overlay so it slides
|
||||||
// over Settings and survives view switches.
|
// over Settings and survives view switches.
|
||||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -121,16 +160,62 @@ fun CalendarHost(
|
|||||||
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||||
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
// An opened/received .ics file. [ImportScreen] parses it and either opens
|
||||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
// the prefilled create form (one event → [importForm]) or its own bulk
|
||||||
|
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||||
|
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
|
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||||
|
LaunchedEffect(requestedImportUri) {
|
||||||
|
if (requestedImportUri != null) {
|
||||||
|
importUri = requestedImportUri
|
||||||
|
onImportConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
LaunchedEffect(widgetNavRequest) {
|
||||||
when (val req = widgetNavRequest) {
|
when (val req = widgetNavRequest) {
|
||||||
is WidgetNavRequest.OpenDate -> {
|
is WidgetNavRequest.OpenDate -> {
|
||||||
|
// Drill the day view in over the widget's view: drop any overlay
|
||||||
|
// that would cover it, so the open doesn't land under Settings/form.
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
createDateIso = null
|
||||||
pendingDayIso = req.dateIso
|
pendingDayIso = req.dateIso
|
||||||
view = CalendarView.Day
|
viewStack = viewBaseStack(defaultView, req.source).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()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
is WidgetNavRequest.Create -> {
|
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()
|
val iso = req.dateIso ?: Clock.System.now()
|
||||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
|
||||||
heldCreateIso = iso
|
heldCreateIso = iso
|
||||||
@@ -145,38 +230,75 @@ fun CalendarHost(
|
|||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
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()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
when (view) {
|
// 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(
|
CalendarView.Week -> WeekScreen(
|
||||||
selectedView = view,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = view,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
selectedView = view,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
selectedView = view,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Prefer the live key; fall back to the held one only while sliding out.
|
// Prefer the live key; fall back to the held one only while sliding out.
|
||||||
val activeKey = detailKey ?: heldKey
|
val activeKey = detailKey ?: heldKey
|
||||||
@@ -254,5 +376,32 @@ fun CalendarHost(
|
|||||||
) {
|
) {
|
||||||
CalendarsScreen(onBack = { showCalendars = false })
|
CalendarsScreen(onBack = { showCalendars = false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import flow for an opened/received .ics file. A single event routes
|
||||||
|
// into the create form (prefilled, for review); many open the picker.
|
||||||
|
importUri?.let { uri ->
|
||||||
|
ImportScreen(
|
||||||
|
uri = uri,
|
||||||
|
onClose = { importUri = null },
|
||||||
|
onOpenSingle = { form ->
|
||||||
|
importUri = null
|
||||||
|
importForm = form
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
importForm?.let { form ->
|
||||||
|
EventEditScreen(
|
||||||
|
initialDateIso = null,
|
||||||
|
initialForm = form,
|
||||||
|
onClose = { importForm = null },
|
||||||
|
onSaved = { importForm = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.Manifest
|
||||||
import android.content.pm.PackageManager
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -20,6 +23,7 @@ import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
|||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RootScreen(
|
fun RootScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -27,6 +31,8 @@ fun RootScreen(
|
|||||||
onDetailKeyConsumed: () -> Unit = {},
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
widgetNavRequest: WidgetNavRequest? = null,
|
widgetNavRequest: WidgetNavRequest? = null,
|
||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
|
requestedImportUri: android.net.Uri? = null,
|
||||||
|
onImportConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -49,19 +55,27 @@ fun RootScreen(
|
|||||||
onDispose { lifecycle.removeObserver(obs) }
|
onDispose { lifecycle.removeObserver(obs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPermission) {
|
// 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.
|
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||||
// Null until DataStore's first emission — render nothing for that
|
// Null until DataStore's first emission — render nothing for that
|
||||||
// frame instead of flashing the wrong screen.
|
// frame instead of flashing the wrong screen.
|
||||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||||
when (onboardingDone) {
|
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||||
|
when (done) {
|
||||||
true -> CalendarHost(
|
true -> CalendarHost(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
requestedDetailKey = requestedDetailKey,
|
requestedDetailKey = requestedDetailKey,
|
||||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||||
widgetNavRequest = widgetNavRequest,
|
widgetNavRequest = widgetNavRequest,
|
||||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = onImportConsumed,
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
@@ -69,6 +83,7 @@ fun RootScreen(
|
|||||||
)
|
)
|
||||||
null -> {}
|
null -> {}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
PermissionScreen(
|
PermissionScreen(
|
||||||
onGranted = { hasPermission = true },
|
onGranted = { hasPermission = true },
|
||||||
@@ -76,3 +91,4 @@ fun RootScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui
|
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.
|
* A navigation a home-screen widget asked the app to perform when launched.
|
||||||
* Parsed from the launch intent in MainActivity and consumed once by
|
* Parsed from the launch intent in MainActivity and consumed once by
|
||||||
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
|
* [CalendarHost]. Every request carries the [source] view of the widget it came
|
||||||
* they are not modelled here).
|
* 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 {
|
sealed interface WidgetNavRequest {
|
||||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
|
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
|
||||||
data class OpenDate(val dateIso: String) : WidgetNavRequest
|
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). */
|
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -34,7 +39,10 @@ import androidx.compose.material3.TopAppBarScrollBehavior
|
|||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -46,6 +54,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
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.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
@@ -56,6 +67,9 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
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.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -75,6 +89,7 @@ fun AgendaScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
@@ -85,11 +100,13 @@ fun AgendaScreen(
|
|||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
var showRangePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val isOnToday = when (val s = state) {
|
val isOnToday = when (val s = state) {
|
||||||
is AgendaUiState.Success -> s.anchor == s.today
|
is AgendaUiState.Success -> s.anchor == s.today
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
val successState = state as? AgendaUiState.Success
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -119,6 +136,7 @@ fun AgendaScreen(
|
|||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -131,18 +149,129 @@ fun AgendaScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.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(
|
AgendaContent(
|
||||||
state = state,
|
state = state,
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.weight(1f)
|
||||||
.fillMaxSize(),
|
.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
|
@Composable
|
||||||
private fun AgendaContent(
|
private fun AgendaContent(
|
||||||
state: AgendaUiState,
|
state: AgendaUiState,
|
||||||
@@ -187,6 +316,7 @@ private fun AgendaList(
|
|||||||
AgendaEventRow(
|
AgendaEventRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
|
modifier = calendarAnimateItem(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -218,11 +348,13 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
|||||||
private fun AgendaEventRow(
|
private fun AgendaEventRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
position: Position,
|
position: Position,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
|
modifier = modifier,
|
||||||
title = title,
|
title = title,
|
||||||
summary = agendaTimeSummary(event),
|
summary = agendaTimeSummary(event),
|
||||||
position = position,
|
position = position,
|
||||||
@@ -247,7 +379,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.EventAvailable,
|
imageVector = Icons.Filled.Coffee,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(48.dp),
|
modifier = Modifier.size(48.dp),
|
||||||
@@ -258,13 +390,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
textAlign = TextAlign.Center,
|
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 +399,7 @@ private fun AgendaTopBar(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -292,6 +418,12 @@ private fun AgendaTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
@@ -324,15 +456,17 @@ private fun agendaTimeSummary(event: EventInstance): String {
|
|||||||
val time = if (event.isAllDay) {
|
val time = if (event.isAllDay) {
|
||||||
stringResource(R.string.event_detail_all_day)
|
stringResource(R.string.event_detail_all_day)
|
||||||
} else {
|
} 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() }
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
return if (location != null) "$time · $location" else time
|
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
|
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 {
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
|
|||||||
@@ -53,5 +53,13 @@ sealed interface AgendaUiState {
|
|||||||
val anchor: LocalDate,
|
val anchor: LocalDate,
|
||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val days: List<AgendaDay>,
|
val days: List<AgendaDay>,
|
||||||
|
/** The range currently in effect — the saved default or a session override. */
|
||||||
|
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
|
) : AgendaUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
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.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -17,10 +19,13 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import java.util.Locale
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
@@ -30,16 +35,24 @@ import kotlin.time.Clock
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AgendaViewModel @Inject constructor(
|
class AgendaViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
|
settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : 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()) }
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
@@ -48,14 +61,32 @@ class AgendaViewModel @Inject constructor(
|
|||||||
private val _anchor = MutableStateFlow(todayDate)
|
private val _anchor = MutableStateFlow(todayDate)
|
||||||
val anchor: StateFlow<LocalDate> = _anchor
|
val anchor: StateFlow<LocalDate> = _anchor
|
||||||
|
|
||||||
val state: StateFlow<AgendaUiState> = _anchor
|
// A transient, in-view override of the saved agenda range. Held in memory
|
||||||
.flatMapLatest { anchor ->
|
// (not persisted), so it survives view switches and rotation within the
|
||||||
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
|
// 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(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
repository.instances(range),
|
repository.instances(window),
|
||||||
) { calendars, instances ->
|
) { calendars, instances ->
|
||||||
buildState(anchor, calendars, instances)
|
buildState(params, calendars, instances)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
@@ -75,16 +106,47 @@ class AgendaViewModel @Inject constructor(
|
|||||||
_anchor.value = date
|
_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(
|
private fun buildState(
|
||||||
anchor: LocalDate,
|
params: AgendaParams,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): AgendaUiState {
|
): AgendaUiState {
|
||||||
if (calendars.isEmpty()) {
|
if (calendars.isEmpty()) {
|
||||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
}
|
}
|
||||||
|
val anchor = params.anchor
|
||||||
val days = groupAgendaDays(anchor, instances, zone)
|
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,12 +4,17 @@ import android.accounts.AccountManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
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.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -25,24 +30,33 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.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.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.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -56,27 +70,44 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.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.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
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.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
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.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
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". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
@@ -94,7 +125,10 @@ fun CalendarsScreen(
|
|||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.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.
|
// 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
|
// [editorSession] bumps on every open so the editor's field state resets for
|
||||||
@@ -129,11 +163,21 @@ fun CalendarsScreen(
|
|||||||
CalendarsList(
|
CalendarsList(
|
||||||
local = calendars.filter { it.isLocal },
|
local = calendars.filter { it.isLocal },
|
||||||
synced = calendars.filterNot { it.isLocal },
|
synced = calendars.filterNot { it.isLocal },
|
||||||
|
disabledIds = disabledIds,
|
||||||
error = error,
|
error = error,
|
||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
|
backupResult = backupResult,
|
||||||
|
onExportBackup = viewModel::exportBackup,
|
||||||
|
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||||
|
autoBackup = autoBackup,
|
||||||
|
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
||||||
|
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
|
||||||
|
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
|
onSetDisabled = viewModel::setDisabled,
|
||||||
|
onSetAccountDisabled = viewModel::setAccountDisabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,14 +186,28 @@ fun CalendarsScreen(
|
|||||||
private fun CalendarsList(
|
private fun CalendarsList(
|
||||||
local: List<CalendarSource>,
|
local: List<CalendarSource>,
|
||||||
synced: List<CalendarSource>,
|
synced: List<CalendarSource>,
|
||||||
|
disabledIds: Set<Long>,
|
||||||
error: Boolean,
|
error: Boolean,
|
||||||
onConsumeError: () -> Unit,
|
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,
|
onBack: () -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
|
onSetDisabled: (Long, Boolean) -> Unit,
|
||||||
|
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
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)
|
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||||
|
|
||||||
LaunchedEffect(error) {
|
LaunchedEffect(error) {
|
||||||
@@ -159,68 +217,195 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||||
|
// to the VM to stream the .ics into.
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri -> uri?.let(onExportBackup) }
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
is BackupResult.Success -> {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
context.resources.getQuantityString(
|
||||||
|
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
onConsumeBackupResult()
|
||||||
|
}
|
||||||
|
BackupResult.Failure -> {
|
||||||
|
snackbarHostState.showSnackbar(backupFailedText)
|
||||||
|
onConsumeBackupResult()
|
||||||
|
}
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.calendars_title),
|
title = stringResource(R.string.calendars_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) {
|
) {
|
||||||
// Local (device-only) calendars — the calendars the app owns. The
|
// What the per-calendar / per-account switches below actually do.
|
||||||
// "Add calendar" entry closes the group as its final row.
|
HintText(stringResource(R.string.calendars_disable_hint))
|
||||||
SectionHeader(stringResource(R.string.calendars_local_header))
|
|
||||||
|
// 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()) {
|
if (local.isEmpty()) {
|
||||||
HintText(stringResource(R.string.calendars_local_empty))
|
HintText(stringResource(R.string.calendars_local_empty))
|
||||||
}
|
} else {
|
||||||
val localCount = local.size + 1
|
|
||||||
local.forEachIndexed { index, calendar ->
|
local.forEachIndexed { index, calendar ->
|
||||||
|
val disabled = calendar.id in disabledIds
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
summary = calendar.description,
|
summary = calendar.description,
|
||||||
position = positionOf(index, localCount),
|
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
dimmed = disabled,
|
||||||
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = {
|
||||||
Icon(
|
EnableSwitch(
|
||||||
Icons.Default.Edit,
|
calendarName = calendar.displayName,
|
||||||
contentDescription = stringResource(R.string.calendars_edit_title),
|
enabled = !disabled,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onEdit(calendar) },
|
onClick = { onEdit(calendar) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup — local calendars have no sync, so a .ics export is their only
|
||||||
|
// safety net. Offered only when there is something to back up.
|
||||||
|
if (local.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||||
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
|
// One connected card: the one-time export on top, then automatic
|
||||||
|
// backup (and its folder/interval rows when on).
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.calendars_add),
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
position = positionOf(local.size, localCount),
|
position = Position.Top,
|
||||||
leading = { AddAvatar() },
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
onClick = onAdd,
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Synced calendars — read-only, grouped by account, each with a
|
// Synced calendars — read-only, grouped by account. Each account is a
|
||||||
// per-account "manage in source app" link.
|
// 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))
|
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||||
HintText(stringResource(R.string.calendars_synced_hint))
|
HintText(stringResource(R.string.calendars_synced_hint))
|
||||||
synced
|
synced
|
||||||
.groupBy { it.accountName.ifBlank { it.accountType } }
|
.groupBy { it.accountName.ifBlank { it.accountType } }
|
||||||
.forEach { (account, cals) ->
|
.forEach { (account, cals) ->
|
||||||
AccountHeader(account = account, accountType = cals.first().accountType)
|
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 ->
|
cals.forEachIndexed { index, calendar ->
|
||||||
|
val disabled = calendar.id in disabledIds
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
position = positionOf(index, cals.size),
|
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
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,
|
if (showInterval) {
|
||||||
leading = { AddAvatar() },
|
BackupIntervalDialog(
|
||||||
onClick = {
|
currentMinutes = autoBackup.intervalMinutes,
|
||||||
runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) }
|
onConfirm = onSetAutoBackupInterval,
|
||||||
},
|
onDismiss = { showInterval = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,10 +428,9 @@ private fun CalendarEditor(
|
|||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
BackHandler(onBack = onClose)
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.predictiveBack(onBack = onClose)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surface),
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -373,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). */
|
/** Tonal field card matching the event editor's design (icon + content). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun EditorCard(
|
private fun EditorCard(
|
||||||
@@ -404,34 +612,180 @@ 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
|
@Composable
|
||||||
private fun AccountHeader(account: String, accountType: String) {
|
private fun CalendarGroup(
|
||||||
val context = LocalContext.current
|
title: String,
|
||||||
Row(
|
expanded: Boolean,
|
||||||
modifier = Modifier
|
bodyHasRows: Boolean,
|
||||||
.fillMaxWidth()
|
headerDisabled: Boolean,
|
||||||
.padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
leading: @Composable () -> Unit,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
manageIcon: ImageVector,
|
||||||
|
manageLabel: String,
|
||||||
|
onManage: () -> Unit,
|
||||||
|
onToggleExpand: () -> Unit,
|
||||||
|
showToggleAll: Boolean,
|
||||||
|
allEnabled: Boolean,
|
||||||
|
onToggleAll: (Boolean) -> Unit,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
Text(
|
GroupedRow(
|
||||||
text = account,
|
title = title,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
position = if (expanded && bodyHasRows) Position.Top else Position.Alone,
|
||||||
fontWeight = FontWeight.SemiBold,
|
dimmed = headerDisabled,
|
||||||
modifier = Modifier.weight(1f),
|
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(),
|
||||||
|
) {
|
||||||
|
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()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
OutlinedButton(onClick = {
|
|
||||||
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
|
|
||||||
}) {
|
|
||||||
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(stringResource(R.string.calendars_manage_in_app))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
/**
|
||||||
|
* 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
|
@Composable
|
||||||
private fun AddAvatar() {
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
@@ -440,7 +794,7 @@ private fun AddAvatar() {
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Add,
|
icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
@@ -468,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
|
* Pick the app to open for managing a synced calendar's account. The account's
|
||||||
* own authenticator package (resolved from [AccountManager], no permission
|
* own authenticator package (resolved from [AccountManager], no permission
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.calendars
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.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.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.time.Clock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +40,11 @@ import javax.inject.Inject
|
|||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CalendarsViewModel @Inject constructor(
|
class CalendarsViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
|
private val icsExporter: IcsExporter,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -40,11 +58,71 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptyList(),
|
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)
|
private val _error = MutableStateFlow(false)
|
||||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
fun consumeError() { _error.value = false }
|
fun consumeError() { _error.value = false }
|
||||||
|
|
||||||
|
private val _backupResult = MutableStateFlow<BackupResult?>(null)
|
||||||
|
val backupResult: StateFlow<BackupResult?> = _backupResult.asStateFlow()
|
||||||
|
|
||||||
|
fun consumeBackupResult() { _backupResult.value = null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise every event of the writable local calendars into the chosen SAF
|
||||||
|
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||||
|
* in [backupResult] for a one-shot message.
|
||||||
|
*/
|
||||||
|
fun exportBackup(uri: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_backupResult.value = try {
|
||||||
|
val count = withContext(io) {
|
||||||
|
val events = repository.exportEvents()
|
||||||
|
icsExporter.writeDocument(
|
||||||
|
uri = uri,
|
||||||
|
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||||
|
)
|
||||||
|
events.size
|
||||||
|
}
|
||||||
|
BackupResult.Success(count)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
BackupResult.Failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun createCalendar(displayName: String, color: Int, description: String?) = write {
|
fun createCalendar(displayName: String, color: Int, description: String?) = write {
|
||||||
repository.createLocalCalendar(displayName, color, description)
|
repository.createLocalCalendar(displayName, color, description)
|
||||||
}
|
}
|
||||||
@@ -57,6 +135,76 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
repository.deleteCalendar(id)
|
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) {
|
private inline fun write(crossinline block: suspend () -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -69,3 +217,17 @@ 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
|
||||||
|
data object Failure : BackupResult
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,56 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
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.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.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.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
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.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.IntOffset
|
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*
|
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||||
@@ -24,17 +66,160 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
|||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
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
|
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||||
* (e.g. "today"); a jump reuses the forward direction.
|
* (e.g. "today"); a jump reuses the forward direction.
|
||||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
* @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(
|
fun calendarSlideTransition(
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
spec: FiniteAnimationSpec<IntOffset>,
|
spec: FiniteAnimationSpec<IntOffset>,
|
||||||
|
fadeSpec: FiniteAnimationSpec<Float>,
|
||||||
|
reduceMotion: Boolean,
|
||||||
): ContentTransform {
|
): ContentTransform {
|
||||||
|
if (reduceMotion) {
|
||||||
|
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||||
|
}
|
||||||
val dir = if (slideDir == 0) 1 else slideDir
|
val dir = if (slideDir == 0) 1 else slideDir
|
||||||
return slideInHorizontally(spec) { w -> dir * w }
|
return slideInHorizontally(spec) { w -> dir * w }
|
||||||
.togetherWith(slideOutHorizontally(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
|
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
||||||
return available[(i + 1) % available.size]
|
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
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
|
||||||
|
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
|
||||||
|
* matches the card/grouped-row design language (not Material's outlined field).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DialogAmountField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
placeholder: String,
|
||||||
|
) {
|
||||||
|
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
|
||||||
|
// so anything lower vanishes.
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
InlineTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = { text ->
|
||||||
|
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
|
||||||
|
},
|
||||||
|
placeholder = placeholder,
|
||||||
|
textStyle = MaterialTheme.typography.titleMedium,
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(72.dp)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
|
||||||
|
@Composable
|
||||||
|
fun DialogUnitDropdown(
|
||||||
|
label: String,
|
||||||
|
entries: List<String>,
|
||||||
|
onPick: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
onClick = { open = true },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(text = label, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
entries.forEachIndexed { index, entry ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(entry) },
|
||||||
|
onClick = {
|
||||||
|
onPick(index)
|
||||||
|
open = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
@@ -10,7 +9,9 @@ import androidx.compose.foundation.layout.ColumnScope
|
|||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -71,11 +72,11 @@ fun CollapsingScaffold(
|
|||||||
actions: @Composable RowScope.() -> Unit = {},
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
val scrollBehavior =
|
val scrollBehavior =
|
||||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.predictiveBack(onBack = onBack)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
@@ -102,7 +103,19 @@ fun CollapsingScaffold(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
|
// Mark the scaffold's system-bar insets as consumed so the
|
||||||
|
// imePadding below adds only the keyboard height beyond them
|
||||||
|
// (max, not sum) — otherwise the nav-bar inset double-counts and
|
||||||
|
// leaves an empty strip above the keyboard.
|
||||||
|
.consumeWindowInsets(innerPadding)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
// Paint the surface across the full area before imePadding carves
|
||||||
|
// into it, so any sliver above the keyboard reads as surface — not
|
||||||
|
// the dialog window's black — during the IME animation.
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
// Shrink the scroll viewport by the keyboard inset so a focused
|
||||||
|
// field (e.g. the custom-reminder amount) can scroll into view.
|
||||||
|
.imePadding()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(top = 8.dp, bottom = 24.dp),
|
.padding(top = 8.dp, bottom = 24.dp),
|
||||||
content = content,
|
content = content,
|
||||||
@@ -114,7 +127,9 @@ fun CollapsingScaffold(
|
|||||||
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
* 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
|
* 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
|
* 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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -124,6 +139,8 @@ fun GroupedRow(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
summary: String? = null,
|
summary: String? = null,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
|
dimmed: Boolean = false,
|
||||||
|
container: Color? = null,
|
||||||
minHeight: Dp = 72.dp,
|
minHeight: Dp = 72.dp,
|
||||||
leading: @Composable (() -> Unit)? = null,
|
leading: @Composable (() -> Unit)? = null,
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
@@ -155,6 +172,16 @@ fun GroupedRow(
|
|||||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
trailingIconColor = 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 {
|
} else {
|
||||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||||
}
|
}
|
||||||
@@ -172,10 +199,10 @@ fun GroupedRow(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.then(gap)
|
.then(gap)
|
||||||
val containerColor = if (selected) {
|
val containerColor = when {
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
} else {
|
container != null -> container
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
else -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
}
|
}
|
||||||
if (onClick != null) {
|
if (onClick != null) {
|
||||||
Surface(
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
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.SolidColor
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.text.TextStyle
|
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.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -36,6 +38,9 @@ fun InlineTextField(
|
|||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
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(
|
val resolvedStyle = textStyle.copy(
|
||||||
color = if (textStyle.color.isSpecified) {
|
color = if (textStyle.color.isSpecified) {
|
||||||
@@ -53,7 +58,11 @@ fun InlineTextField(
|
|||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
capitalization = capitalization,
|
capitalization = capitalization,
|
||||||
|
imeAction = imeAction,
|
||||||
),
|
),
|
||||||
|
keyboardActions = onImeAction?.let { action ->
|
||||||
|
KeyboardActions(onAny = { action() })
|
||||||
|
} ?: KeyboardActions.Default,
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
Box {
|
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
|
||||||
|
private 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,445 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.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.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||||
|
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
|
||||||
|
* picker is visually identical to a Settings sub-page and uses the full width.
|
||||||
|
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FullScreenPicker(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
// The dialog window pans by default when the keyboard opens, which —
|
||||||
|
// combined with the content's own imePadding — leaves a fixed black gap
|
||||||
|
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
||||||
|
// full-screen and imePadding alone lifts the focused field.
|
||||||
|
val view = LocalView.current
|
||||||
|
SideEffect {
|
||||||
|
(view.parent as? DialogWindowProvider)?.window
|
||||||
|
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
|
}
|
||||||
|
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General single-select picker, full-screen: each option is a connected grouped
|
||||||
|
* row and the current one carries a check. Drop-in for the former dialog
|
||||||
|
* (theme, week start, language, …).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> OptionPicker(
|
||||||
|
title: String,
|
||||||
|
options: List<T>,
|
||||||
|
selected: T,
|
||||||
|
label: @Composable (T) -> String,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
header?.invoke(this)
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = label(option),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
||||||
|
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
||||||
|
* a "Custom" row that expands an inline number field plus a segmented unit
|
||||||
|
* selector. Returns the choice as a [CalendarReminderOverride].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderDefaultPicker(
|
||||||
|
title: String,
|
||||||
|
presets: List<Int>,
|
||||||
|
selected: CalendarReminderOverride,
|
||||||
|
allowInherit: Boolean,
|
||||||
|
onSelect: (CalendarReminderOverride) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
|
||||||
|
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
|
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
||||||
|
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
||||||
|
|
||||||
|
val options = buildList {
|
||||||
|
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
||||||
|
add(CalendarReminderOverride.None)
|
||||||
|
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
||||||
|
}
|
||||||
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = reminderOverrideLabel(option),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// When expanded, the Custom row connects downward into the editor card
|
||||||
|
// so the two read as one grouped container (the per-calendar pattern).
|
||||||
|
GroupedRow(
|
||||||
|
title = if (customSelected) {
|
||||||
|
stringResource(
|
||||||
|
R.string.reminder_custom_with_value,
|
||||||
|
reminderLeadTimeLabel(selectedMinutes!!),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.event_edit_reminder_custom)
|
||||||
|
},
|
||||||
|
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
||||||
|
selected = customSelected,
|
||||||
|
trailing = if (customSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { customExpanded = !customExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = customExpanded,
|
||||||
|
enter = calendarExpandEnter(),
|
||||||
|
exit = calendarCollapseExit(),
|
||||||
|
) {
|
||||||
|
CustomReminderEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
unit = unit,
|
||||||
|
onUnitChange = { unit = it },
|
||||||
|
onConfirm = { minutes ->
|
||||||
|
onSelect(CalendarReminderOverride.Minutes(minutes))
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||||
|
* row above it (matching the grouped-row system, so the two read as one
|
||||||
|
* container). An amount field with a live preview of the resulting lead time, a
|
||||||
|
* single-choice unit toggle, and a tonal confirm enabled only for a valid
|
||||||
|
* 1–999 amount. [onConfirm] receives the final lead time in minutes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CustomReminderEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
unit: ReminderUnit,
|
||||||
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
|
onConfirm: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||||
|
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
// Unit toggle first so it stays visible above the keyboard once the
|
||||||
|
// amount field (the bottom row) is focused and scrolled into view.
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ReminderUnit.entries.forEachIndexed { index, entry ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = unit == entry,
|
||||||
|
onClick = { onUnitChange(entry) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
||||||
|
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Amount, a live preview of the lead time it resolves to, and Set —
|
||||||
|
// all on one row, sitting just above the keyboard.
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = onAmountChange,
|
||||||
|
placeholder = "10",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
|
enabled = amount != null,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_custom_set))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
|
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
||||||
|
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
||||||
|
minutes == null -> "" to ReminderUnit.Minutes
|
||||||
|
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
||||||
|
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
||||||
|
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
||||||
|
else -> minutes.toString() to ReminderUnit.Minutes
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||||
|
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
||||||
|
|
||||||
|
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
|
||||||
|
enum class ReminderUnit(val minutesFactor: Int) {
|
||||||
|
Minutes(1),
|
||||||
|
Hours(60),
|
||||||
|
Days(1_440),
|
||||||
|
Weeks(10_080),
|
||||||
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
|
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||||
|
ReminderUnit.Hours -> R.string.reminder_unit_hours
|
||||||
|
ReminderUnit.Days -> R.string.reminder_unit_days
|
||||||
|
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanise a reminder lead time (minutes before the event start) into one
|
||||||
|
* line: "Default reminder" (negative = the provider default), "At time of
|
||||||
|
* event" (0), "10 minutes before", "1 hour before", … Shared by the detail
|
||||||
|
* screen, the event form and the default-reminder settings so the wording
|
||||||
|
* never drifts.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun reminderLeadTimeLabel(minutes: Int): String = when {
|
||||||
|
minutes < 0 -> stringResource(R.string.reminder_default)
|
||||||
|
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
||||||
|
minutes % 10_080 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
||||||
|
minutes % 1_440 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
||||||
|
minutes % 60 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||||
|
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,68 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.text.format.DateFormat
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M3 time picker in an alert dialog, seeded with [initial]. Shared by the event
|
||||||
|
* form (start/end times) and Settings (the all-day reminder fire time).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TimePickerAlert(
|
||||||
|
initial: LocalTime,
|
||||||
|
onConfirm: (LocalTime) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = rememberTimePickerState(
|
||||||
|
initialHour = initial.hour,
|
||||||
|
initialMinute = initial.minute,
|
||||||
|
is24Hour = deviceUses24HourClock(LocalContext.current),
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onConfirm(LocalTime(state.hour, state.minute)) }) {
|
||||||
|
Text(stringResource(R.string.dialog_ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
text = { TimePicker(state = state) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the clock should read 24-hour, matching the rest of the device.
|
||||||
|
*
|
||||||
|
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
|
||||||
|
* the *app's* context locale — and this app applies a per-app language
|
||||||
|
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
|
||||||
|
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
|
||||||
|
* system 12/24 override, and otherwise fall back to the **device** locale
|
||||||
|
* (Resources.getSystem), not the app's.
|
||||||
|
*/
|
||||||
|
private fun deviceUses24HourClock(context: Context): Boolean =
|
||||||
|
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
|
||||||
|
"24" -> true
|
||||||
|
"12" -> false
|
||||||
|
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
|
||||||
|
else -> {
|
||||||
|
val deviceLocale = Resources.getSystem().configuration.locales[0]
|
||||||
|
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
@@ -71,12 +72,20 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
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.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
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 de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -105,6 +114,7 @@ fun DayScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
@@ -188,6 +198,7 @@ fun DayScreen(
|
|||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -233,6 +244,8 @@ private fun DayContent(
|
|||||||
val threshold = with(density) { 24.dp.toPx() }
|
val threshold = with(density) { 24.dp.toPx() }
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
|
||||||
// Hoisted above the per-day AnimatedContent so the vertical scroll position
|
// 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
|
// survives day-to-day swipes. We only centre on noon once, on first entry
|
||||||
@@ -283,7 +296,7 @@ private fun DayContent(
|
|||||||
DayUiState.Loading -> "loading"
|
DayUiState.Loading -> "loading"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
label = "day-transition",
|
label = "day-transition",
|
||||||
) { s ->
|
) { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
@@ -340,6 +353,7 @@ private fun DayTopBar(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -358,6 +372,12 @@ private fun DayTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
@@ -451,6 +471,8 @@ private fun Timeline(
|
|||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||||
@@ -473,7 +495,7 @@ private fun Timeline(
|
|||||||
) {
|
) {
|
||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "%02d".format(h),
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -496,6 +518,7 @@ private fun Timeline(
|
|||||||
blocks = state.timed,
|
blocks = state.timed,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
date = state.date,
|
date = state.date,
|
||||||
|
today = state.today,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -512,11 +535,14 @@ private fun DayColumnCard(
|
|||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||||
|
val showHourLines = LocalShowHourLines.current
|
||||||
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular column — the soft corners come from the outer
|
// Plain rectangular column — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -529,6 +555,9 @@ private fun DayColumnCard(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.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
|
// Tap an empty slot to create an event there. Taps on event
|
||||||
// blocks are consumed by their own click handler first, so this
|
// blocks are consumed by their own click handler first, so this
|
||||||
// only fires on the column background. Snaps to the tapped hour.
|
// only fires on the column background. Snaps to the tapped hour.
|
||||||
@@ -556,6 +585,10 @@ private fun DayColumnCard(
|
|||||||
.padding(horizontal = 1.dp),
|
.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,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val 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
|
val showTime = block.endMin - block.startMin >= 45
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -614,8 +650,8 @@ private fun DayLoading() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun minToHm(min: Int): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatDayTitle(date: LocalDate): String {
|
private fun formatDayTitle(date: LocalDate): String {
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -41,6 +40,7 @@ import androidx.compose.material.icons.filled.Place
|
|||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -59,6 +59,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -67,7 +68,6 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
@@ -91,11 +91,16 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -129,12 +134,31 @@ fun EventDetailScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
||||||
|
// an .ics in the cache and hands back a content Uri for the chooser.
|
||||||
|
val shareFailedMessage = stringResource(R.string.event_share_failed)
|
||||||
|
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||||
|
val onShareClick = {
|
||||||
|
scope.launch {
|
||||||
|
val uri = viewModel.shareUri()
|
||||||
|
val sent = uri != null && runCatching {
|
||||||
|
val send = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/calendar"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(send, shareChooserTitle))
|
||||||
|
}.isSuccess
|
||||||
|
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
// upgrade in place. Granting continues straight into the tapped action.
|
// upgrade in place. Granting continues straight into the tapped action.
|
||||||
var pendingEdit by remember { mutableStateOf(false) }
|
var pendingEdit by remember { mutableStateOf(false) }
|
||||||
@@ -190,6 +214,7 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.predictiveBack(onBack = onBack),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -203,9 +228,18 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// Only writable calendars get actions — WebCal subscriptions,
|
|
||||||
// birthday calendars etc. are read-only at the provider level.
|
|
||||||
val s = state
|
val s = state
|
||||||
|
// Share works for any loaded event — it only reads the event.
|
||||||
|
if (s is EventDetailUiState.Success) {
|
||||||
|
IconButton(onClick = onShareClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Share,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_share),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||||
|
// birthday calendars etc. are read-only at the provider level.
|
||||||
if (s is EventDetailUiState.Success && s.canModify) {
|
if (s is EventDetailUiState.Success && s.canModify) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onEditClick,
|
onClick = onEditClick,
|
||||||
@@ -557,10 +591,22 @@ private fun AttendeeRow(attendee: Attendee) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
val hasName = attendee.name.isNotBlank()
|
||||||
Text(
|
Text(
|
||||||
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
text = if (hasName) attendee.name else attendee.email.orEmpty(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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 ->
|
attendeeRoleLabel(attendee)?.let { roleRes ->
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(roleRes),
|
text = stringResource(roleRes),
|
||||||
@@ -684,26 +730,7 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
|||||||
|
|
||||||
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
|
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderLeadText(reminder: Reminder): String {
|
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||||
val minutes = reminder.minutes
|
|
||||||
return when {
|
|
||||||
minutes < 0 -> stringResource(R.string.reminder_default)
|
|
||||||
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
|
||||||
minutes % 10_080 == 0 -> {
|
|
||||||
val weeks = minutes / 10_080
|
|
||||||
pluralStringResource(R.plurals.reminder_weeks, weeks, weeks)
|
|
||||||
}
|
|
||||||
minutes % 1_440 == 0 -> {
|
|
||||||
val days = minutes / 1_440
|
|
||||||
pluralStringResource(R.plurals.reminder_days, days, days)
|
|
||||||
}
|
|
||||||
minutes % 60 == 0 -> {
|
|
||||||
val hours = minutes / 60
|
|
||||||
pluralStringResource(R.plurals.reminder_hours, hours, hours)
|
|
||||||
}
|
|
||||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
||||||
@@ -756,20 +783,25 @@ private fun formatWhen(
|
|||||||
val zid = ZoneId.of(zone.id)
|
val zid = ZoneId.of(zone.id)
|
||||||
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||||
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale)
|
||||||
|
|
||||||
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
||||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||||
|
|
||||||
if (instance.isAllDay) {
|
if (instance.isAllDay) {
|
||||||
// All-day end is the exclusive next midnight; step back to the last
|
// All-day events live at UTC midnights with an exclusive end. Resolve
|
||||||
// covered day so a one-day event reads as a single date.
|
// the covered dates in UTC — not the device zone, which would shift the
|
||||||
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
|
// midnight boundaries off the intended date (east of UTC pushes the
|
||||||
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
|
// end past the last day; west of UTC pulls the start back) — and step
|
||||||
allDayLabel to dateFull.format(startLdt.toLocalDate())
|
// the end back to the last covered day so a one-day event reads as a
|
||||||
|
// single date.
|
||||||
|
val utc = ZoneId.of("UTC")
|
||||||
|
val startDate = instance.start.toJavaLocalDateTime(utc).toLocalDate()
|
||||||
|
val lastDate = (instance.end - 1.seconds).toJavaLocalDateTime(utc).toLocalDate()
|
||||||
|
return if (startDate == lastDate) {
|
||||||
|
allDayLabel to dateFull.format(startDate)
|
||||||
} else {
|
} else {
|
||||||
allDayLabel to
|
allDayLabel to "${dateMedium.format(startDate)} – ${dateMedium.format(lastDate)}"
|
||||||
"${dateMedium.format(startLdt.toLocalDate())} – ${dateMedium.format(lastLdt.toLocalDate())}"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.detail
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.time.Clock
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -34,6 +40,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class EventDetailViewModel @Inject constructor(
|
class EventDetailViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
|
private val icsExporter: IcsExporter,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -113,6 +120,24 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
_deleteState.value = DeleteUiState.Idle
|
_deleteState.value = DeleteUiState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise the open event to a `.ics` cache file and return a shareable
|
||||||
|
* content Uri (for an ACTION_SEND), or null when nothing is loaded or the
|
||||||
|
* write fails. The event is exported as a one-off (see [toShareIcsEvent]).
|
||||||
|
*/
|
||||||
|
suspend fun shareUri(): Uri? {
|
||||||
|
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
|
||||||
|
return runCatching {
|
||||||
|
withContext(io) {
|
||||||
|
val ics = IcsWriter().writeCalendar(
|
||||||
|
events = listOf(detail.toShareIcsEvent()),
|
||||||
|
dtStamp = Clock.System.now(),
|
||||||
|
)
|
||||||
|
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
val detail = repository.eventDetail(target.eventId)
|
val detail = repository.eventDetail(target.eventId)
|
||||||
// The Events row holds the series start; replace it with this
|
// The Events row holds the series start; replace it with this
|
||||||
@@ -143,3 +168,13 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||||
|
private fun shareFileName(title: String): String {
|
||||||
|
val base = title.trim()
|
||||||
|
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
|
||||||
|
.replace(' ', '_')
|
||||||
|
.take(40)
|
||||||
|
.ifBlank { "event" }
|
||||||
|
return "$base.ics"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.edit
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
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.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
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.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Contacts
|
||||||
import androidx.compose.material.icons.filled.EventAvailable
|
import androidx.compose.material.icons.filled.EventAvailable
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
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.Place
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
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.Tune
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.AssistChip
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -68,10 +70,8 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TimePicker
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -84,14 +84,17 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -100,7 +103,9 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
@@ -109,13 +114,29 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
|
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.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
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.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
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.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
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -152,21 +173,25 @@ fun EventEditScreen(
|
|||||||
onSaved: () -> Unit,
|
onSaved: () -> Unit,
|
||||||
editKey: LongArray? = null,
|
editKey: LongArray? = null,
|
||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
|
initialForm: EventForm? = null,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
if (editKey != null) {
|
when {
|
||||||
viewModel.openForEdit(
|
// Single-event .ics open: the form arrives prefilled for review.
|
||||||
|
initialForm != null -> viewModel.openImported(initialForm)
|
||||||
|
editKey != null -> viewModel.openForEdit(
|
||||||
eventId = editKey[0],
|
eventId = editKey[0],
|
||||||
beginMillis = editKey[1],
|
beginMillis = editKey[1],
|
||||||
endMillis = editKey[2],
|
endMillis = editKey[2],
|
||||||
)
|
)
|
||||||
} else {
|
else -> {
|
||||||
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||||
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
viewModel.openNew(date, initialStartMinutes)
|
viewModel.openNew(date, initialStartMinutes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -177,8 +202,6 @@ fun EventEditScreen(
|
|||||||
viewModel.reset()
|
viewModel.reset()
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
BackHandler(onBack = close)
|
|
||||||
|
|
||||||
// The event vanished between the detail screen and the edit tap — fall
|
// The event vanished between the detail screen and the edit tap — fall
|
||||||
// back to the detail screen, which shows its own failure state.
|
// back to the detail screen, which shows its own failure state.
|
||||||
LaunchedEffect(loadFailed) {
|
LaunchedEffect(loadFailed) {
|
||||||
@@ -228,6 +251,7 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.predictiveBack(onBack = close),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -385,7 +409,6 @@ private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
|
|||||||
* spring open with the expressive spatial spec instead of popping in.
|
* spring open with the expressive spatial spec instead of popping in.
|
||||||
* (Initially-visible sections render without animating.)
|
* (Initially-visible sections render without animating.)
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OptionalFormSection(
|
private fun OptionalFormSection(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
@@ -393,10 +416,8 @@ private fun OptionalFormSection(
|
|||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
|
enter = calendarExpandEnter(),
|
||||||
fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()),
|
exit = calendarCollapseExit(),
|
||||||
exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
|
|
||||||
fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()),
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
||||||
}
|
}
|
||||||
@@ -420,6 +441,37 @@ private fun EventEditContent(
|
|||||||
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showFieldPicker 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 }
|
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
||||||
// The accent ties the form to the detail screen's design language: the
|
// The accent ties the form to the detail screen's design language: the
|
||||||
// bar under the title takes the target calendar's colour.
|
// bar under the title takes the target calendar's colour.
|
||||||
@@ -551,11 +603,39 @@ private fun EventEditContent(
|
|||||||
icon = Icons.Default.Place,
|
icon = Icons.Default.Place,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||||
) {
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
InlineField(
|
InlineField(
|
||||||
value = form.location,
|
value = form.location,
|
||||||
onValueChange = viewModel::setLocation,
|
onValueChange = viewModel::setLocation,
|
||||||
placeholder = stringResource(R.string.event_detail_location),
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,33 +658,24 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
|
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
// Reminders stack one per row (remove on the right edge); the
|
// A grouped list like guests: each reminder is its own tonal card
|
||||||
// add chip closes the card at the bottom. The card icon centres
|
// with connected corners, the last row being "Add reminder".
|
||||||
// on the first row, whatever that is.
|
val reminders = form.reminders
|
||||||
EditCard(
|
val rows = reminders.size + 1
|
||||||
|
reminders.forEachIndexed { index, minutes ->
|
||||||
|
GroupedItemCard(
|
||||||
|
position = positionOf(index, rows),
|
||||||
icon = Icons.Default.Notifications,
|
icon = Icons.Default.Notifications,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_reminders),
|
title = reminderLabel(minutes),
|
||||||
header = {
|
removeContentDescription = stringResource(R.string.event_edit_remove_reminder),
|
||||||
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) },
|
onRemove = { viewModel.removeReminder(minutes) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (form.reminders.isNotEmpty()) {
|
AddActionCard(
|
||||||
Spacer(Modifier.height(4.dp))
|
label = stringResource(R.string.event_edit_add_reminder),
|
||||||
AddReminderChip(onClick = { showReminderPicker = true })
|
position = positionOf(reminders.size, rows),
|
||||||
}
|
onClick = { showReminderPicker = true },
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
|
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
|
||||||
@@ -767,6 +838,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()) {
|
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -831,6 +948,7 @@ private fun EventEditContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (showRecurrencePicker) {
|
if (showRecurrencePicker) {
|
||||||
RecurrencePickerDialog(
|
RecurrencePickerDialog(
|
||||||
current = form.rrule,
|
current = form.rrule,
|
||||||
@@ -902,9 +1020,9 @@ private fun FieldPickerDialog(
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
hiddenFields.forEach { field ->
|
hiddenFields.forEach { field ->
|
||||||
OptionCard(
|
OptionCard(
|
||||||
label = stringResource(fieldLabel(field)),
|
label = stringResource(eventFormFieldLabel(field)),
|
||||||
onClick = { onSelect(field) },
|
onClick = { onSelect(field) },
|
||||||
icon = fieldIcon(field),
|
icon = eventFormFieldIcon(field),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -916,14 +1034,7 @@ private fun FieldPickerDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
||||||
private val REMINDER_QUICK_PICKS = listOf(0, 10, 30, 60, 1_440)
|
private val REMINDER_QUICK_PICKS = REMINDER_PRESETS
|
||||||
|
|
||||||
private enum class ReminderUnit(val minutesFactor: Int) {
|
|
||||||
Minutes(1),
|
|
||||||
Hours(60),
|
|
||||||
Days(1_440),
|
|
||||||
Weeks(10_080),
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder picker, two steps: the common lead times as a tappable list
|
* Reminder picker, two steps: the common lead times as a tappable list
|
||||||
@@ -1245,84 +1356,6 @@ private fun Set<DayOfWeek>.toMask(): Int = fold(0) { acc, day -> acc or day.toMa
|
|||||||
private fun Int.toDaySet(): Set<DayOfWeek> =
|
private fun Int.toDaySet(): Set<DayOfWeek> =
|
||||||
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
|
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
|
||||||
|
|
||||||
/** Tonal 3-digit number input shared by the custom reminder/recurrence steps. */
|
|
||||||
@Composable
|
|
||||||
private fun DialogAmountField(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
placeholder: String,
|
|
||||||
) {
|
|
||||||
// surfaceContainerHighest — the dialog itself sits on
|
|
||||||
// surfaceContainerHigh, so anything lower vanishes.
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
) {
|
|
||||||
InlineField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = { text ->
|
|
||||||
if (text.length <= 3 && text.all(Char::isDigit)) {
|
|
||||||
onValueChange(text)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
placeholder = placeholder,
|
|
||||||
textStyle = MaterialTheme.typography.titleMedium,
|
|
||||||
keyboardType = KeyboardType.Number,
|
|
||||||
modifier = Modifier
|
|
||||||
.width(72.dp)
|
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps. */
|
|
||||||
@Composable
|
|
||||||
private fun DialogUnitDropdown(
|
|
||||||
label: String,
|
|
||||||
entries: List<String>,
|
|
||||||
onPick: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
var open by remember { mutableStateOf(false) }
|
|
||||||
Box {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
onClick = { open = true },
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 14.dp,
|
|
||||||
end = 8.dp,
|
|
||||||
top = 12.dp,
|
|
||||||
bottom = 12.dp,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ArrowDropDown,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
|
||||||
entries.forEachIndexed { index, entry ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(entry) },
|
|
||||||
onClick = {
|
|
||||||
onPick(index)
|
|
||||||
open = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
|
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
|
||||||
RecurrenceFreq.Daily -> R.string.recurrence_daily
|
RecurrenceFreq.Daily -> R.string.recurrence_daily
|
||||||
@@ -1338,72 +1371,274 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
|
|||||||
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */
|
/** 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,
|
||||||
|
)
|
||||||
|
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
|
@Composable
|
||||||
private fun ReminderRow(label: String, onRemove: () -> Unit) {
|
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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Icon(
|
||||||
text = label,
|
imageVector = icon,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
contentDescription = null,
|
||||||
modifier = Modifier.weight(1f),
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
IconButton(onClick = onRemove, modifier = Modifier.size(32.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(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = stringResource(R.string.event_edit_remove_reminder),
|
contentDescription = removeContentDescription,
|
||||||
modifier = Modifier.size(18.dp),
|
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
|
@Composable
|
||||||
private fun AddReminderChip(onClick: () -> Unit) {
|
private fun GuestCard(
|
||||||
AssistChip(
|
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,
|
onClick = onClick,
|
||||||
label = { Text(stringResource(R.string.event_edit_add)) },
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
leadingIcon = {
|
shape = groupedShape(position),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(groupedGap(position)),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Add,
|
imageVector = Icons.Default.Add,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp),
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
},
|
Spacer(Modifier.width(16.dp))
|
||||||
border = null,
|
Text(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
text = label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
|
||||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
|
||||||
ReminderUnit.Hours -> R.string.reminder_unit_hours
|
|
||||||
ReminderUnit.Days -> R.string.reminder_unit_days
|
|
||||||
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
/**
|
||||||
EventFormField.Location -> R.string.event_detail_location
|
* The trailing card of the guests list: an inline email field plus a "from
|
||||||
EventFormField.Description -> R.string.event_detail_description
|
* contacts" button. Type an address and press Done (or tap +) to commit a guest;
|
||||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
* or pick from contacts to fill email + name at once. The field clears after,
|
||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
* ready for the next. Matches the form's inline-field input style — no dialog.
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
*/
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
@Composable
|
||||||
EventFormField.Color -> R.string.event_edit_color
|
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.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
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 fieldIcon(field: EventFormField): ImageVector = when (field) {
|
/**
|
||||||
EventFormField.Location -> Icons.Default.Place
|
* Read the email + display name from a contact-pick result URI. No
|
||||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
||||||
EventFormField.Reminders -> Icons.Default.Notifications
|
*/
|
||||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>? =
|
||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
context.contentResolver.query(
|
||||||
EventFormField.Visibility -> Icons.Default.Lock
|
uri,
|
||||||
EventFormField.Color -> Icons.Default.Palette
|
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() }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Visibility selector: one card per level, each with its own icon; the
|
* Visibility selector: one card per level, each with its own icon; the
|
||||||
* current level is highlighted. Tap picks and closes.
|
* current level is highlighted. Tap picks and closes.
|
||||||
@@ -1517,16 +1752,7 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
|
|||||||
|
|
||||||
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderLabel(minutes: Int): String = when {
|
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
||||||
minutes <= 0 -> stringResource(R.string.reminder_at_time)
|
|
||||||
minutes % 10_080 == 0 ->
|
|
||||||
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
|
||||||
minutes % 1_440 == 0 ->
|
|
||||||
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
|
||||||
minutes % 60 == 0 ->
|
|
||||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
|
||||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One info card mirroring the detail screen's DetailCard: tonal container,
|
* One info card mirroring the detail screen's DetailCard: tonal container,
|
||||||
@@ -1646,8 +1872,9 @@ private fun ScheduleRow(
|
|||||||
val dateFormat = remember(locale) {
|
val dateFormat = remember(locale) {
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
}
|
}
|
||||||
val timeFormat = remember(locale) {
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
val timeFormat = remember(locale, use24Hour) {
|
||||||
|
timeOfDayFormatter(use24Hour, locale)
|
||||||
}
|
}
|
||||||
// Tappable values read as links (primary), like the location on the
|
// Tappable values read as links (primary), like the location on the
|
||||||
// detail screen; errors flip them to the error colour.
|
// detail screen; errors flip them to the error colour.
|
||||||
@@ -1687,31 +1914,6 @@ private fun ScheduleRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun TimePickerAlert(
|
|
||||||
initial: LocalTime,
|
|
||||||
onConfirm: (LocalTime) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val state = rememberTimePickerState(
|
|
||||||
initialHour = initial.hour,
|
|
||||||
initialMinute = initial.minute,
|
|
||||||
)
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { onConfirm(LocalTime(state.hour, state.minute)) }) {
|
|
||||||
Text(stringResource(R.string.dialog_ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
text = { TimePicker(state = state) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CalendarPickerDialog(
|
private fun CalendarPickerDialog(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
@@ -29,6 +31,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
@@ -71,6 +74,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// Set while the form edits an existing event instead of composing a new one.
|
// Set while the form edits an existing event instead of composing a new one.
|
||||||
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
||||||
private val _loadFailed = MutableStateFlow(false)
|
private val _loadFailed = MutableStateFlow(false)
|
||||||
|
// True once the user has hand-edited the reminders on a new event, which
|
||||||
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
|
// their choice. Reset with the form.
|
||||||
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
|
|
||||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
@@ -100,6 +107,13 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val editTarget: EditTarget?,
|
val editTarget: EditTarget?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ReminderDefaults(
|
||||||
|
val timed: Int?,
|
||||||
|
val allDay: Int?,
|
||||||
|
val timedOverrides: Map<Long, Int?>,
|
||||||
|
val allDayOverrides: Map<Long, Int?>,
|
||||||
|
)
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
val lastUsed: Long?,
|
val lastUsed: Long?,
|
||||||
@@ -107,10 +121,18 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val allowColorOnUnsupported: Boolean,
|
val allowColorOnUnsupported: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Writable calendars — the only valid event targets. */
|
/**
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
* Writable calendars — the only valid event targets. Disabled calendars are
|
||||||
.map { calendars -> calendars.filter { it.canModifyContents } }
|
* excluded, so you can't create into a calendar you've removed from the app;
|
||||||
.catch { emit(emptyList()) }
|
* 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. */
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
private val resolvedCalendarId: Flow<Long?> = combine(
|
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||||
@@ -194,6 +216,63 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
|
applyDefaultReminder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
||||||
|
* the create form" path). [form] already carries the file's fields; its
|
||||||
|
* [EventForm.calendarId] is null so the calendar still resolves to the
|
||||||
|
* last-used/first-writable one, and reminders are frozen as touched so the
|
||||||
|
* settings default never overwrites what the file specified. No-op when a
|
||||||
|
* form is already open, so the prefill survives configuration changes.
|
||||||
|
*/
|
||||||
|
fun openImported(form: EventForm) {
|
||||||
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
|
_remindersTouched.value = true
|
||||||
|
_revealed.value = form.populatedFields()
|
||||||
|
_form.value = form
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill a new event's reminders from the settings default — the all-day
|
||||||
|
* default for all-day events, otherwise the resolved calendar's per-calendar
|
||||||
|
* override or the global timed default. No-op while editing an existing event
|
||||||
|
* or once the user has hand-edited the reminders, so the auto-default never
|
||||||
|
* clobbers a manual choice. [calendarId] short-circuits the resolution after a
|
||||||
|
* calendar switch; null resolves it as the form does.
|
||||||
|
*/
|
||||||
|
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||||
|
if (_editTarget.value != null || _remindersTouched.value) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val defaults = combine(
|
||||||
|
settingsPrefs.defaultReminderMinutes,
|
||||||
|
settingsPrefs.defaultAllDayReminderMinutes,
|
||||||
|
settingsPrefs.perCalendarReminderOverride,
|
||||||
|
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { timed, allDay, timedOv, allDayOv ->
|
||||||
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
|
}.first()
|
||||||
|
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||||
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
|
val form = _form.value ?: return@launch
|
||||||
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
|
val default = resolveDefaultReminder(
|
||||||
|
timedGlobal = defaults.timed,
|
||||||
|
allDayGlobal = defaults.allDay,
|
||||||
|
timedOverrides = defaults.timedOverrides,
|
||||||
|
allDayOverrides = defaults.allDayOverrides,
|
||||||
|
calendarId = targetId,
|
||||||
|
isAllDay = form.isAllDay,
|
||||||
|
)
|
||||||
|
val reminders = listOfNotNull(default)
|
||||||
|
_form.value = form.copy(reminders = reminders)
|
||||||
|
// Surface the section so an auto-applied default is visible and
|
||||||
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
|
if (reminders.isNotEmpty()) {
|
||||||
|
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,6 +308,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_revealed.value = emptySet()
|
_revealed.value = emptySet()
|
||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
|
_remindersTouched.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
@@ -239,14 +319,24 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun setTitle(value: String) = update { it.copy(title = value) }
|
fun setTitle(value: String) = update { it.copy(title = value) }
|
||||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) }
|
fun setAllDay(value: Boolean) {
|
||||||
|
update { it.copy(isAllDay = value) }
|
||||||
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
|
applyDefaultReminder()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switching calendars drops any chosen colour: a palette key is
|
* Switching calendars drops any chosen colour: a palette key is
|
||||||
* account-scoped, and a raw colour may be invalid on the new calendar.
|
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||||
* The event falls back to the new calendar's colour until re-picked.
|
* The event falls back to the new calendar's colour until re-picked.
|
||||||
*/
|
*/
|
||||||
fun setCalendar(id: Long) = update { it.copy(calendarId = id, colorKey = null, color = null) }
|
fun setCalendar(id: Long) {
|
||||||
|
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||||
|
// A fresh event re-inherits the new calendar's default reminder unless
|
||||||
|
// the user has already hand-edited it (guarded inside).
|
||||||
|
applyDefaultReminder(id)
|
||||||
|
}
|
||||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||||
|
|
||||||
@@ -262,12 +352,41 @@ class EventEditViewModel @Inject constructor(
|
|||||||
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||||
|
|
||||||
fun addReminder(minutes: Int) = update {
|
fun addReminder(minutes: Int) {
|
||||||
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
|
_remindersTouched.value = true
|
||||||
|
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeReminder(minutes: Int) = update {
|
fun removeReminder(minutes: Int) {
|
||||||
it.copy(reminders = it.reminders - minutes)
|
_remindersTouched.value = true
|
||||||
|
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. */
|
/** Moving the start drags the end along, preserving the duration. */
|
||||||
|
|||||||
@@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
prefs.hiddenCalendarIds,
|
prefs.hiddenCalendarIds,
|
||||||
) { calendars, hidden ->
|
prefs.disabledCalendarIds,
|
||||||
if (calendars.isEmpty()) {
|
) { 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)
|
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
} else {
|
} else {
|
||||||
FilterUiState.Success(groupByAccount(calendars, hidden))
|
FilterUiState.Success(groupByAccount(enabled, hidden))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles an opened/received `.ics` file. A single event is handed straight to
|
||||||
|
* the prefilled create form via [onOpenSingle]; several events show a target-
|
||||||
|
* calendar picker and import in bulk (dedup by UID), then a result summary.
|
||||||
|
* Empty/failed files show a short message and close.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ImportScreen(
|
||||||
|
uri: Uri,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
|
viewModel: ImportViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
LaunchedEffect(uri) { viewModel.load(uri) }
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier
|
||||||
|
.predictiveBack(onBack = onClose)
|
||||||
|
.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.import_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
when (val s = state) {
|
||||||
|
ImportUiState.Loading,
|
||||||
|
ImportUiState.Importing,
|
||||||
|
is ImportUiState.Single,
|
||||||
|
-> CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||||
|
|
||||||
|
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||||
|
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||||
|
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
|
||||||
|
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
|
||||||
|
// No writable calendar to import into — tell the user honestly.
|
||||||
|
if (state.calendars.isEmpty()) {
|
||||||
|
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.import_target_header),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
state.calendars.forEach { calendar ->
|
||||||
|
OptionCard(
|
||||||
|
label = calendar.displayName,
|
||||||
|
onClick = { selected = calendar.id },
|
||||||
|
selected = calendar.id == selected,
|
||||||
|
icon = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.warnings.forEach { WarningText(it) }
|
||||||
|
Button(
|
||||||
|
onClick = { onImport(selected) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.import_done_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_done_imported,
|
||||||
|
state.summary.imported,
|
||||||
|
state.summary.imported,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
if (state.summary.skippedDuplicate > 0) {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_done_skipped,
|
||||||
|
state.summary.skippedDuplicate,
|
||||||
|
state.summary.skippedDuplicate,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
Text(stringResource(R.string.import_close))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WarningText(warning: IcsParseWarning) {
|
||||||
|
val text = when (warning) {
|
||||||
|
IcsParseWarning.ModifiedOccurrenceSkipped -> stringResource(R.string.import_warning_recurrence)
|
||||||
|
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
||||||
|
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
||||||
|
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(message, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
if (onClose != null) {
|
||||||
|
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** What an opened/received `.ics` resolved to. */
|
||||||
|
sealed interface ImportUiState {
|
||||||
|
data object Loading : ImportUiState
|
||||||
|
data object Importing : ImportUiState
|
||||||
|
|
||||||
|
/** The file held no importable event (or couldn't be read/parsed as one). */
|
||||||
|
data object Empty : ImportUiState
|
||||||
|
data object Failed : ImportUiState
|
||||||
|
|
||||||
|
/** Exactly one event → review it in the prefilled create form. */
|
||||||
|
data class Single(val form: EventForm, val warnings: Set<IcsParseWarning>) : ImportUiState
|
||||||
|
|
||||||
|
/** Several events → pick a target calendar and bulk-import. */
|
||||||
|
data class Many(
|
||||||
|
val events: List<ParsedIcsEvent>,
|
||||||
|
val warnings: Set<IcsParseWarning>,
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
) : ImportUiState
|
||||||
|
|
||||||
|
data class Done(val summary: IcsImportSummary) : ImportUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads an `.ics` [Uri], parses it (pure [IcsParser]) and routes by event count:
|
||||||
|
* one event opens the create form for review, many open the bulk-import picker.
|
||||||
|
* The bulk import dedups by UID in the repository.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ImportViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val importer: IcsImporter,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val parser = IcsParser()
|
||||||
|
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
||||||
|
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
|
||||||
|
fun load(uri: Uri) {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val parsed = withContext(io) {
|
||||||
|
importer.readText(uri)?.let(parser::parse)
|
||||||
|
}
|
||||||
|
_state.value = when {
|
||||||
|
parsed == null -> ImportUiState.Failed
|
||||||
|
parsed.events.isEmpty() -> ImportUiState.Empty
|
||||||
|
parsed.events.size == 1 -> ImportUiState.Single(
|
||||||
|
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
||||||
|
warnings = parsed.warnings,
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
// 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
|
||||||
|
fun import(targetCalendarId: Long) {
|
||||||
|
val many = _state.value as? ImportUiState.Many ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = ImportUiState.Importing
|
||||||
|
_state.value = try {
|
||||||
|
ImportUiState.Done(repository.importEvents(targetCalendarId, many.events))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ImportUiState.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -65,6 +66,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
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.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
@@ -86,6 +89,7 @@ fun MonthScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
@@ -161,6 +165,7 @@ fun MonthScreen(
|
|||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -214,6 +219,8 @@ private fun MonthContent(
|
|||||||
val threshold = with(density) { 6.dp.toPx() }
|
val threshold = with(density) { 6.dp.toPx() }
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||||
detectHorizontalDragGestures(
|
detectHorizontalDragGestures(
|
||||||
@@ -240,7 +247,7 @@ private fun MonthContent(
|
|||||||
MonthUiState.Loading -> "loading"
|
MonthUiState.Loading -> "loading"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
label = "month-transition",
|
label = "month-transition",
|
||||||
) { s ->
|
) { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
@@ -261,6 +268,7 @@ private fun MonthTopBar(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -279,6 +287,12 @@ private fun MonthTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,78 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/** UI-facing language choice. AUTO follows the system languages. */
|
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||||
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-app language via AppCompatDelegate. On API 33+ this delegates to the
|
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
|
||||||
* platform per-app-languages API; below that the appcompat backport persists
|
*
|
||||||
* the choice itself (manifest `autoStoreLocales` service), so we don't mirror
|
* That file is the single source of truth for which languages we ship: dropping
|
||||||
* it in DataStore. Setting a locale recreates the activity, which re-reads the
|
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
|
||||||
* current value for the dropdown.
|
* language show up here and in the system per-app-language settings, with no
|
||||||
|
* other code change. The system-default choice is represented as `null`.
|
||||||
|
*
|
||||||
|
* On API 33+ this delegates to the platform per-app-languages API; below that
|
||||||
|
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
|
||||||
|
* service), so we don't mirror it in DataStore. Setting a locale recreates the
|
||||||
|
* activity, which re-reads the current value for the picker.
|
||||||
*/
|
*/
|
||||||
object AppLanguage {
|
object AppLanguage {
|
||||||
|
|
||||||
fun current(): LanguagePref {
|
/**
|
||||||
val locales = AppCompatDelegate.getApplicationLocales()
|
* The BCP-47 tags the app ships translations for, in declaration order, as
|
||||||
if (locales.isEmpty) return LanguagePref.AUTO
|
* listed in locales_config.xml. Returns whatever could be parsed; a missing
|
||||||
return when (locales[0]?.language) {
|
* or malformed config yields an empty list (the picker then offers only the
|
||||||
"de" -> LanguagePref.GERMAN
|
* system-default entry rather than crashing).
|
||||||
"en" -> LanguagePref.ENGLISH
|
*/
|
||||||
else -> LanguagePref.AUTO
|
fun supportedTags(context: Context): List<String> {
|
||||||
|
val tags = mutableListOf<String>()
|
||||||
|
val parser = context.resources.getXml(R.xml.locales_config)
|
||||||
|
try {
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
|
||||||
|
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
|
||||||
}
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Fall back to whatever was parsed before the failure.
|
||||||
|
} finally {
|
||||||
|
parser.close()
|
||||||
|
}
|
||||||
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
fun apply(pref: LanguagePref) {
|
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
||||||
val locales = when (pref) {
|
fun currentTag(): String? {
|
||||||
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList()
|
val locales = AppCompatDelegate.getApplicationLocales()
|
||||||
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de")
|
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
||||||
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en")
|
}
|
||||||
|
|
||||||
|
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
|
||||||
|
fun apply(tag: String?) {
|
||||||
|
val locales = if (tag == null) {
|
||||||
|
LocaleListCompat.getEmptyLocaleList()
|
||||||
|
} else {
|
||||||
|
LocaleListCompat.forLanguageTags(tag)
|
||||||
}
|
}
|
||||||
AppCompatDelegate.setApplicationLocales(locales)
|
AppCompatDelegate.setApplicationLocales(locales)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The autonym for a tag — the language's own name in its own script, e.g.
|
||||||
|
* "Deutsch", "English", "Français" — so users find their language regardless
|
||||||
|
* of the current UI language. Capitalised per the language's own rules.
|
||||||
|
*/
|
||||||
|
fun displayName(tag: String): String {
|
||||||
|
val locale = Locale.forLanguageTag(tag)
|
||||||
|
return locale.getDisplayName(locale)
|
||||||
|
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.StatusBarManager
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import android.text.format.DateFormat
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -30,21 +37,28 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Dashboard
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.material.icons.filled.Gavel
|
import androidx.compose.material.icons.filled.Gavel
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.Translate
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -57,23 +71,52 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
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.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
||||||
@@ -180,19 +223,83 @@ private fun SettingsHub(
|
|||||||
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
||||||
onClick = onManageCalendars,
|
onClick = onManageCalendars,
|
||||||
)
|
)
|
||||||
LanguageRow(position = Position.Bottom)
|
LanguageRow(position = Position.Middle)
|
||||||
|
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
||||||
|
// is API 33+; on older versions the tile is still addable manually from
|
||||||
|
// the QS editor, so the row simply doesn't appear here.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
QuickSettingsTileRow(position = Position.Middle)
|
||||||
|
}
|
||||||
|
ReportProblemRow(position = Position.Bottom)
|
||||||
|
|
||||||
AppVersionText()
|
AppVersionText()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the project's issue tracker to report a problem. If a crash report was
|
||||||
|
* captured (and not yet sent), it surfaces that report first via the same
|
||||||
|
* dialog the next-launch prompt uses; otherwise it opens the issue template
|
||||||
|
* chooser. No data leaves the device until the user submits the issue.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ReportProblemRow(position: Position) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var report by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_report_problem),
|
||||||
|
summary = stringResource(R.string.settings_report_problem_hint),
|
||||||
|
position = position,
|
||||||
|
leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) },
|
||||||
|
onClick = {
|
||||||
|
val pending = CrashReporter.pendingReport(context)
|
||||||
|
if (pending != null) report = pending else openIssueTracker(context)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
report?.let { pending ->
|
||||||
|
CrashReportDialog(
|
||||||
|
report = pending,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(context, pending)
|
||||||
|
CrashReporter.clearReport(context)
|
||||||
|
report = null
|
||||||
|
},
|
||||||
|
onDismiss = { report = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the system to add the "New event" Quick Settings tile (API 33+). A
|
||||||
|
* top-level action row, like [ReportProblemRow] — the tile is a system-surface
|
||||||
|
* shortcut, not part of any one settings category.
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
@Composable
|
||||||
|
private fun QuickSettingsTileRow(position: Position) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_qs_tile),
|
||||||
|
summary = stringResource(R.string.settings_qs_tile_hint),
|
||||||
|
position = position,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Dashboard, ChipAccent.Neutral) },
|
||||||
|
onClick = { requestAddQsTile(context) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LanguageRow(position: Position) {
|
private fun LanguageRow(position: Position) {
|
||||||
|
val context = LocalContext.current
|
||||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||||
// row updates instantly even before the recreation lands.
|
// row updates instantly even before the recreation lands.
|
||||||
var current by remember { mutableStateOf(AppLanguage.current()) }
|
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||||
|
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
|
||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
summary = languageLabel(current),
|
summary = languageLabel(current),
|
||||||
@@ -202,9 +309,9 @@ private fun LanguageRow(position: Position) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (showDialog) {
|
if (showDialog) {
|
||||||
OptionPickerDialog(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
options = LanguagePref.entries,
|
options = options,
|
||||||
selected = current,
|
selected = current,
|
||||||
label = { languageLabel(it) },
|
label = { languageLabel(it) },
|
||||||
onSelect = {
|
onSelect = {
|
||||||
@@ -212,6 +319,18 @@ private fun LanguageRow(position: Position) {
|
|||||||
AppLanguage.apply(it)
|
AppLanguage.apply(it)
|
||||||
},
|
},
|
||||||
onDismiss = { showDialog = false },
|
onDismiss = { showDialog = false },
|
||||||
|
// Invite contributions right where users pick their language.
|
||||||
|
header = {
|
||||||
|
val translateUrl = stringResource(R.string.about_translate_url)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_translate),
|
||||||
|
summary = stringResource(R.string.settings_translate_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
|
||||||
|
onClick = { openUrl(context, translateUrl) },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,6 +340,7 @@ private fun AboutCard() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sourceUrl = stringResource(R.string.about_source_url)
|
val sourceUrl = stringResource(R.string.about_source_url)
|
||||||
val licenseUrl = stringResource(R.string.about_license_url)
|
val licenseUrl = stringResource(R.string.about_license_url)
|
||||||
|
val supportUrl = stringResource(R.string.about_support_url)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
@@ -279,6 +399,19 @@ private fun AboutCard() {
|
|||||||
Text(stringResource(R.string.settings_license))
|
Text(stringResource(R.string.settings_license))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { openUrl(context, supportUrl) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Favorite,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.settings_about_support))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,11 +470,16 @@ private fun AppearanceScreen(
|
|||||||
) {
|
) {
|
||||||
var showTheme by remember { mutableStateOf(false) }
|
var showTheme by remember { mutableStateOf(false) }
|
||||||
var showWeekStart by remember { mutableStateOf(false) }
|
var showWeekStart by remember { mutableStateOf(false) }
|
||||||
|
var showTimeFormat by remember { mutableStateOf(false) }
|
||||||
|
var showDefaultView by remember { mutableStateOf(false) }
|
||||||
|
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||||
|
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
) {
|
) {
|
||||||
|
// Theme & colour
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
summary = themeLabel(state.themeMode),
|
summary = themeLabel(state.themeMode),
|
||||||
@@ -355,7 +493,7 @@ private fun AppearanceScreen(
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.settings_dynamic_color_unavailable)
|
stringResource(R.string.settings_dynamic_color_unavailable)
|
||||||
},
|
},
|
||||||
position = Position.Middle,
|
position = Position.Bottom,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.dynamicColor,
|
checked = state.dynamicColor,
|
||||||
@@ -369,16 +507,72 @@ private fun AppearanceScreen(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Calendar — view, week, and timeline formatting
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_view),
|
||||||
|
summary = stringResource(state.defaultView.labelRes),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showDefaultView = true },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
summary = weekStartLabel(state.weekStart),
|
summary = weekStartLabel(state.weekStart),
|
||||||
position = Position.Bottom,
|
position = Position.Middle,
|
||||||
onClick = { showWeekStart = true },
|
onClick = { showWeekStart = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_time_format),
|
||||||
|
summary = timeFormatLabel(state.timeFormat),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showTimeFormat = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_hour_lines),
|
||||||
|
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.showHourLines,
|
||||||
|
onCheckedChange = viewModel::setShowHourLines,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Agenda
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
summary = agendaRangeLabel(state.agendaScreenRange),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showAgendaScreenRange = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_widget_range),
|
||||||
|
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showAgendaWidgetRange = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_range_bar),
|
||||||
|
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.agendaShowRangeBar,
|
||||||
|
onCheckedChange = viewModel::setAgendaShowRangeBar,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
OptionPickerDialog(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
options = ThemeMode.entries,
|
options = ThemeMode.entries,
|
||||||
selected = state.themeMode,
|
selected = state.themeMode,
|
||||||
@@ -388,15 +582,53 @@ private fun AppearanceScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showWeekStart) {
|
if (showWeekStart) {
|
||||||
OptionPickerDialog(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
options = WeekStartPref.entries,
|
options = WEEK_START_OPTIONS,
|
||||||
selected = state.weekStart,
|
selected = state.weekStart,
|
||||||
label = { weekStartLabel(it) },
|
label = { weekStartLabel(it) },
|
||||||
onSelect = viewModel::setWeekStart,
|
onSelect = viewModel::setWeekStart,
|
||||||
onDismiss = { showWeekStart = false },
|
onDismiss = { showWeekStart = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showAgendaScreenRange) {
|
||||||
|
AgendaRangePicker(
|
||||||
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
description = stringResource(R.string.settings_agenda_range_hint),
|
||||||
|
selected = state.agendaScreenRange,
|
||||||
|
onSelect = viewModel::setAgendaScreenRange,
|
||||||
|
onDismiss = { showAgendaScreenRange = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAgendaWidgetRange) {
|
||||||
|
AgendaRangePicker(
|
||||||
|
title = stringResource(R.string.settings_agenda_widget_range),
|
||||||
|
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
||||||
|
selected = state.agendaWidgetRange,
|
||||||
|
onSelect = viewModel::setAgendaWidgetRange,
|
||||||
|
onDismiss = { showAgendaWidgetRange = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTimeFormat) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_time_format),
|
||||||
|
options = TimeFormatPref.entries,
|
||||||
|
selected = state.timeFormat,
|
||||||
|
label = { timeFormatLabel(it) },
|
||||||
|
onSelect = viewModel::setTimeFormat,
|
||||||
|
onDismiss = { showTimeFormat = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showDefaultView) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_default_view),
|
||||||
|
options = IMPLEMENTED_VIEWS,
|
||||||
|
selected = state.defaultView,
|
||||||
|
label = { stringResource(it.labelRes) },
|
||||||
|
onSelect = viewModel::setDefaultView,
|
||||||
|
onDismiss = { showDefaultView = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -420,8 +652,17 @@ private fun EventFormScreen(
|
|||||||
fields.forEachIndexed { index, field ->
|
fields.forEachIndexed { index, field ->
|
||||||
val checked = field in state.defaultFormFields
|
val checked = field in state.defaultFormFields
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(formFieldLabel(field)),
|
title = stringResource(eventFormFieldLabel(field)),
|
||||||
position = positionOf(index, fields.size),
|
position = positionOf(index, fields.size),
|
||||||
|
// Same icon the field carries in the new-event form, so a toggle
|
||||||
|
// is easy to match to the field it controls.
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = eventFormFieldIcon(field),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = checked,
|
checked = checked,
|
||||||
@@ -455,6 +696,22 @@ private fun EventFormScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
|
||||||
|
* shows its own confirmation dialog and handles the already-added case, so no
|
||||||
|
* result handling is needed here.
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
private fun requestAddQsTile(context: Context) {
|
||||||
|
val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
|
||||||
|
statusBar.requestAddTileService(
|
||||||
|
ComponentName(context, NewEventTileService::class.java),
|
||||||
|
context.getString(R.string.qs_tile_new_event_label),
|
||||||
|
Icon.createWithResource(context, R.drawable.ic_qs_new_event),
|
||||||
|
context.mainExecutor,
|
||||||
|
) { /* result code unused — the system surfaces its own feedback */ }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
|
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
|
||||||
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
|
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
|
||||||
@@ -482,6 +739,14 @@ private fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var showDefaultReminder by remember { mutableStateOf(false) }
|
||||||
|
var showAllDayReminder by remember { mutableStateOf(false) }
|
||||||
|
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||||
|
var showSnooze by remember { mutableStateOf(false) }
|
||||||
|
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||||
|
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||||
|
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_notifications),
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -489,13 +754,325 @@ private fun NotificationsScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_reminders),
|
title = stringResource(R.string.settings_reminders),
|
||||||
summary = stringResource(R.string.settings_reminders_hint),
|
summary = stringResource(R.string.settings_reminders_hint),
|
||||||
position = Position.Alone,
|
position = Position.Top,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
||||||
},
|
},
|
||||||
onClick = { toggleReminders(!state.remindersEnabled) },
|
onClick = { toggleReminders(!state.remindersEnabled) },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
summary = reminderChoiceLabel(state.defaultReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showDefaultReminder = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showAllDayReminder = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_allday_reminder_time),
|
||||||
|
summary = stringResource(
|
||||||
|
R.string.settings_allday_reminder_time_hint,
|
||||||
|
formatTimeOfDay(context, state.allDayReminderTimeMinutes),
|
||||||
|
),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showAllDayReminderTime = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delivery reliability + snooze: both are global reminder-delivery
|
||||||
|
// settings, so they sit with the defaults above rather than below the
|
||||||
|
// long per-calendar list. Reliability is a soft, optional battery-
|
||||||
|
// optimisation exemption (system-settings deep-link, no special
|
||||||
|
// permission); shown as live status, reversible at any time.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
val batteryExempt = rememberBatteryOptimizationExempt()
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_reliable_delivery),
|
||||||
|
summary = if (batteryExempt) {
|
||||||
|
stringResource(R.string.settings_reliable_delivery_exempt)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.settings_reliable_delivery_hint)
|
||||||
|
},
|
||||||
|
position = Position.Top,
|
||||||
|
trailing = if (batteryExempt) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { openBatteryOptimizationSettings(context) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snooze: how long the notification's "Snooze" action defers a reminder.
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
|
summary = snoozeDurationLabel(state.snoozeMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showSnooze = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-calendar overrides: the whole section folds behind one header to
|
||||||
|
// keep the screen tidy. Expanded, each writable calendar gets its own
|
||||||
|
// expandable card that may keep, drop, or replace the global default —
|
||||||
|
// separately for timed and all-day events.
|
||||||
|
if (state.writableCalendars.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_calendar_reminders_title),
|
||||||
|
summary = stringResource(R.string.settings_calendar_reminders_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = calendarSectionExpanded,
|
||||||
|
enter = calendarExpandEnter(),
|
||||||
|
exit = calendarCollapseExit(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
state.writableCalendars.forEach { calendar ->
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
val expanded = calendar.id in expandedCalendars
|
||||||
|
// Calendar card; tapping expands it into a grouped list
|
||||||
|
// of three (the card + the timed and all-day rows).
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
position = if (expanded) Position.Top else Position.Alone,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
expandedCalendars = if (expanded) {
|
||||||
|
expandedCalendars - calendar.id
|
||||||
|
} else {
|
||||||
|
expandedCalendars + calendar.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = expanded,
|
||||||
|
enter = calendarExpandEnter(),
|
||||||
|
exit = calendarCollapseExit(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||||
|
)
|
||||||
|
val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSnooze) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
|
options = SNOOZE_PRESETS,
|
||||||
|
selected = state.snoozeMinutes,
|
||||||
|
label = { snoozeDurationLabel(it) },
|
||||||
|
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||||
|
onDismiss = { showSnooze = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showDefaultReminder) {
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
presets = REMINDER_PRESETS,
|
||||||
|
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||||
|
allowInherit = false,
|
||||||
|
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) },
|
||||||
|
onDismiss = { showDefaultReminder = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAllDayReminder) {
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
presets = ALLDAY_REMINDER_PRESETS,
|
||||||
|
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
||||||
|
allowInherit = false,
|
||||||
|
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) },
|
||||||
|
onDismiss = { showAllDayReminder = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAllDayReminderTime) {
|
||||||
|
TimePickerAlert(
|
||||||
|
initial = LocalTime(
|
||||||
|
state.allDayReminderTimeMinutes / 60,
|
||||||
|
state.allDayReminderTimeMinutes % 60,
|
||||||
|
),
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.setAllDayReminderTimeMinutes(it.hour * 60 + it.minute)
|
||||||
|
showAllDayReminderTime = false
|
||||||
|
},
|
||||||
|
onDismiss = { showAllDayReminderTime = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
overrideDialog?.let { target ->
|
||||||
|
val map = if (target.isAllDay) {
|
||||||
|
state.perCalendarAllDayReminderOverride
|
||||||
|
} else {
|
||||||
|
state.perCalendarReminderOverride
|
||||||
|
}
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(
|
||||||
|
if (target.isAllDay) {
|
||||||
|
R.string.settings_default_reminder_allday
|
||||||
|
} else {
|
||||||
|
R.string.settings_default_reminder
|
||||||
|
},
|
||||||
|
),
|
||||||
|
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
||||||
|
selected = map.choiceFor(target.calendarId),
|
||||||
|
allowInherit = true,
|
||||||
|
onSelect = {
|
||||||
|
if (target.isAllDay) {
|
||||||
|
viewModel.setCalendarAllDayReminderOverride(target.calendarId, it)
|
||||||
|
} else {
|
||||||
|
viewModel.setCalendarReminderOverride(target.calendarId, it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { overrideDialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||||
|
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||||
|
|
||||||
|
/** A global default (null = none) as a picker choice for selection highlighting. */
|
||||||
|
private fun Int?.toReminderChoice(): CalendarReminderOverride =
|
||||||
|
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
||||||
|
|
||||||
|
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||||
|
private fun CalendarReminderOverride.toMinutesOrNull(): Int? =
|
||||||
|
(this as? CalendarReminderOverride.Minutes)?.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||||
|
* `ON_RESUME` so the row reflects a change the user just made in system
|
||||||
|
* settings without needing to leave and re-enter the screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberBatteryOptimizationExempt(): Boolean {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
exempt = isIgnoringBatteryOptimizations(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
return exempt
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||||
|
val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
return power.isIgnoringBatteryOptimizations(context.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the user straight to Calendula's exemption: the direct
|
||||||
|
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog ("Allow Calendula to ignore
|
||||||
|
* battery optimisation?") rather than the full app list they'd have to scroll.
|
||||||
|
* Falls back to the optimisation list if the OS refuses the direct intent.
|
||||||
|
*/
|
||||||
|
private fun openBatteryOptimizationSettings(context: Context) {
|
||||||
|
val direct = Intent(
|
||||||
|
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||||
|
"package:${context.packageName}".toUri(),
|
||||||
|
)
|
||||||
|
if (runCatching { context.startActivity(direct) }.isFailure) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lead times offered for the all-day default — day-scale, since a "minutes
|
||||||
|
* before midnight" reminder on an all-day event is rarely what's wanted.
|
||||||
|
*/
|
||||||
|
private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
|
||||||
|
|
||||||
|
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||||
|
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||||
|
|
||||||
|
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
|
||||||
|
@Composable
|
||||||
|
private fun snoozeDurationLabel(minutes: Int): String =
|
||||||
|
if (minutes % 60 == 0) {
|
||||||
|
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
|
||||||
|
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
||||||
|
val time = Calendar.getInstance().apply {
|
||||||
|
set(Calendar.HOUR_OF_DAY, minutesOfDay / 60)
|
||||||
|
set(Calendar.MINUTE, minutesOfDay % 60)
|
||||||
|
}.time
|
||||||
|
return DateFormat.getTimeFormat(context).format(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
|
||||||
|
private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||||
|
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||||
|
this[calendarId] == null -> CalendarReminderOverride.None
|
||||||
|
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label for a global-default choice: null → "None", else the lead time. */
|
||||||
|
@Composable
|
||||||
|
private fun reminderChoiceLabel(minutes: Int?): String =
|
||||||
|
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes)
|
||||||
|
|
||||||
|
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||||
|
@Composable
|
||||||
|
private fun calendarOverrideSummary(
|
||||||
|
choice: CalendarReminderOverride,
|
||||||
|
globalDefault: Int?,
|
||||||
|
): String = when (choice) {
|
||||||
|
CalendarReminderOverride.Inherit ->
|
||||||
|
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||||
|
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -531,54 +1108,12 @@ private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OptionCard selection dialog — the app's only sanctioned picker style. */
|
|
||||||
@Composable
|
|
||||||
private fun <T> OptionPickerDialog(
|
|
||||||
title: String,
|
|
||||||
options: List<T>,
|
|
||||||
selected: T,
|
|
||||||
label: @Composable (T) -> String,
|
|
||||||
onSelect: (T) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(title) },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
options.forEach { option ->
|
|
||||||
OptionCard(
|
|
||||||
label = label(option),
|
|
||||||
onClick = {
|
|
||||||
onSelect(option)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
selected = option == selected,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openUrl(context: Context, url: String) {
|
private fun openUrl(context: Context, url: String) {
|
||||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
runCatching { context.startActivity(intent) }
|
runCatching { context.startActivity(intent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formFieldLabel(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
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||||
when (mode) {
|
when (mode) {
|
||||||
@@ -588,20 +1123,28 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
|
||||||
|
private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
||||||
|
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
|
private fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
||||||
|
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
|
||||||
|
// Localised full weekday name, so any of the seven days reads naturally
|
||||||
|
// without a per-day string resource.
|
||||||
|
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
|
||||||
|
.getDisplayName(JavaTextStyle.FULL, currentLocale())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
||||||
when (pref) {
|
when (pref) {
|
||||||
WeekStartPref.AUTO -> R.string.settings_week_start_auto
|
TimeFormatPref.AUTO -> R.string.settings_time_format_auto
|
||||||
WeekStartPref.MONDAY -> R.string.settings_week_start_monday
|
TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h
|
||||||
WeekStartPref.SUNDAY -> R.string.settings_week_start_sunday
|
TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun languageLabel(pref: LanguagePref): String = stringResource(
|
private fun languageLabel(tag: String?): String =
|
||||||
when (pref) {
|
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||||
LanguagePref.AUTO -> R.string.settings_language_auto
|
|
||||||
LanguagePref.GERMAN -> R.string.settings_language_german
|
|
||||||
LanguagePref.ENGLISH -> R.string.settings_language_english
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package de.jeanlucmakiola.calendula.ui.settings
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
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
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -15,11 +19,44 @@ data class SettingsUiState(
|
|||||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
val dynamicColorAvailable: Boolean = true,
|
val dynamicColorAvailable: Boolean = true,
|
||||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
||||||
|
/** 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 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"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* The default reminder lead time (minutes) prefilled on new timed events;
|
||||||
|
* null = no default reminder. Per-calendar overrides take precedence.
|
||||||
|
*/
|
||||||
|
val defaultReminderMinutes: Int? = null,
|
||||||
|
/** The default reminder lead time prefilled on new all-day events; null = none. */
|
||||||
|
val defaultAllDayReminderMinutes: Int? = null,
|
||||||
|
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||||
|
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
||||||
|
/** 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
|
||||||
|
* reminder); absent = inherit the global default.
|
||||||
|
*/
|
||||||
|
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||||
|
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||||
|
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||||
|
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||||
|
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||||
/**
|
/**
|
||||||
* Whether the event-colour picker is offered on calendars that publish no
|
* Whether the event-colour picker is offered on calendars that publish no
|
||||||
* colour palette (the colour may then not survive their next sync).
|
* colour palette (the colour may then not survive their next sync).
|
||||||
|
|||||||
@@ -1,31 +1,58 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Build
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
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_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.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
|
repository: CalendarRepository,
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
|
/** Writable calendars — the only ones that take a per-calendar reminder override. */
|
||||||
|
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||||
|
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||||
|
.catch { emit(emptyList()) }
|
||||||
|
|
||||||
val state: StateFlow<SettingsUiState> =
|
val state: StateFlow<SettingsUiState> =
|
||||||
combine(
|
combine(
|
||||||
// combine() only types up to five flows, so the sixth pref folds
|
// combine() types up to five flows, so the prefs split into two
|
||||||
// into the assembled state in an outer combine.
|
// groups that fold together in the outer combine.
|
||||||
combine(
|
combine(
|
||||||
prefs.themeMode,
|
prefs.themeMode,
|
||||||
prefs.dynamicColor,
|
prefs.dynamicColor,
|
||||||
@@ -42,15 +69,85 @@ class SettingsViewModel @Inject constructor(
|
|||||||
remindersEnabled = reminders,
|
remindersEnabled = reminders,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
combine(
|
||||||
prefs.allowColorOnUnsupportedCalendars,
|
prefs.allowColorOnUnsupportedCalendars,
|
||||||
) { base, allowColor ->
|
prefs.defaultReminderMinutes,
|
||||||
base.copy(allowColorOnUnsupportedCalendars = allowColor)
|
prefs.defaultAllDayReminderMinutes,
|
||||||
|
prefs.allDayReminderTimeMinutes,
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
combine(
|
||||||
|
prefs.defaultView,
|
||||||
|
prefs.agendaScreenRange,
|
||||||
|
prefs.agendaWidgetRange,
|
||||||
|
prefs.timeFormat,
|
||||||
|
prefs.showHourLines,
|
||||||
|
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
||||||
|
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
||||||
|
},
|
||||||
|
prefs.agendaShowRangeBar,
|
||||||
|
) { base, defaults, overrides, views, showRangeBar ->
|
||||||
|
base.copy(
|
||||||
|
defaultView = views.defaultView,
|
||||||
|
agendaScreenRange = views.agendaScreenRange,
|
||||||
|
agendaWidgetRange = views.agendaWidgetRange,
|
||||||
|
timeFormat = views.timeFormat,
|
||||||
|
showHourLines = views.showHourLines,
|
||||||
|
agendaShowRangeBar = showRangeBar,
|
||||||
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
|
allDayReminderTimeMinutes = defaults.allDayReminderTime,
|
||||||
|
snoozeMinutes = defaults.snoozeMinutes,
|
||||||
|
perCalendarReminderOverride = overrides.timed,
|
||||||
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
|
writableCalendars = overrides.calendars,
|
||||||
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
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 allDayReminderTime: Int,
|
||||||
|
val snoozeMinutes: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReminderOverrides(
|
||||||
|
val timed: Map<Long, Int?>,
|
||||||
|
val allDay: Map<Long, Int?>,
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ViewSettings(
|
||||||
|
val defaultView: CalendarView,
|
||||||
|
val agendaScreenRange: AgendaRange,
|
||||||
|
val agendaWidgetRange: AgendaRange,
|
||||||
|
val timeFormat: TimeFormatPref,
|
||||||
|
val showHourLines: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
@@ -60,7 +157,52 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setWeekStart(pref: WeekStartPref) {
|
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 setDefaultView(view: CalendarView) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||||
@@ -71,6 +213,30 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDefaultReminderMinutes(minutes: Int?) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||||
|
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSnoozeMinutes(minutes: Int) {
|
||||||
|
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
@@ -76,9 +77,17 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
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.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.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
@@ -113,6 +122,7 @@ fun WeekScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
@@ -193,6 +203,7 @@ fun WeekScreen(
|
|||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -243,6 +254,8 @@ private fun WeekContent(
|
|||||||
val threshold = with(density) { 24.dp.toPx() }
|
val threshold = with(density) { 24.dp.toPx() }
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
|
||||||
// Hoisted above the per-week AnimatedContent so the vertical scroll position
|
// 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
|
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
|
||||||
@@ -296,7 +309,7 @@ private fun WeekContent(
|
|||||||
WeekUiState.Loading -> "loading"
|
WeekUiState.Loading -> "loading"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
label = "week-transition",
|
label = "week-transition",
|
||||||
) { s ->
|
) { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
@@ -351,6 +364,7 @@ private fun WeekTopBar(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -369,6 +383,12 @@ private fun WeekTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
@@ -557,6 +577,8 @@ private fun Timeline(
|
|||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||||
@@ -580,7 +602,7 @@ private fun Timeline(
|
|||||||
) {
|
) {
|
||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "%02d".format(h),
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -610,6 +632,7 @@ private fun Timeline(
|
|||||||
blocks = state.timedByDay[day].orEmpty(),
|
blocks = state.timedByDay[day].orEmpty(),
|
||||||
dark = dark,
|
dark = dark,
|
||||||
date = day,
|
date = day,
|
||||||
|
today = state.today,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -628,11 +651,14 @@ private fun DayColumnCard(
|
|||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||||
|
val showHourLines = LocalShowHourLines.current
|
||||||
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular columns — the soft corners come from the outer
|
// Plain rectangular columns — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -645,6 +671,9 @@ private fun DayColumnCard(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.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
|
// Tap an empty slot to create an event there; taps on event
|
||||||
// blocks are consumed by their own handler first. Snaps to hour.
|
// blocks are consumed by their own handler first. Snaps to hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
@@ -671,6 +700,10 @@ private fun DayColumnCard(
|
|||||||
.padding(horizontal = 1.dp),
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -683,7 +716,10 @@ private fun EventBlock(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val 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
|
val showTime = block.endMin - block.startMin >= 45
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -752,8 +788,8 @@ private fun WeekLoading() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun minToHm(min: Int): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
||||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
private fun formatWeekRange(weekStart: LocalDate): String {
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
|
|||||||
@@ -181,6 +181,18 @@ internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
|||||||
|
|
||||||
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
|
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
|
||||||
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
||||||
|
if (isAllDay) {
|
||||||
|
// All-day events live at UTC midnights with an exclusive end. Compare
|
||||||
|
// calendar dates in UTC and step the exclusive end back to the last
|
||||||
|
// covered day (mirroring the detail/edit views), so a one-day event
|
||||||
|
// covers exactly its single date. Slicing the day in the device zone
|
||||||
|
// would push the exclusive end a few hours into the next local day
|
||||||
|
// east of UTC, making the event leak onto day + 1.
|
||||||
|
val startDate = start.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endExclusive = end.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val lastDay = maxOf(startDate, endExclusive.minus(1, DateTimeUnit.DAY))
|
||||||
|
return day in startDate..lastDay
|
||||||
|
}
|
||||||
val dayStart = day.atStartOfDayIn(zone)
|
val dayStart = day.atStartOfDayIn(zone)
|
||||||
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||||
return start < dayEnd && end > dayStart
|
return start < dayEnd && end > dayStart
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import android.Manifest
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -23,9 +25,6 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/** How far ahead the agenda widget loads (a month of upcoming events). */
|
|
||||||
private const val AGENDA_WIDGET_DAYS = 30
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far either side of today the month widget pre-loads. The displayed month
|
* How far either side of today the month widget pre-loads. The displayed month
|
||||||
* is chosen reactively in the composition, so one wide read covers ~13 months of
|
* is chosen reactively in the composition, so one wide read covers ~13 months of
|
||||||
@@ -46,7 +45,22 @@ internal fun Context.hasCalendarPermission(): Boolean =
|
|||||||
sealed interface AgendaWidgetData {
|
sealed interface AgendaWidgetData {
|
||||||
/** Calendar permission not granted — the widget can't read events. */
|
/** Calendar permission not granted — the widget can't read events. */
|
||||||
data object NeedsPermission : AgendaWidgetData
|
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.
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
) : AgendaWidgetData
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,9 +105,24 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
val repo = widgetEntryPoint().calendarRepository()
|
val ep = widgetEntryPoint()
|
||||||
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
|
val prefs = ep.settingsPrefs()
|
||||||
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
|
val savedRange = prefs.agendaWidgetRange.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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
/** 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.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.GlanceModifier
|
import androidx.glance.GlanceModifier
|
||||||
@@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items
|
|||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.appwidget.updateAll
|
import androidx.glance.appwidget.updateAll
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
|
import androidx.glance.currentState
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
import androidx.glance.layout.Box
|
import androidx.glance.layout.Box
|
||||||
import androidx.glance.layout.Column
|
import androidx.glance.layout.Column
|
||||||
@@ -41,6 +44,11 @@ import androidx.glance.text.TextStyle
|
|||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.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.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
@@ -61,8 +69,20 @@ import java.util.Locale
|
|||||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
* 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")
|
||||||
|
|
||||||
class AgendaWidget : GlanceAppWidget() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val data = context.loadAgendaWidgetData()
|
val data = context.loadAgendaWidgetData()
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
@@ -100,12 +120,21 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
Spacer(GlanceModifier.height(4.dp))
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
when (data) {
|
when (data) {
|
||||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
||||||
is AgendaWidgetData.Ready ->
|
is AgendaWidgetData.Ready -> {
|
||||||
if (data.days.isEmpty()) {
|
// 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 rangeEnd = data.today.plus(
|
||||||
|
range.dayCount(data.today, data.weekStart) - 1,
|
||||||
|
DateTimeUnit.DAY,
|
||||||
|
)
|
||||||
|
val visibleDays = data.days.filter { it.date <= rangeEnd }
|
||||||
|
if (visibleDays.isEmpty()) {
|
||||||
WidgetMessage(R.string.agenda_empty_title)
|
WidgetMessage(R.string.agenda_empty_title)
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
data.days.forEach { day ->
|
visibleDays.forEach { day ->
|
||||||
add(AgendaRow.Header(day.date, data.today))
|
add(AgendaRow.Header(day.date, data.today))
|
||||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||||
}
|
}
|
||||||
@@ -114,7 +143,8 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
items(rows.size) { index ->
|
items(rows.size) { index ->
|
||||||
when (val row = rows[index]) {
|
when (val row = rows[index]) {
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||||
is AgendaRow.Event -> EventRow(row.event, dark)
|
is AgendaRow.Event -> EventRow(row.event, dark, data.is24Hour)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,12 +215,16 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
|||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
.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
|
@Composable
|
||||||
private fun EventRow(event: EventInstance, dark: Boolean) {
|
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
Row(
|
Row(
|
||||||
@@ -199,11 +233,12 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
|
|||||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.eventDetailIntent(
|
MainActivity.openEventIntent(
|
||||||
context = context,
|
context = context,
|
||||||
eventId = event.eventId,
|
eventId = event.eventId,
|
||||||
beginMillis = event.start.toEpochMilliseconds(),
|
beginMillis = event.start.toEpochMilliseconds(),
|
||||||
endMillis = event.end.toEpochMilliseconds(),
|
endMillis = event.end.toEpochMilliseconds(),
|
||||||
|
source = CalendarView.Agenda,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -224,7 +259,7 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
|
|||||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event),
|
text = eventTimeSummary(context, event, is24Hour),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||||
)
|
)
|
||||||
@@ -263,17 +298,17 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
|||||||
return if (relative != null) "$relative · $formatted" else formatted
|
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) {
|
val time = if (event.isAllDay) {
|
||||||
context.getString(R.string.event_detail_all_day)
|
context.getString(R.string.event_detail_all_day)
|
||||||
} else {
|
} else {
|
||||||
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
"${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}"
|
||||||
}
|
}
|
||||||
val location = event.location?.takeIf { it.isNotBlank() }
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
return if (location != null) "$time · $location" else time
|
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
|
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.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
@@ -209,7 +210,9 @@ private fun MonthHeader(label: String) {
|
|||||||
resId = R.drawable.ic_widget_today,
|
resId = R.drawable.ic_widget_today,
|
||||||
contentDescription = context.getString(R.string.widget_today),
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
onClick = GlanceModifier.clickable(
|
onClick = GlanceModifier.clickable(
|
||||||
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
|
actionStartActivity(
|
||||||
|
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
HeaderIcon(
|
HeaderIcon(
|
||||||
@@ -300,8 +303,16 @@ private fun WeekRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
Box(
|
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,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -353,7 +364,24 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||||
val context = LocalContext.current
|
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(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.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>
|
||||||
@@ -47,6 +47,9 @@
|
|||||||
<string name="event_detail_back">Zurück</string>
|
<string name="event_detail_back">Zurück</string>
|
||||||
<string name="event_detail_edit">Bearbeiten</string>
|
<string name="event_detail_edit">Bearbeiten</string>
|
||||||
<string name="event_detail_delete">Löschen</string>
|
<string name="event_detail_delete">Löschen</string>
|
||||||
|
<string name="event_detail_share">Teilen</string>
|
||||||
|
<string name="event_share_chooser_title">Termin teilen</string>
|
||||||
|
<string name="event_share_failed">Termin konnte nicht geteilt werden.</string>
|
||||||
<string name="event_delete_title">Termin löschen?</string>
|
<string name="event_delete_title">Termin löschen?</string>
|
||||||
<string name="event_delete_body">Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt.</string>
|
<string name="event_delete_body">Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt.</string>
|
||||||
<string name="event_delete_recurring_title">Wiederkehrenden Termin löschen</string>
|
<string name="event_delete_recurring_title">Wiederkehrenden Termin löschen</string>
|
||||||
@@ -74,6 +77,16 @@
|
|||||||
<string name="event_edit_add">Hinzufügen</string>
|
<string name="event_edit_add">Hinzufügen</string>
|
||||||
<string name="event_edit_add_reminder">Erinnerung 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_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="event_edit_reminder_custom">Benutzerdefiniert</string>
|
||||||
<string name="reminder_unit_minutes">Minuten</string>
|
<string name="reminder_unit_minutes">Minuten</string>
|
||||||
<string name="reminder_unit_hours">Stunden</string>
|
<string name="reminder_unit_hours">Stunden</string>
|
||||||
@@ -174,6 +187,15 @@
|
|||||||
<item quantity="one">%d Woche vorher</item>
|
<item quantity="one">%d Woche vorher</item>
|
||||||
<item quantity="other">%d Wochen vorher</item>
|
<item quantity="other">%d Wochen vorher</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
@@ -191,6 +213,8 @@
|
|||||||
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
|
<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_enable_button">Erinnerungen einschalten</string>
|
||||||
<string name="reminder_onboarding_skip_button">Später</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) -->
|
<!-- View-Switcher (M1) -->
|
||||||
<string name="view_month">Monat</string>
|
<string name="view_month">Monat</string>
|
||||||
@@ -206,8 +230,15 @@
|
|||||||
<string name="agenda_today_action">Heute</string>
|
<string name="agenda_today_action">Heute</string>
|
||||||
<string name="agenda_header_today">Heute</string>
|
<string name="agenda_header_today">Heute</string>
|
||||||
<string name="agenda_header_tomorrow">Morgen</string>
|
<string name="agenda_header_tomorrow">Morgen</string>
|
||||||
<string name="agenda_empty_title">Nichts geplant</string>
|
<string name="agenda_empty_title">Alles erledigt</string>
|
||||||
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</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 -->
|
<!-- Startbildschirm-Widgets -->
|
||||||
<string name="widget_agenda_title">Anstehend</string>
|
<string name="widget_agenda_title">Anstehend</string>
|
||||||
@@ -224,6 +255,11 @@
|
|||||||
<string name="shortcut_new_event_short">Neuer Termin</string>
|
<string name="shortcut_new_event_short">Neuer Termin</string>
|
||||||
<string name="shortcut_new_event_long">Neuen Termin erstellen</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) -->
|
<!-- Kalender-Filter (M3) -->
|
||||||
<string name="filter_title">Kalender</string>
|
<string name="filter_title">Kalender</string>
|
||||||
|
|
||||||
@@ -239,8 +275,31 @@
|
|||||||
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
|
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
|
||||||
<string name="settings_week_start">Wochenstart</string>
|
<string name="settings_week_start">Wochenstart</string>
|
||||||
<string name="settings_week_start_auto">Automatisch</string>
|
<string name="settings_week_start_auto">Automatisch</string>
|
||||||
<string name="settings_week_start_monday">Montag</string>
|
<string name="settings_time_format">Zeitformat</string>
|
||||||
<string name="settings_week_start_sunday">Sonntag</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_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_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||||
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
||||||
@@ -248,14 +307,27 @@
|
|||||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||||
<string name="settings_reminders">Termin-Erinnerungen</string>
|
<string name="settings_reminders">Termin-Erinnerungen</string>
|
||||||
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
||||||
|
<string name="settings_default_reminder">Standard-Erinnerung</string>
|
||||||
|
<string name="settings_default_reminder_allday">Ganztägige Termine</string>
|
||||||
|
<string name="settings_allday_reminder_time">Uhrzeit für ganztägige Erinnerungen</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">Erinnerungen für ganztägige Termine werden um %1$s ausgelöst</string>
|
||||||
|
<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_section_calendars">Kalender</string>
|
||||||
<string name="settings_manage_calendars">Kalender verwalten</string>
|
<string name="settings_manage_calendars">Kalender verwalten</string>
|
||||||
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
||||||
<string name="settings_section_language">Sprache</string>
|
<string name="settings_section_language">Sprache</string>
|
||||||
<string name="settings_language">App-Sprache</string>
|
<string name="settings_language">App-Sprache</string>
|
||||||
<string name="settings_language_auto">Systemstandard</string>
|
<string name="settings_language_auto">Systemstandard</string>
|
||||||
<string name="settings_language_german">Deutsch</string>
|
|
||||||
<string name="settings_language_english">English</string>
|
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
|
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
|
||||||
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||||
@@ -265,8 +337,11 @@
|
|||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
<string name="settings_about_author">von Jean-Luc Makiola</string>
|
<string name="settings_about_author">von Jean-Luc Makiola</string>
|
||||||
<string name="settings_about_source">Quellcode</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_version">Version %1$s</string>
|
||||||
<string name="settings_about_logo_desc">Calendula-App-Symbol</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 -->
|
<!-- Calendar manager -->
|
||||||
<string name="calendars_title">Kalender</string>
|
<string name="calendars_title">Kalender</string>
|
||||||
@@ -285,4 +360,53 @@
|
|||||||
<string name="calendars_delete_confirm_title">Kalender löschen?</string>
|
<string name="calendars_delete_confirm_title">Kalender löschen?</string>
|
||||||
<string name="calendars_delete_confirm_message">\"%1$s\" und alle zugehörigen Termine werden dauerhaft von diesem Gerät entfernt.</string>
|
<string name="calendars_delete_confirm_message">\"%1$s\" und alle zugehörigen Termine werden dauerhaft von diesem Gerät entfernt.</string>
|
||||||
<string name="calendars_write_error">Änderung konnte nicht gespeichert werden.</string>
|
<string name="calendars_write_error">Änderung konnte nicht gespeichert werden.</string>
|
||||||
|
<!-- Backup (whole-calendar .ics export) -->
|
||||||
|
<string name="calendars_backup_header">Sicherung</string>
|
||||||
|
<string name="calendars_backup_hint">Lokale Kalender werden nirgends synchronisiert – exportiere sie als .ics-Datei, um eine Kopie zu behalten.</string>
|
||||||
|
<string name="calendars_backup_action">Als .ics-Datei exportieren</string>
|
||||||
|
<string name="calendars_backup_failed">Sicherung konnte nicht exportiert werden.</string>
|
||||||
|
<plurals name="calendars_backup_done">
|
||||||
|
<item quantity="one">%d Termin exportiert.</item>
|
||||||
|
<item quantity="other">%d Termine exportiert.</item>
|
||||||
|
</plurals>
|
||||||
|
<!-- Import (.ics) -->
|
||||||
|
<string name="import_title">Termine importieren</string>
|
||||||
|
<string name="import_target_header">Zu Kalender hinzufügen</string>
|
||||||
|
<string name="import_empty">In dieser Datei wurden keine Termine gefunden.</string>
|
||||||
|
<string name="import_failed">Datei konnte nicht gelesen werden.</string>
|
||||||
|
<string name="import_no_calendar">Kein beschreibbarer Kalender zum Importieren. Lege zuerst einen lokalen Kalender an.</string>
|
||||||
|
<string name="import_done_title">Import abgeschlossen</string>
|
||||||
|
<string name="import_close">Schließen</string>
|
||||||
|
<string name="import_warning_recurrence">Einige geänderte Einzeltermine wiederkehrender Termine wurden übersprungen.</string>
|
||||||
|
<string name="import_warning_no_start">Ein Termin ohne Startzeit wurde übersprungen.</string>
|
||||||
|
<string name="import_warning_attendees">Gästelisten wurden nicht importiert.</string>
|
||||||
|
<string name="import_warning_timezone">Eine unbekannte Zeitzone wurde durch die deines Geräts ersetzt.</string>
|
||||||
|
<plurals name="import_event_count">
|
||||||
|
<item quantity="one">%d Termin in dieser Datei.</item>
|
||||||
|
<item quantity="other">%d Termine in dieser Datei.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_action">
|
||||||
|
<item quantity="one">%d Termin importieren</item>
|
||||||
|
<item quantity="other">%d Termine importieren</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_imported">
|
||||||
|
<item quantity="one">%d Termin importiert.</item>
|
||||||
|
<item quantity="other">%d Termine importiert.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_skipped">
|
||||||
|
<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>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
6
app/src/main/res/values-night/colors.xml
Normal file
6
app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<resources>
|
||||||
|
<!-- Dark-scheme window backdrop, matching the Compose dark background/surface
|
||||||
|
(#101316) so activity recreation (e.g. language switch) doesn't flash a
|
||||||
|
lighter grey. See values/colors.xml. -->
|
||||||
|
<color name="window_background">#FF101316</color>
|
||||||
|
</resources>
|
||||||
@@ -3,4 +3,8 @@
|
|||||||
<color name="seed">#FF5C6B7A</color>
|
<color name="seed">#FF5C6B7A</color>
|
||||||
<!-- Adaptive icon background -->
|
<!-- Adaptive icon background -->
|
||||||
<color name="ic_launcher_background">#FF5C6B7A</color>
|
<color name="ic_launcher_background">#FF5C6B7A</color>
|
||||||
|
<!-- Window backdrop shown during activity recreation (e.g. on a language
|
||||||
|
switch). Matches the Compose light scheme background/surface so the
|
||||||
|
recreation is seamless; overridden for dark in values-night. -->
|
||||||
|
<color name="window_background">#FFFBFCFE</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -48,6 +48,9 @@
|
|||||||
<string name="event_detail_back">Back</string>
|
<string name="event_detail_back">Back</string>
|
||||||
<string name="event_detail_edit">Edit</string>
|
<string name="event_detail_edit">Edit</string>
|
||||||
<string name="event_detail_delete">Delete</string>
|
<string name="event_detail_delete">Delete</string>
|
||||||
|
<string name="event_detail_share">Share</string>
|
||||||
|
<string name="event_share_chooser_title">Share event</string>
|
||||||
|
<string name="event_share_failed">Couldn\'t share this event.</string>
|
||||||
<string name="event_delete_title">Delete event?</string>
|
<string name="event_delete_title">Delete event?</string>
|
||||||
<string name="event_delete_body">The event is removed from your calendar and from every device it syncs to.</string>
|
<string name="event_delete_body">The event is removed from your calendar and from every device it syncs to.</string>
|
||||||
<string name="event_delete_recurring_title">Delete recurring event</string>
|
<string name="event_delete_recurring_title">Delete recurring event</string>
|
||||||
@@ -75,6 +78,16 @@
|
|||||||
<string name="event_edit_add">Add</string>
|
<string name="event_edit_add">Add</string>
|
||||||
<string name="event_edit_add_reminder">Add reminder</string>
|
<string name="event_edit_add_reminder">Add reminder</string>
|
||||||
<string name="event_edit_remove_reminder">Remove 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="event_edit_reminder_custom">Custom</string>
|
||||||
<string name="reminder_unit_minutes">minutes</string>
|
<string name="reminder_unit_minutes">minutes</string>
|
||||||
<string name="reminder_unit_hours">hours</string>
|
<string name="reminder_unit_hours">hours</string>
|
||||||
@@ -175,6 +188,23 @@
|
|||||||
<item quantity="one">%d week before</item>
|
<item quantity="one">%d week before</item>
|
||||||
<item quantity="other">%d weeks before</item>
|
<item quantity="other">%d weeks before</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
@@ -192,6 +222,8 @@
|
|||||||
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
<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_enable_button">Turn on reminders</string>
|
||||||
<string name="reminder_onboarding_skip_button">Not now</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) -->
|
<!-- View switcher (M1) -->
|
||||||
<string name="view_month">Month</string>
|
<string name="view_month">Month</string>
|
||||||
@@ -207,8 +239,15 @@
|
|||||||
<string name="agenda_today_action">Today</string>
|
<string name="agenda_today_action">Today</string>
|
||||||
<string name="agenda_header_today">Today</string>
|
<string name="agenda_header_today">Today</string>
|
||||||
<string name="agenda_header_tomorrow">Tomorrow</string>
|
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||||
<string name="agenda_empty_title">Nothing scheduled</string>
|
<string name="agenda_empty_title">You\'re all caught up</string>
|
||||||
<string name="agenda_empty_subtitle">Upcoming events will show up here.</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 -->
|
<!-- Home-screen widgets -->
|
||||||
<string name="widget_agenda_title">Upcoming</string>
|
<string name="widget_agenda_title">Upcoming</string>
|
||||||
@@ -232,12 +271,36 @@
|
|||||||
<string name="settings_theme_system">System</string>
|
<string name="settings_theme_system">System</string>
|
||||||
<string name="settings_theme_light">Light</string>
|
<string name="settings_theme_light">Light</string>
|
||||||
<string name="settings_theme_dark">Dark</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">Dynamic colour</string>
|
||||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</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">Week starts on</string>
|
||||||
<string name="settings_week_start_auto">Automatic</string>
|
<string name="settings_week_start_auto">Automatic</string>
|
||||||
<string name="settings_week_start_monday">Monday</string>
|
<string name="settings_time_format">Time format</string>
|
||||||
<string name="settings_week_start_sunday">Sunday</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_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_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_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||||
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
||||||
@@ -245,16 +308,32 @@
|
|||||||
<string name="settings_section_notifications">Notifications</string>
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
<string name="settings_reminders">Event reminders</string>
|
<string name="settings_reminders">Event reminders</string>
|
||||||
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
||||||
|
<string name="settings_default_reminder">Default reminder</string>
|
||||||
|
<string name="settings_default_reminder_allday">All-day events</string>
|
||||||
|
<string name="settings_allday_reminder_time">All-day reminder time</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">Reminders for all-day events fire at %1$s</string>
|
||||||
|
<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_section_calendars">Calendars</string>
|
||||||
<string name="settings_manage_calendars">Manage 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_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
||||||
<string name="settings_section_language">Language</string>
|
<string name="settings_section_language">Language</string>
|
||||||
<string name="settings_language">App language</string>
|
<string name="settings_language">App language</string>
|
||||||
<string name="settings_language_auto">System default</string>
|
<string name="settings_language_auto">System default</string>
|
||||||
<string name="settings_language_german">Deutsch</string>
|
<string name="settings_translate">Help translate</string>
|
||||||
<string name="settings_language_english">English</string>
|
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- 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_event_form_subtitle">Default fields for new events</string>
|
||||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||||
<string name="settings_section_about">About</string>
|
<string name="settings_section_about">About</string>
|
||||||
@@ -262,17 +341,25 @@
|
|||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||||
<string name="settings_about_source">Source</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_version">Version %1$s</string>
|
||||||
<string name="settings_about_logo_desc">Calendula app icon</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 -->
|
<!-- Calendar manager -->
|
||||||
<string name="calendars_title">Calendars</string>
|
<string name="calendars_title">Calendars</string>
|
||||||
<string name="calendars_local_header">Your 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_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_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_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_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_add_account">Add account</string>
|
||||||
<string name="calendars_new_title">New calendar</string>
|
<string name="calendars_new_title">New calendar</string>
|
||||||
<string name="calendars_edit_title">Edit calendar</string>
|
<string name="calendars_edit_title">Edit calendar</string>
|
||||||
@@ -282,10 +369,89 @@
|
|||||||
<string name="calendars_delete_confirm_title">Delete calendar?</string>
|
<string name="calendars_delete_confirm_title">Delete calendar?</string>
|
||||||
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
|
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
|
||||||
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
||||||
|
<!-- Backup (whole-calendar .ics export) -->
|
||||||
|
<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>
|
||||||
|
<item quantity="other">Exported %d events.</item>
|
||||||
|
</plurals>
|
||||||
|
<!-- Import (.ics) -->
|
||||||
|
<string name="import_title">Import events</string>
|
||||||
|
<string name="import_target_header">Add to calendar</string>
|
||||||
|
<string name="import_empty">No events found in this file.</string>
|
||||||
|
<string name="import_failed">Couldn\'t read this file.</string>
|
||||||
|
<string name="import_no_calendar">No writable calendar to import into. Create a local calendar first.</string>
|
||||||
|
<string name="import_done_title">Import complete</string>
|
||||||
|
<string name="import_close">Close</string>
|
||||||
|
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
|
||||||
|
<string name="import_warning_no_start">An event without a start time was skipped.</string>
|
||||||
|
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
|
||||||
|
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
|
||||||
|
<plurals name="import_event_count">
|
||||||
|
<item quantity="one">%d event in this file.</item>
|
||||||
|
<item quantity="other">%d events in this file.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_action">
|
||||||
|
<item quantity="one">Import %d event</item>
|
||||||
|
<item quantity="other">Import %d events</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_imported">
|
||||||
|
<item quantity="one">Imported %d event.</item>
|
||||||
|
<item quantity="other">Imported %d events.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_skipped">
|
||||||
|
<item quantity="one">Skipped %d already in this calendar.</item>
|
||||||
|
<item quantity="other">Skipped %d already in this calendar.</item>
|
||||||
|
</plurals>
|
||||||
<!-- Launcher long-press shortcuts -->
|
<!-- Launcher long-press shortcuts -->
|
||||||
<string name="shortcut_new_event_short">New event</string>
|
<string name="shortcut_new_event_short">New event</string>
|
||||||
<string name="shortcut_new_event_long">Create a new event</string>
|
<string name="shortcut_new_event_long">Create a new event</string>
|
||||||
|
|
||||||
|
<!-- 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_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_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>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="Theme.Calendula" parent="android:Theme.Material.Light.NoActionBar">
|
<!-- AppCompat (DayNight) parent so MainActivity can be an AppCompatActivity,
|
||||||
|
which is required for AppCompatDelegate.setApplicationLocales (the in-app
|
||||||
|
language picker) to sync to the system. Actual colours are driven by the
|
||||||
|
Compose theme; this is essentially the launch/backdrop theme. -->
|
||||||
|
<style name="Theme.Calendula" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/window_background</item>
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
<item name="android:windowLightStatusBar">true</item>
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
|||||||
7
app/src/main/res/xml/file_paths.xml
Normal file
7
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Exposes the cache subdirectory where IcsExporter stages files for sharing. -->
|
||||||
|
<paths>
|
||||||
|
<cache-path
|
||||||
|
name="shared_ics"
|
||||||
|
path="shared_ics/" />
|
||||||
|
</paths>
|
||||||
13
app/src/main/res/xml/locales_config.xml
Normal file
13
app/src/main/res/xml/locales_config.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
The languages Calendula ships translations for. This is the single source of
|
||||||
|
truth: each entry must have a matching res/values-<tag>/strings.xml, and is
|
||||||
|
surfaced automatically in both the in-app language picker (parsed at runtime
|
||||||
|
by AppLanguage) and the system per-app language settings (Android 13+, via
|
||||||
|
android:localeConfig in the manifest). To add a community translation, drop
|
||||||
|
in the values-<tag> folder and add one <locale> line here.
|
||||||
|
-->
|
||||||
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<locale android:name="en" />
|
||||||
|
<locale android:name="de" />
|
||||||
|
</locale-config>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user