Compare commits
289 Commits
v2.7.0
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| a82df3f6d0 | |||
| e0e3eb73b9 | |||
| 8a7a0af207 | |||
| 71a652ce3b | |||
| 5fa6eac1ad | |||
| 21ba3fb7cf | |||
| 316c2b0b12 | |||
| ef77eab627 | |||
| 955d47ef12 | |||
| 8536774522 | |||
| a8aeae5f32 | |||
| 988ac009b5 | |||
| 494e486998 | |||
| 3c73028c80 | |||
| 2be1be19fe | |||
| 1f050c2be9 | |||
|
|
5771b603f2 | ||
|
|
5725989aff | ||
| 8b22e1b2af | |||
| a514b8b506 | |||
| 60fcc6b64c | |||
| d6bc660983 | |||
| b449fff77c | |||
| f0cc35f2ce | |||
| 2ce79942c4 | |||
| df426bb8df | |||
| 31f51554f9 | |||
| e967007bdc | |||
| 9f7427e72f | |||
| 79d9e0eaa0 | |||
| a98dd654a6 | |||
| a138e179dd | |||
| 94e3887345 | |||
| f440d385fa | |||
| 8fb4767888 | |||
| 993d74502f | |||
| 4a11c951ae | |||
| f25f308326 | |||
| bff683a403 | |||
| bb7954d026 | |||
| d9f4239729 | |||
| cf3b897305 | |||
| 87a78ca924 | |||
| 13debac340 | |||
| 76846420a2 | |||
| 65bd6c4254 | |||
| 1a3e4f501f | |||
| 974d65f619 | |||
|
|
721ca0d7e0 | ||
|
|
2556eba84b | ||
|
|
fc1ff97d22 | ||
|
|
b7f3d421ef | ||
|
|
2722b3427a | ||
|
|
e28c293d27 | ||
|
|
5cb2a836ab | ||
|
|
b5978e7a61 | ||
| c4a048adee | |||
|
|
2746ca5784 | ||
| fc9bd33992 | |||
| 2239c38ecc | |||
| 79ad70e0c0 | |||
| f168021f51 | |||
| a2b1e7792b | |||
| c6ce0830b1 | |||
| be13f97802 | |||
| f6d40de832 | |||
| fdee311658 | |||
| 1ff41153e9 | |||
| 49d04bcee0 | |||
| 5e470baf4e | |||
| c8a4f90404 | |||
| 0a89ff9d0e | |||
| 0718afd5a6 | |||
| 84e6402031 | |||
| 7fe59b36c3 | |||
| 8881559e67 | |||
| 9f7e93d5f8 | |||
| 3863e74857 | |||
| 44d056f31f | |||
| 5c513a1b19 | |||
| 037d05b171 | |||
| 86d4e11584 | |||
| 67a07a9346 | |||
| b8a420bdab | |||
| 442697d57c | |||
| 8e3a17fdc3 | |||
| e6dd627c85 | |||
|
|
5844539db2 | ||
|
|
2cc83d7714 | ||
|
|
dada1bbe72 | ||
|
|
e061cbe127 | ||
| 262b2b8273 | |||
| efca0c86c4 | |||
| 08cd95cfba | |||
| bfc9a0db39 | |||
| 08905990cc | |||
| b5895b190e | |||
| 37f0e22911 | |||
| 4e125e58d5 | |||
| 4c26450ad4 | |||
| 52816b327a | |||
| cbb241bc4d | |||
| e5aa90bdd6 | |||
| 580dc5a669 | |||
| b9e800e9fc | |||
| 40d70d9f2b | |||
| 14b19cd902 | |||
| 0fd579b19b | |||
| 030c3e6ce1 | |||
| e67960d352 | |||
| 22bbfef3e2 | |||
| 4da9abe05b | |||
| 4540faf2a0 | |||
| 3aecb19a0c | |||
| f7c0b8a957 | |||
| 9a8d9b2177 | |||
| 93ca38d974 | |||
| a38093cae3 | |||
|
|
67511ce9be | ||
|
|
2e90682874 | ||
|
|
2a4cb6a896 | ||
| 8d530c5681 | |||
| 225f4c3491 | |||
| 11578a5588 | |||
| 5d887524b1 | |||
| 405ff16233 | |||
| 048f407ba1 | |||
| 69460bc35a | |||
|
|
bf10deaf5c | ||
|
|
b9e861af62 | ||
|
|
2eadda937b | ||
|
|
71b90fa184 | ||
|
|
4e9cf88bd4 | ||
|
|
b999202629 | ||
|
|
6f0e13a4a2 | ||
|
|
9ef7b33800 | ||
|
|
d04b431f2c | ||
|
|
dbeeadff80 | ||
|
|
f4a169ef5f | ||
|
|
604099b3f2 | ||
|
|
a3e6541512 | ||
| a89560953d | |||
| 4e498de051 | |||
| f2fb3d6279 | |||
| c2d88e744e | |||
| 2ae4c818ba | |||
| 25ff7ce7ca | |||
| fb2a40114a | |||
| e1f79df67a | |||
| e258a4a7f2 | |||
| 3f27a47d1e | |||
| feb87a1e73 | |||
| 28e464a616 | |||
| 93df6d6d62 | |||
| 49ddb9437b | |||
| e1cf00999d | |||
| 2d0d707a7d | |||
| de1fe31223 | |||
| 273cfce969 | |||
| b6a45b7264 | |||
| c7ef52e426 | |||
| b1151b6516 | |||
| 1664625bc5 | |||
| e8b87446f7 | |||
| dc478516cd | |||
| 8945b877b2 | |||
| 1424f0ffc0 | |||
| 548e581554 | |||
| a6ae4d7e1d | |||
| 614f4f2d75 | |||
| e6736b049a | |||
| 301904be6d | |||
| 3ca7200624 | |||
| c30a153552 | |||
| bb92d02000 | |||
| cd703e9d54 | |||
| f27b0e269d | |||
| 0f31981d26 | |||
| 6471c48528 | |||
| f8a2569831 | |||
| 6ba7f38887 | |||
| 366059b012 | |||
| 3cf1439850 | |||
| b2bfdc0f42 | |||
| 7c9830deda | |||
| 5e98059388 | |||
| 53c0482760 | |||
| f8efa4d3e4 | |||
| 93cc264fbf | |||
| 585cdac4ab | |||
| 9994e8c534 | |||
| 766c2ffcf8 | |||
| add88fbadf | |||
| 09a1aecb76 | |||
| c91608e48a | |||
| 47e8036000 | |||
| 07a8faf2df | |||
| 54b059e744 | |||
| d0c01ab8c4 | |||
| bf10fee19f | |||
| 2e88c40eff | |||
| 26e538a9ba | |||
| 814daa8151 | |||
| 450fc7f7af | |||
| be6d10d126 | |||
| a1e8c05c7b | |||
| cecd0c2234 | |||
| 43eb5e23e8 | |||
| 946addbbcd | |||
| 8a63aa86a7 | |||
| 0ddbbb11e8 | |||
| 575b1c7eb7 | |||
| dd61e0ed37 | |||
| 376ad5f394 | |||
| 8a9e6f0ee8 | |||
| 913b49e962 | |||
| 96f4d4dc84 | |||
| f4789c535e | |||
| 00220c5f0e | |||
| b7a5f16c84 | |||
| 2c38912834 | |||
| a9c67a5827 | |||
| a69b161178 | |||
| 024deadf78 | |||
| 599c3c03e7 | |||
| 5d48c90408 | |||
| c6d15d0118 | |||
| 79e083b4d4 | |||
| 119a8afe8e | |||
| b537e143f8 | |||
| faf2f27eda | |||
| 3d8e3ca69e | |||
| 3b5c0be765 | |||
| 013efef29e | |||
| 5457a34282 | |||
| 82e93fda4c | |||
| 871cc34cab | |||
| 5b99da32b0 | |||
| b8a7191fbe | |||
| 6b0eb48056 | |||
| 572a4734ea | |||
| 7f8a9069c0 | |||
| 22c4eded96 | |||
| 9839f9cd38 | |||
| c0e27133b4 | |||
| a4e0ec3fde | |||
| ac17343747 | |||
| b0f34ff18a | |||
| ab8a9e3401 | |||
| 92ec718b28 | |||
| 4ad1c09f8f | |||
| 5e8cb686e9 | |||
| 62ebd48e3c | |||
| c3dbcca989 | |||
| 37452be3bd | |||
| 006cce96a9 | |||
| 88d770bebe | |||
| b6bee55fc6 | |||
| 7ab277b434 | |||
| d0b6823385 | |||
| 5c80f99179 | |||
| 0875e69c2d | |||
| ac572da40a | |||
| f27811d215 | |||
| 36e2199dff | |||
| eb51175ceb | |||
| bc59200f77 | |||
| ed6b0fa89f | |||
| c503199e06 | |||
| f3f155b5ff | |||
| 294524a943 | |||
| ef6f1891b3 | |||
| 9fb3c780f1 | |||
| 8a80478555 | |||
| df203c0b6b | |||
| 0c95479051 | |||
| 221313178f | |||
| 297a2350e3 | |||
| 997ee44792 | |||
| 81baadfaf3 | |||
| 35022267dc | |||
| 588e024036 | |||
| eeef089e4a | |||
| 9023899ddb | |||
| 5ab3344f8c | |||
| 2431abe912 | |||
| 2f153fef56 | |||
| 701077f25b | |||
| 290a905f8b |
23
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
23
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Something doesn't work the way it should
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
---
|
||||||
|
|
||||||
|
### What happened
|
||||||
|
|
||||||
|
|
||||||
|
### What you expected
|
||||||
|
|
||||||
|
|
||||||
|
### Steps to reproduce
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||||
|
- Android version:
|
||||||
|
- Device:
|
||||||
27
.gitea/ISSUE_TEMPLATE/crash_report.md
Normal file
27
.gitea/ISSUE_TEMPLATE/crash_report.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: Crash report
|
||||||
|
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
|
||||||
|
title: "Crash: "
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- crash
|
||||||
|
- priority:high
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Thanks for reporting a crash in Calendula!
|
||||||
|
|
||||||
|
If the app prefilled this for you, the crash report is already below — just add
|
||||||
|
what you were doing and submit. Otherwise, paste the report from your clipboard
|
||||||
|
into the code block. The report contains only app/Android/device versions and the
|
||||||
|
stack trace — no personal data or calendar content.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### What happened
|
||||||
|
|
||||||
|
|
||||||
|
### Crash report
|
||||||
|
|
||||||
|
```
|
||||||
|
(paste the crash report here)
|
||||||
|
```
|
||||||
16
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
16
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea or improvement
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- feat
|
||||||
|
---
|
||||||
|
|
||||||
|
### What would you like Calendula to do?
|
||||||
|
|
||||||
|
|
||||||
|
### Why — what problem does it solve?
|
||||||
|
|
||||||
|
|
||||||
|
### Anything else
|
||||||
|
<!-- mockups, examples from other apps, alternatives you considered -->
|
||||||
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: Question
|
||||||
|
about: Ask how something works or get help using Calendula
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- question
|
||||||
|
---
|
||||||
|
|
||||||
|
### Your question
|
||||||
|
|
||||||
|
|
||||||
|
### What you've tried
|
||||||
|
<!-- so far, if anything -->
|
||||||
|
|
||||||
|
|
||||||
|
### Context
|
||||||
|
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||||
|
- Android version:
|
||||||
|
- Device:
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
name: CI
|
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,54 @@ 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
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
# 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 +81,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 +97,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 +108,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,78 +1,91 @@
|
|||||||
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
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
submodules: recursive
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Resolve version and whether it is a new release
|
||||||
uses: android-actions/setup-android@v3
|
id: v
|
||||||
with:
|
env:
|
||||||
packages: ''
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
- 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
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
@@ -121,31 +134,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 +168,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 +207,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 +220,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 +267,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 +320,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
|
||||||
@@ -4,11 +4,7 @@ name: Translations
|
|||||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||||
on:
|
on:
|
||||||
push:
|
pull_request:
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
tags-ignore:
|
|
||||||
- '**'
|
|
||||||
paths:
|
paths:
|
||||||
- 'app/src/main/res/values*/strings.xml'
|
- 'app/src/main/res/values*/strings.xml'
|
||||||
- 'app/src/main/res/xml/locales_config.xml'
|
- 'app/src/main/res/xml/locales_config.xml'
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,3 +55,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# KSP
|
# KSP
|
||||||
.ksp/
|
.ksp/
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
/CLAUDE.md
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "floret-kit"]
|
||||||
|
path = floret-kit
|
||||||
|
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
||||||
@@ -230,44 +230,59 @@ pass on the existing controls; new toggles ride in with their own features.
|
|||||||
|
|
||||||
**Tier 3 — platform reach (depends on Tier 2)**
|
**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 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||||
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
||||||
default reminder **+ per-calendar override**, bundled with battery-exemption
|
default reminder **+ per-calendar override**, bundled with battery-exemption
|
||||||
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
|
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
|
||||||
10. **The `.ics` engine — export + import** *(in progress → v2.7)* — one
|
10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one
|
||||||
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
|
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
|
||||||
four surfaces: single-event share + whole-calendar backup (export),
|
four surfaces all shipped: single-event share + whole-calendar backup
|
||||||
open-`.ics`→form + whole-calendar restore (import). Closes the
|
(export), open-`.ics`→form + whole-calendar bulk import (import). Closed the
|
||||||
device-local-calendar data-loss gap (#10/#11 merged here). Built as **two
|
device-local-calendar data-loss gap (#10/#11 merged here). Built as two
|
||||||
sequential branches in one release**: `feat/ics-export` (write side +
|
sequential branches: `feat/ics-export` (write side + UID-on-create precursor)
|
||||||
UID-on-create precursor) then `feat/ics-import` (parser, restore, dedup).
|
then `feat/ics-import` (parser, restore, dedup by UID). Import is
|
||||||
Import is liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` /
|
liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID`
|
||||||
`RECURRENCE-ID` it can't model. Timezone rule: all-day `VALUE=DATE`,
|
/ guest lists it can't model. Plans:
|
||||||
non-recurring timed UTC `Z`, recurring timed `TZID`-labelled from the stored
|
`docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`.
|
||||||
`EVENT_TIMEZONE` (no `VTIMEZONE` blocks; resolved against the OS tz DB on
|
11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)* —
|
||||||
import). Plan: `docs/superpowers/plans/2026-06-18-05-ics-export.md`.
|
followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager
|
||||||
11. **Snooze / dismiss notification actions** *(next, after v2.7)* — follows the
|
decision (snooze must re-fire an alarm).
|
||||||
`.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. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
|
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: whether **local-calendar backup (#10)**
|
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
|
||||||
should lead Tier 4 outright (it's a silent data-loss risk, not a feature);
|
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
|
||||||
whether drag-drop (#12) jumps ahead given its daily-driver impact.
|
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
|
||||||
|
|
||||||
@@ -277,26 +292,22 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact.
|
|||||||
- Agenda view (fourth view: upcoming events grouped by day; also the
|
- 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 — standard in every calendar, cheap,
|
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
|
||||||
currently absent. Daily-driver polish.
|
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
|
||||||
- Week numbers in the **month** grid — week view already shows the badge
|
the view and shrinks the day cells; the badge stays week-view-only.
|
||||||
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
|
|
||||||
- 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)* — promote out of "fill-in": for a daily driver
|
- ~~Full-text search~~ **shipped v2.8.0** — for a daily driver with real event
|
||||||
with real event history, finding an event is core completeness, not optional.
|
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
|
||||||
|
|
||||||
@@ -319,15 +330,69 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact.
|
|||||||
go/no-go gate as the OSM/INTERNET item below.
|
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)* — device-only
|
- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0** —
|
||||||
(`ACCOUNT_TYPE_LOCAL`) calendars are first-class in Calendula but have **no
|
device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no
|
||||||
sync and therefore no backup**: a lost/wiped phone destroys them permanently.
|
backup. Settings → Calendars → Export writes every event to a user-chosen
|
||||||
Whole-calendar `.ics` (VCALENDAR) export to a user-chosen file (SAF), plus
|
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
|
||||||
restore-on-import that recreates events into a chosen local calendar. Reuses
|
UID). Closed the silent data-loss gap.
|
||||||
the .ics serializer from the single-event share work; the restore path reuses
|
|
||||||
the import parser. A data-integrity obligation, not a feature.
|
|
||||||
|
|
||||||
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
|
### Disable a calendar in-app *(captured 2026-06-25)*
|
||||||
|
|
||||||
|
A second, heavier visibility level **above** the existing per-view filter. Today
|
||||||
|
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
|
||||||
|
a calendar's *events* from the month/week/day/agenda views — the calendar itself
|
||||||
|
still clutters the drawer filter list, the event-form calendar picker, and the
|
||||||
|
import target picker. "Disable" removes a calendar from the app's surfaces
|
||||||
|
entirely; "hide" stays the lightweight, frequently-toggled control.
|
||||||
|
|
||||||
|
**Two-level model (both kept):**
|
||||||
|
- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the
|
||||||
|
drawer. Toggles events on/off in the views; the calendar stays listed
|
||||||
|
everywhere. Operates only over the *enabled* calendars.
|
||||||
|
- **Disabled** (new) — the calendar is gone from the app: not in the drawer
|
||||||
|
filter list, not in the event-form picker, not in the import picker, and its
|
||||||
|
events never appear (it's gated out of `instances()` like a hidden one). It
|
||||||
|
remains visible **only** in Settings → Calendars, where the enable/disable
|
||||||
|
toggle lives, so it can be brought back.
|
||||||
|
|
||||||
|
**Storage — app-side (DataStore), mirrors the hidden set.** Add
|
||||||
|
`disabledCalendarIds: Set<Long>` + `setDisabledCalendarIds(...)` to
|
||||||
|
`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`).
|
||||||
|
**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely
|
||||||
|
a Calendula-local preference, so other calendar apps are unaffected and the sync
|
||||||
|
adapters stay out of it (privacy-clean, reversible).
|
||||||
|
|
||||||
|
**Where the disabled set is applied:**
|
||||||
|
- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude
|
||||||
|
`calendarId ∈ (hidden ∪ disabled)` so a disabled calendar's events never show
|
||||||
|
and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw —
|
||||||
|
the screens that need everything still get everything.)
|
||||||
|
- `FilterViewModel.state` — drop disabled calendars from the drawer filter list
|
||||||
|
(you can't hide/show what's disabled).
|
||||||
|
- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create
|
||||||
|
into a calendar you've removed from the app. Handle the last-used-calendar
|
||||||
|
preselect falling on a now-disabled calendar (fall back to first enabled
|
||||||
|
writable).
|
||||||
|
- `ImportViewModel` — exclude disabled from the import target list.
|
||||||
|
- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists
|
||||||
|
disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle.
|
||||||
|
|
||||||
|
**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each
|
||||||
|
row on the existing `CalendarsScreen` (both the local and the synced/read-only
|
||||||
|
groups — disabling is an app-side view choice, independent of write access).
|
||||||
|
Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so
|
||||||
|
they're re-enableable. Follow the project dialog/list conventions (M3 grouped
|
||||||
|
list, `option-card-modal-style-default` if a confirm/selection surface is needed).
|
||||||
|
|
||||||
|
**Decided behaviour:**
|
||||||
|
- All calendars disabled → views show the existing empty state.
|
||||||
|
- A disabled calendar that still holds events: events simply vanish from
|
||||||
|
views/search until re-enabled (no data touched — it's a filter, not a delete).
|
||||||
|
- Deep links / notifications pointing at an event in a disabled calendar still
|
||||||
|
open its detail screen — detail is a direct id lookup, not an `instances()`
|
||||||
|
query, so disabling never strands an existing link.
|
||||||
|
|
||||||
|
## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)*
|
||||||
|
|
||||||
Two themes bundled because both are "make reminders trustworthy" — the core of
|
Two themes bundled because both are "make reminders trustworthy" — the core of
|
||||||
the "Calendula is your only calendar app" promise.
|
the "Calendula is your only calendar app" promise.
|
||||||
@@ -404,30 +469,34 @@ it directly undermines the feature's premise, so it rides in here.
|
|||||||
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
||||||
are notorious for suppressing it).
|
are notorious for suppressing it).
|
||||||
|
|
||||||
### Open decisions (resolve before building)
|
### Decisions made (as shipped in v2.6.0)
|
||||||
|
|
||||||
1. Self-schedule via `AlarmManager` vs trust the provider broadcast
|
1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
|
||||||
(reliability vs simplicity + battery cost).
|
battery-optimisation exemption is the reliability lever (simplicity + battery
|
||||||
2. All-day reminder representation (minutes-before vs absolute time-of-day).
|
cost won over the exact-alarm subsystem).
|
||||||
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
|
2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
|
||||||
a list inside the Notifications sub-page).
|
time-of-day ("9am the day before") deferred — see the deferred list above.
|
||||||
|
3. **Per-calendar overrides live in the Notifications sub-page** (override list),
|
||||||
|
governing timed events only.
|
||||||
|
|
||||||
### Later (round two)
|
### Round two
|
||||||
|
|
||||||
- Snooze + dismiss actions on the notification (snooze needs an
|
- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
|
||||||
exact-alarm / WorkManager decision) — Tier 4 #13.
|
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~~ **shipped v2.5.0** — agenda + month widgets
|
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
||||||
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0** —
|
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
|
||||||
optional quick-settings tile still open
|
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
|
||||||
|
|
||||||
## Quality & reliability
|
## Quality & reliability
|
||||||
|
|
||||||
@@ -443,9 +512,10 @@ it directly undermines the feature's premise, so it rides in here.
|
|||||||
Beyond classic calendar-client scope; discussed, deliberately not planned
|
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
|
||||||
@@ -455,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
|
||||||
|
|
||||||
@@ -465,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**.
|
||||||
|
|||||||
388
CHANGELOG.md
388
CHANGELOG.md
@@ -5,7 +5,364 @@ 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.14.0] — 2026-07-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Restore events from a backup file. The backup section of Settings can now read
|
||||||
|
events back **in** from an `.ics` file, not just write one out: pick a file,
|
||||||
|
choose which calendar to import into, and Calendula adds the events — skipping
|
||||||
|
any that are already there and telling you how many it skipped. Export gained a
|
||||||
|
per-calendar selector at the same time, so you can back up just the calendars
|
||||||
|
you pick instead of everything at once ([#32]).
|
||||||
|
- Week numbers in Month view. A new **Week numbers** setting (off by default)
|
||||||
|
adds a slim gutter down the left of the Month grid showing the calendar-week
|
||||||
|
number for each row, sized to match the day cells. Handy if you plan or refer
|
||||||
|
to dates by week number ([#25]).
|
||||||
|
- Tap a date header to open that day. In Week and Agenda view, tapping a date
|
||||||
|
header now opens that date in Day view — the same drill-in that Month view and
|
||||||
|
the agenda widget already offered, so every view behaves the same way. It makes
|
||||||
|
jumping to a specific day quicker: switch to Week, swipe to the week you want,
|
||||||
|
then tap the date to open it. Thanks to @ptab for the suggestion ([#37]).
|
||||||
|
- An early Simplified Chinese translation. Calendula has started speaking
|
||||||
|
Simplified Chinese, contributed as a community translation through
|
||||||
|
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||||
|
It is still an early effort, so many parts of the app show in English until it
|
||||||
|
fills out — you can already pick it under Settings → Language or in Android's
|
||||||
|
per-app language settings. Thanks to
|
||||||
|
[zh-cn](https://weblate.dev.jeanlucmakiola.de/user/zh-cn/) for getting it
|
||||||
|
started; help finishing it is very welcome.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Long event titles wrap in the edit screen. When editing an event, a long title
|
||||||
|
now wraps onto multiple lines instead of being clipped to a single line, so you
|
||||||
|
can see and edit the whole thing ([#33]).
|
||||||
|
|
||||||
|
## [2.13.1] — 2026-07-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Create events from other apps and widgets. Calendula now registers the
|
||||||
|
standard "insert event" intent (`ACTION_INSERT` on the calendar events type),
|
||||||
|
so other apps and home-screen widgets — such as the Todo Agenda widget — can
|
||||||
|
hand off to Calendula to create a new event. It opens the new-event form
|
||||||
|
prefilled with whatever they passed (title, start/end time, all-day, location,
|
||||||
|
description, recurrence), and picks your last-used or first writable calendar.
|
||||||
|
Thanks to @dschuermann for the suggestion ([#30]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Some recurring events could not be opened. Events in a series that started
|
||||||
|
before 1970 — for example yearly birthdays or anniversaries synced over CalDAV
|
||||||
|
— showed "Something went wrong" instead of opening, because their stored start
|
||||||
|
time is a negative value that was wrongly treated as invalid. They now open
|
||||||
|
normally and appear in search again. A related case (an event whose stored end
|
||||||
|
precedes its start) is now kept and openable instead of failing the same way.
|
||||||
|
Thanks to @dschuermann for the report ([#34]).
|
||||||
|
- The time picker now follows your 24-hour setting. With Calendula set to
|
||||||
|
24-hour time, the clock dial for choosing an event's start and end time still
|
||||||
|
showed AM/PM instead of a 24-hour dial; it now matches your setting (and the
|
||||||
|
same fix applies to the all-day reminder time in Settings). Thanks to
|
||||||
|
@abrossimow for the report ([#27]).
|
||||||
|
|
||||||
|
## [2.13.0] — 2026-07-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Calendula now speaks Spanish and Italian. Both arrived as community
|
||||||
|
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
|
||||||
|
— a **huge thank you** to
|
||||||
|
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
|
||||||
|
translation and to
|
||||||
|
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
|
||||||
|
one! Pick your language under Settings → Language or in Android's per-app
|
||||||
|
language settings. Strings added in this very release may still show in
|
||||||
|
English until the translations catch up. Want Calendula in your language?
|
||||||
|
Translating happens entirely in the browser — every contribution is welcome.
|
||||||
|
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||||
|
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||||
|
birthdays, anniversaries and other dates into local "Birthdays",
|
||||||
|
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
|
||||||
|
change. Each is a normal local calendar, so you set its colour, visibility and
|
||||||
|
reminders the usual way — new birthdays even start with a reminder a week before
|
||||||
|
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
|
||||||
|
This is the first feature to use the contacts permission: it is requested only
|
||||||
|
when you turn the feature on, everything stays on your device (Calendula has no
|
||||||
|
internet access), and your contacts are only ever read, never changed. Thanks
|
||||||
|
to @moonj for the suggestion ([#15]).
|
||||||
|
- Set more than one default reminder per calendar. A calendar's default
|
||||||
|
reminders — and the global defaults under Settings → Notifications — can now
|
||||||
|
hold several lead times instead of just one, so new events can start with, say,
|
||||||
|
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||||
|
multi-select; per-calendar overrides can still inherit the global default or
|
||||||
|
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||||
|
- Widget headers now open the app. Tapping the month/year title on the month
|
||||||
|
widget opens the app on the month view, and tapping the "Upcoming" title on
|
||||||
|
the agenda widget opens it on your default view — so there's a one-tap way
|
||||||
|
back into the app that lands where you'd expect, instead of only through a day
|
||||||
|
or event. On the month widget, tapping anywhere on a day — not just the small
|
||||||
|
date number — now opens that day, and the "today" button snaps the grid back
|
||||||
|
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||||
|
suggestions ([#18], [#20]).
|
||||||
|
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
|
||||||
|
and **Body font** pickers: keep the system default, choose a bundled face
|
||||||
|
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
|
||||||
|
face), or load any `.ttf`/`.otf` file from your device, independently per
|
||||||
|
role. Font size and colour stay with Android's accessibility scaling and the
|
||||||
|
app theme, as discussed on the issue. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#19]).
|
||||||
|
- Choose what the view-switch button cycles through. Settings → Appearance now
|
||||||
|
lets you pick which views the top-right quick-switch button rotates between
|
||||||
|
and drag them into your preferred order; views you switch off stay reachable
|
||||||
|
from the navigation menu, which can be reordered the same way. Thanks to
|
||||||
|
@abrossimow for the suggestion ([#24]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Month widget arrows and "today" button work again. On release builds the
|
||||||
|
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||||
|
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||||
|
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
|
||||||
|
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||||
|
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||||
|
popping up — matching how a disabled calendar's events already stay hidden
|
||||||
|
everywhere else in the app ([#17]).
|
||||||
|
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||||
|
this event** and saving a change to one event in a repeating series silently
|
||||||
|
did nothing — the change was rejected and the edit form simply reappeared with
|
||||||
|
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||||
|
lands on just that one event and leaves the rest of the series untouched
|
||||||
|
([#16]).
|
||||||
|
- Every calendar can be picked when creating an event. The event editor's
|
||||||
|
calendar picker was a fixed-height dialog, so with many calendars anything
|
||||||
|
past the first nine or so was simply unreachable. It is now a full-screen,
|
||||||
|
scrollable list grouped by account, with each calendar's colour shown.
|
||||||
|
Thanks to @dschuermann for the report ([#29]).
|
||||||
|
|
||||||
|
## [2.12.0] — 2026-06-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- See past events your way. Two new settings change how events that have already
|
||||||
|
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||||
|
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||||
|
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||||
|
(Settings → Appearance) separately fades finished events in the month and week
|
||||||
|
views. Both are off by default, an event only counts as finished once it has
|
||||||
|
actually ended (events still in progress are never dimmed), and the lists
|
||||||
|
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||||
|
([#12]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Readable titles on overlapping events. In the week and day views, events that
|
||||||
|
overlap split a day into slim columns where the title used to be clipped to a
|
||||||
|
character or two. The title now wraps across as many lines as the block can
|
||||||
|
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||||
|
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||||
|
for the suggestion ([#13]).
|
||||||
|
|
||||||
|
## [2.11.2] — 2026-06-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Jump straight to typing a title. When you start a new event, Calendula now puts
|
||||||
|
the cursor in the title field and opens the keyboard right away, so you can type
|
||||||
|
the name without an extra tap. It's on by default and only affects creating an
|
||||||
|
event — editing never grabs focus — and a new **Focus title on new event**
|
||||||
|
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#10]).
|
||||||
|
|
||||||
|
## [2.11.1] — 2026-06-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Calendula can now be set as your default calendar app. It registers the
|
||||||
|
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||||
|
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||||
|
that day. Android has no way for an app to make itself the default, so you pick
|
||||||
|
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||||
|
|
||||||
|
## [2.11.0] — 2026-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Start the week on any day. The **Week starts on** setting (Settings →
|
||||||
|
Appearance) now offers every weekday — not just Monday or Sunday — alongside
|
||||||
|
the automatic, locale-based default. Thanks to @zmaherdev for the suggestion
|
||||||
|
([#3]).
|
||||||
|
- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings →
|
||||||
|
Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or
|
||||||
|
follow the system setting automatically. It applies everywhere times appear —
|
||||||
|
the week and day timelines, agenda, event details, search and reminders — so
|
||||||
|
the format is now consistent across the whole app. Thanks to @zmaherdev for
|
||||||
|
the suggestion ([#6]).
|
||||||
|
- Optional hour lines in the timeline. A new **Hour lines** switch (Settings →
|
||||||
|
Appearance) draws a faint separator at each hour in the week and day views,
|
||||||
|
making it easier to see when events start and end. Off by default. Thanks to
|
||||||
|
@zmaherdev for the suggestion ([#5]).
|
||||||
|
- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda
|
||||||
|
range** and **Agenda widget range** settings (Settings → Appearance) let the
|
||||||
|
agenda screen and its home-screen widget each show just today, the rest of this
|
||||||
|
week, the rest of this month, a rolling 7 or 30 days, or a custom number of
|
||||||
|
days — "This week" follows your week-start. The agenda also gains a bar at the
|
||||||
|
top naming the exact dates in view, with a button to switch the range just for
|
||||||
|
the current session (it resets when you reopen the app); the bar can be turned
|
||||||
|
off. Thanks to @zmaherdev for the suggestion ([#4]).
|
||||||
|
- Automatic backup of local calendars. A new **Automatic backup** option
|
||||||
|
(Settings → Calendars → Backup) periodically exports your local calendars to an
|
||||||
|
`.ics` file in a folder you choose, on an interval you set (from 30 minutes up).
|
||||||
|
Useful for file-based syncing such as Syncthing, or simply as a safety net — it
|
||||||
|
is a one-way export and never touches your synced accounts. Thanks to @shield
|
||||||
|
for the discussion that inspired it ([#7], [#8]).
|
||||||
|
- Field icons in the event-form settings. Each optional-field toggle (Settings →
|
||||||
|
Event form) now shows the same icon the field uses in the new-event form, so
|
||||||
|
the list is easier to scan.
|
||||||
|
- Help translate Calendula. A **Help translate** link at the top of **Settings →
|
||||||
|
App language** opens the project's Weblate, where you can add or improve a
|
||||||
|
language in your browser — no coding needed. Contributions in any language are
|
||||||
|
welcome.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
|
||||||
|
agenda), and the agenda's empty state now reads "You're all caught up".
|
||||||
|
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
|
||||||
|
into collapsible, source-branded cards — each account shows its app's icon —
|
||||||
|
with a per-account menu to enable or disable all of its calendars at once or
|
||||||
|
open the account in its source app.
|
||||||
|
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
|
||||||
|
moved up with the other global reminder options, and the per-calendar reminder
|
||||||
|
overrides now fold into a single expandable section.
|
||||||
|
- Moved **Add Quick Settings tile** out of the event-form section to its own
|
||||||
|
top-level entry in Settings.
|
||||||
|
|
||||||
|
## [2.10.0] — 2026-06-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Turn a calendar off across the whole app. Settings → Calendars now has a
|
||||||
|
switch on every calendar — both your own and synced ones. Switching one off
|
||||||
|
removes it everywhere: its events disappear from all views and search, and it
|
||||||
|
drops out of the drawer's hide/show filter, the event-form calendar picker and
|
||||||
|
the import target picker. Unlike hiding (the quick per-view checkbox in the
|
||||||
|
drawer), a disabled calendar leaves the app entirely until you turn it back on.
|
||||||
|
Nothing is deleted and no other app is affected — it's a Calendula-only view
|
||||||
|
choice — so you can re-enable it any time from the same screen, where disabled
|
||||||
|
calendars stay listed but dimmed.
|
||||||
|
- An optional way to support development. Settings → About now has a "Support
|
||||||
|
development" button that opens Ko-fi in your browser. It's a plain donation
|
||||||
|
link with no perks attached, and it needs no new permissions — Calendula still
|
||||||
|
has no internet access of its own and just hands the link to your browser.
|
||||||
|
|
||||||
|
## [2.9.0] — 2026-06-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
|
||||||
|
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
|
||||||
|
time you start the app, instead of always opening on Week. Thanks to
|
||||||
|
@devinside for the suggestion ([#1]).
|
||||||
|
- The month widget is now interactive: tap any day to open it, or tap an event to
|
||||||
|
open its details. Previously only the month's prev/next/today controls
|
||||||
|
responded. Thanks to @devinside for spotting this ([#2]).
|
||||||
|
- Predictive back. Swiping back from a screen — an event, the editor, search or
|
||||||
|
settings — now follows your finger, shrinking the screen to preview what's
|
||||||
|
behind so you can see where Back will take you before you let go; slide it back
|
||||||
|
to cancel. The preview needs Android 14 or newer; on older versions Back works
|
||||||
|
as before.
|
||||||
|
- Respect for "Remove animations". If you've turned animations off in your
|
||||||
|
device's Accessibility settings, Calendula now honours it everywhere: motion
|
||||||
|
collapses to a quick fade and the back preview is skipped.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Smoother, more consistent motion throughout. Expanding sections, list updates
|
||||||
|
in search and agenda, and the onboarding screens now animate the same way
|
||||||
|
across the app, instead of some places sliding or growing while others popped
|
||||||
|
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
|
||||||
|
snapping, while paging within a view keeps its slide.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Home-screen widgets now back out the way you came in. A day or event opened
|
||||||
|
from the Agenda widget keeps you in the agenda context, and from the Month
|
||||||
|
widget in the month context — pressing Back returns there instead of dropping
|
||||||
|
you on the week view. More broadly, the app now keeps a real view history:
|
||||||
|
switching views and drilling into a day are retraced by Back one step at a
|
||||||
|
time, down to your default view before the app exits. Thanks to @devinside for
|
||||||
|
reporting ([#2]).
|
||||||
|
|
||||||
|
## [2.8.0] — 2026-06-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Find events fast. A search button in the top bar of every calendar view opens
|
||||||
|
a search box — type a couple of letters and matching events (by title,
|
||||||
|
location or description) appear, soonest first with past events below. Tap a
|
||||||
|
result to open it. Search covers your whole calendar, not just what's on
|
||||||
|
screen, and skips calendars you've hidden. A recurring event shows its next
|
||||||
|
occurrence rather than the date the series first started.
|
||||||
|
- A current-time line in the day and week views. A thin coloured line marks the
|
||||||
|
present moment across today's column, so you can see at a glance where you are
|
||||||
|
in the day. It updates every minute and only appears when today is in view.
|
||||||
|
- Add and remove event guests. The create/edit form now has a Guests section:
|
||||||
|
add people by email or pick them from your contacts, mark each as required or
|
||||||
|
optional, and remove them. Calendula never sends invitations itself (it has no
|
||||||
|
internet access) — it only records the guests; if the event lives on a synced
|
||||||
|
account, that account may email them when it syncs, and on a local calendar no
|
||||||
|
one is notified. The form tells you which applies. Picking a guest from
|
||||||
|
contacts needs no contacts permission.
|
||||||
|
- Pick a location from your contacts. A contacts button beside the location
|
||||||
|
field drops a contact's address straight into an event — handy for a meeting
|
||||||
|
at someone's home or office. Like the guest picker, it needs no contacts
|
||||||
|
permission.
|
||||||
|
- A "New event" Quick Settings tile. Add it to your quick settings to jump
|
||||||
|
straight into the new-event form from anywhere. Settings → New event has a
|
||||||
|
one-tap button to add the tile (Android 13+); on older versions you can add it
|
||||||
|
from the quick-settings editor.
|
||||||
|
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
|
||||||
|
reminder; snooze hides it and brings it back after a delay you pick in
|
||||||
|
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
|
||||||
|
system won't re-post a reminder on its own, so Calendula schedules an exact
|
||||||
|
alarm to bring a snoozed one back on time.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Event details now show each guest's email beneath their name, instead of only
|
||||||
|
when no name is available.
|
||||||
|
- Crash and problem reports now open on the project's public Codeberg tracker,
|
||||||
|
where anyone can register and file an issue. Nothing is sent automatically —
|
||||||
|
you still review the report and submit it yourself in the browser.
|
||||||
|
|
||||||
|
## [2.7.5] — 2026-06-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Further build cleanup for the official F-Droid repository: stopped embedding
|
||||||
|
AGP's dependency-metadata block in the APK, which F-Droid's reproducible-build
|
||||||
|
scanner rejects as an extra signing block. No functional or visible changes —
|
||||||
|
the same app as 2.7.4, just without that Play-oriented metadata blob.
|
||||||
|
|
||||||
|
## [2.7.4] — 2026-06-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Build cleanup that lets Calendula ship in the official F-Droid repository:
|
||||||
|
removed an unused Gradle toolchain-resolver plugin, which F-Droid's offline,
|
||||||
|
reproducible build process disallows. No functional or visible changes — this
|
||||||
|
is the same app as 2.7.3.
|
||||||
|
|
||||||
|
## [2.7.3] — 2026-06-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Home-screen widgets no longer get stuck on a loading spinner in the published
|
||||||
|
(F-Droid) release build. They render via Android's background-work system, and
|
||||||
|
release optimisation (R8) was stripping a helper class it loads by name, so the
|
||||||
|
render job never ran. Added the missing keep rule — widgets now load normally.
|
||||||
|
|
||||||
|
## [2.7.2] — 2026-06-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Crash reporting you control. If Calendula closes unexpectedly, it now captures
|
||||||
|
a technical report and, on the next launch, offers to send it as an issue on
|
||||||
|
the project's tracker. Nothing is uploaded automatically — the report stays on
|
||||||
|
your device until you choose to share it, it contains no personal data or
|
||||||
|
calendar content (only the app, Android and device versions plus the stack
|
||||||
|
trace), and you see the full text before sending. There's also a "Report a
|
||||||
|
problem" entry in Settings, and if the app ever fails to start repeatedly, a
|
||||||
|
minimal recovery screen still lets you send the report.
|
||||||
|
|
||||||
|
## [2.7.1] — 2026-06-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed the app crashing immediately on launch whenever calendar access hadn't
|
||||||
|
been granted yet (a fresh install, or after revoking the permission). The app
|
||||||
|
set up its live calendar-change listener before the permission screen could
|
||||||
|
appear, which newer Android versions reject outright — so the app died before
|
||||||
|
you could grant access. The listener now waits for the permission and attaches
|
||||||
|
itself the moment it's granted.
|
||||||
|
|
||||||
## [2.7.0] — 2026-06-18
|
## [2.7.0] — 2026-06-18
|
||||||
|
|
||||||
@@ -488,3 +845,32 @@ 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
|
||||||
|
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
|
||||||
|
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
||||||
|
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||||
|
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||||
|
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||||
|
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
|
||||||
|
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||||
|
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||||
|
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||||
|
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||||
|
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||||
|
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
||||||
|
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
|
||||||
|
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
|
||||||
|
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||||
|
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||||
|
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||||
|
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||||
|
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||||
|
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||||
|
|||||||
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 = 20700
|
versionCode = 21400
|
||||||
versionName = "2.7.0"
|
versionName = "2.14.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 {
|
||||||
@@ -70,6 +90,18 @@ android {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
// BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon).
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||||
|
// a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any
|
||||||
|
// "extra signing block" — so leaving it in blocks publishing to the official
|
||||||
|
// repo. It lives in the signing block, not the zip entries, so disabling it
|
||||||
|
// doesn't change the build output (reproducibility is unaffected).
|
||||||
|
dependenciesInfo {
|
||||||
|
includeInApk = false
|
||||||
|
includeInBundle = false
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
@@ -122,10 +154,19 @@ 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)
|
||||||
|
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-time")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||||
|
implementation("de.jeanlucmakiola.floret:identity")
|
||||||
|
implementation("de.jeanlucmakiola.floret:components")
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
|||||||
23
app/proguard-rules.pro
vendored
23
app/proguard-rules.pro
vendored
@@ -15,3 +15,26 @@
|
|||||||
# Keep the generated Room database implementations fully intact.
|
# Keep the generated Room database implementations fully intact.
|
||||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
-dontwarn androidx.room.paging.**
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
|
# Glance runs an @Composable's `actionRunCallback<T>()` by persisting the
|
||||||
|
# callback's fully-qualified class name into the click PendingIntent, then
|
||||||
|
# reflectively instantiating it (Class.forName(name).newInstance()) when the tap
|
||||||
|
# fires. Under R8 full mode (AGP 9 default) these ActionCallback classes — only
|
||||||
|
# ever referenced reflectively — get renamed or have their no-arg constructor
|
||||||
|
# stripped, so the lookup fails silently and the tap does nothing. In the month
|
||||||
|
# and agenda widgets that broke every run-callback control (the prev/next/today
|
||||||
|
# month arrows and the agenda refresh) in release builds while actionStartActivity
|
||||||
|
# taps, which ride a PendingIntent and need no reflection, kept working. Keep
|
||||||
|
# every ActionCallback's name and constructor intact.
|
||||||
|
-keep class * implements androidx.glance.appwidget.action.ActionCallback { <init>(...); }
|
||||||
|
|
||||||
|
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
||||||
|
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
||||||
|
# keep both its name and a no-arg constructor. Glance renders every widget
|
||||||
|
# through a WorkManager worker (androidx.glance.session.SessionWorker) whose
|
||||||
|
# default merger is androidx.work.OverwritingInputMerger. Under R8 full mode
|
||||||
|
# (AGP 9 default) that unused no-arg constructor was stripped, so WorkManager
|
||||||
|
# threw "OverwritingInputMerger has no zero argument constructor", the
|
||||||
|
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||||
|
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||||
|
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
||||||
|
|||||||
10
app/src/debug/res/values/colors.xml
Normal file
10
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Debug-only launcher-icon background. Production is slate (#5C6B7A); the
|
||||||
|
debug build paints the adaptive-icon background burnt orange instead, so the
|
||||||
|
debug icon reads at a glance as "not the real app" on the home screen. The
|
||||||
|
off-white foreground mark contrasts on both. See drawable/ic_launcher_background.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFB23B00</color>
|
||||||
|
</resources>
|
||||||
10
app/src/debug/res/values/strings.xml
Normal file
10
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Debug-build resource overrides. Merged on top of src/main for the `debug`
|
||||||
|
build type only (release/releaseTest keep the production values), so the
|
||||||
|
debug app is unmistakable on the launcher: its own label, alongside the
|
||||||
|
real app thanks to the `.debug` applicationId suffix.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Calendula Debug</string>
|
||||||
|
</resources>
|
||||||
@@ -5,6 +5,15 @@
|
|||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.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" />
|
||||||
|
<!--
|
||||||
|
Optional and feature-gated: only the "Contact special dates" feature reads
|
||||||
|
contacts, and only after the user enables it and grants this at runtime
|
||||||
|
(never requested at startup). Everything stays offline — birthdays and
|
||||||
|
other contact dates are mirrored one-way into local calendars; contacts
|
||||||
|
are never written and nothing leaves the device (the app has no INTERNET
|
||||||
|
permission). See docs/design/contact-special-dates.md.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
<!--
|
<!--
|
||||||
Lets the "Reliable delivery" setting open the direct system dialog to
|
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||||
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||||
@@ -13,6 +22,17 @@
|
|||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<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
|
||||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||||
@@ -28,6 +48,7 @@
|
|||||||
<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"
|
||||||
@@ -47,6 +68,35 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Be selectable as the system calendar app. Android has no API for
|
||||||
|
an app to make itself the default, so registering the filters
|
||||||
|
launchers and the OS use is what lets the user pick Calendula
|
||||||
|
from the system chooser when a date action fires (issue #9).
|
||||||
|
APP_CALENDAR is the "open the calendar app" action; the VIEW
|
||||||
|
filters below catch a tapped date. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.APP_CALENDAR" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- A launcher/clock date tap fires ACTION_VIEW on the provider's
|
||||||
|
time Uri (content://com.android.calendar/time/<epochMillis>);
|
||||||
|
some surfaces use the time/epoch mime type. We open the day view
|
||||||
|
on that date (MainActivity.calendarTimeDateOrNull). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data
|
||||||
|
android:scheme="content"
|
||||||
|
android:host="com.android.calendar"
|
||||||
|
android:pathPrefix="/time" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="time/epoch" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
@@ -62,12 +112,46 @@
|
|||||||
<data android:mimeType="text/calendar" />
|
<data android:mimeType="text/calendar" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
||||||
|
to create a new event, the way the AOSP calendar accepts it:
|
||||||
|
ACTION_INSERT on the events dir mime type, carrying the new
|
||||||
|
event's fields as CalendarContract extras
|
||||||
|
(MainActivity.insertFormOrNull, issue #30). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.INSERT" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||||
|
</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. -->
|
||||||
@@ -82,6 +166,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"
|
||||||
|
|||||||
@@ -1,11 +1,78 @@
|
|||||||
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.contacts.SpecialDatesScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
|
import de.jeanlucmakiola.floret.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). The
|
||||||
|
// capture/loop-detection/report machinery lives in floret-kit's
|
||||||
|
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
||||||
|
CrashReporter.install(
|
||||||
|
this,
|
||||||
|
CrashConfig(
|
||||||
|
appLabel = getString(R.string.app_name),
|
||||||
|
newIssueUrl = getString(R.string.report_issue_url),
|
||||||
|
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||||
|
issueTitle = getString(R.string.crash_report_issue_title),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
reconcileAutoBackup()
|
||||||
|
reconcileSpecialDates()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring the scheduled auto-backup work back in line with the saved settings
|
||||||
|
* on every launch — re-arms it after a reinstall and, crucially, cancels any
|
||||||
|
* orphaned work once backup has been turned off.
|
||||||
|
*/
|
||||||
|
private fun reconcileAutoBackup() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(this, BackupWorker.Deps::class.java)
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
val prefs = deps.settingsPrefs()
|
||||||
|
BackupScheduler.apply(
|
||||||
|
context = this@CalendulaApp,
|
||||||
|
enabled = prefs.autoBackupEnabled.first(),
|
||||||
|
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||||
|
hasFolder = prefs.autoBackupFolderUri.first() != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||||
|
* settings on every launch — like [reconcileAutoBackup], this cancels
|
||||||
|
* orphaned work after the feature is turned off and re-schedules after a
|
||||||
|
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
|
||||||
|
* refresh, so no immediate run is needed here.
|
||||||
|
*/
|
||||||
|
private fun reconcileSpecialDates() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
SpecialDatesScheduler.apply(
|
||||||
|
context = this@CalendulaApp,
|
||||||
|
enabled = deps.settingsPrefs().specialDatesEnabled.first(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,26 +4,49 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.CalendarContract
|
||||||
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.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
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.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.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||||
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.floret.components.DebugRibbon
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
@@ -41,12 +64,37 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// by CalendarHost's import flow.
|
// by CalendarHost's import flow.
|
||||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||||
|
|
||||||
|
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||||
|
// app/widget asking us to create an event, issue #30). Consumed once by
|
||||||
|
// CalendarHost, which opens it in the create form for review.
|
||||||
|
private var requestedInsertForm by mutableStateOf<EventForm?>(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()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
|
requestedInsertForm = intent.insertFormOrNull()
|
||||||
|
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.
|
||||||
@@ -57,9 +105,34 @@ class MainActivity : AppCompatActivity() {
|
|||||||
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))
|
||||||
|
}
|
||||||
|
// The user's custom-font choice, resolved to a Material typography
|
||||||
|
// (issue #19). Recomputed only when a token — or the custom-font
|
||||||
|
// re-import stamp AppFontSettings carries, so replacing the file
|
||||||
|
// behind an active "custom" token still refreshes — changes;
|
||||||
|
// "system for both" returns the default scale untouched.
|
||||||
|
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||||
|
val typography = remember(fonts, context) {
|
||||||
|
calendulaTypography(
|
||||||
|
brand = resolveFontFamily(fonts.brand, FontRole.BRAND, context),
|
||||||
|
plain = resolveFontFamily(fonts.plain, FontRole.PLAIN, context),
|
||||||
|
)
|
||||||
|
}
|
||||||
CalendulaTheme(
|
CalendulaTheme(
|
||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
|
typography = typography,
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalUse24HourFormat provides use24Hour,
|
||||||
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -69,16 +142,46 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onWidgetNavConsumed = { requestedNav = null },
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
onInsertConsumed = { requestedInsertForm = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// A persistent corner marker so a debug build is never
|
||||||
|
// mistaken for the production app; compiled out of release.
|
||||||
|
if (BuildConfig.DEBUG) DebugRibbon()
|
||||||
|
}
|
||||||
|
pendingCrashReport?.let { report ->
|
||||||
|
CrashReportDialog(
|
||||||
|
report = report,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(this@MainActivity, report)
|
||||||
|
CrashReporter.clearReport(this@MainActivity)
|
||||||
|
pendingCrashReport = null
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
// Keep the report (Settings can still reach it); just
|
||||||
|
// stop it popping on every launch.
|
||||||
|
CrashReporter.dismissPrompt(this@MainActivity)
|
||||||
|
pendingCrashReport = null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||||
|
CrashReporter.markHealthy(this)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
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 }
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
|
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,21 +195,95 @@ class MainActivity : AppCompatActivity() {
|
|||||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
else -> null
|
else -> null
|
||||||
} ?: return null
|
} ?: return null
|
||||||
|
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||||
|
// it's a navigation, not a file to import, so [navRequestOrNull] owns it.
|
||||||
|
if (uri.host == CALENDAR_PROVIDER_HOST) return null
|
||||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
/**
|
||||||
|
* A prefilled new-event form from an external `ACTION_INSERT` launch — another
|
||||||
|
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
|
||||||
|
* The new event's fields ride as CalendarContract extras; anything omitted
|
||||||
|
* falls back to the in-app "new event" defaults in [buildInsertEventForm].
|
||||||
|
*/
|
||||||
|
private fun Intent.insertFormOrNull(): EventForm? {
|
||||||
|
if (action != Intent.ACTION_INSERT) return null
|
||||||
|
return buildInsertEventForm(
|
||||||
|
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||||
|
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||||
|
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
||||||
|
title = getStringExtra(CalendarContract.Events.TITLE),
|
||||||
|
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
||||||
|
location = getStringExtra(CalendarContract.Events.EVENT_LOCATION),
|
||||||
|
rrule = getStringExtra(CalendarContract.Events.RRULE),
|
||||||
|
zone = TimeZone.currentSystemDefault(),
|
||||||
|
now = Clock.System.now(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Long extra's value, or null when the extra is absent. */
|
||||||
|
private fun Intent.longExtraOrNull(key: String): Long? =
|
||||||
|
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||||
|
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||||
|
* <epochMillis>`. Null for any other intent. The matching manifest filter is
|
||||||
|
* what lets users pick Calendula from the system calendar chooser (issue #9).
|
||||||
|
*/
|
||||||
|
private fun Intent.calendarTimeDateOrNull(): LocalDate? {
|
||||||
|
if (action != Intent.ACTION_VIEW) return null
|
||||||
|
val uri = data ?: return null
|
||||||
|
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||||
|
val segments = uri.pathSegments
|
||||||
|
if (segments.firstOrNull() != "time") return null
|
||||||
|
val millis = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||||
|
return Instant.fromEpochMilliseconds(millis)
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||||
|
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||||
|
// the day view rooted over the default home view (OpenDate source = null).
|
||||||
|
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||||
|
// A widget header tap: open a top-level view with no date drill-in. The
|
||||||
|
// empty string carried by [openViewIntent] means "the default home view".
|
||||||
|
if (hasExtra(EXTRA_OPEN_VIEW)) {
|
||||||
|
val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty()
|
||||||
|
return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name })
|
||||||
|
}
|
||||||
|
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(
|
||||||
@@ -117,12 +294,24 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
// The calendar provider's authority/host. A date tap arrives as
|
||||||
|
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||||
|
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||||
|
|
||||||
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||||
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
private const val EXTRA_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"
|
||||||
|
|
||||||
|
// A widget header tap asking to open a top-level view (no date drill-in).
|
||||||
|
// Its value is the target [CalendarView] name, or "" for the default view.
|
||||||
|
private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW"
|
||||||
|
|
||||||
|
// 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"
|
||||||
@@ -145,14 +334,35 @@ class MainActivity : AppCompatActivity() {
|
|||||||
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 {
|
||||||
@@ -161,5 +371,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the app on a top-level [view] with no date drill-in — a widget
|
||||||
|
* header tap. A null [view] opens the user's default home view (the agenda
|
||||||
|
* widget's "Upcoming" title); a concrete view roots there over the default
|
||||||
|
* home (the month widget's month/year title → [CalendarView.Month]). The
|
||||||
|
* per-view data URI keeps distinct headers' PendingIntents from collapsing.
|
||||||
|
*/
|
||||||
|
fun openViewIntent(context: Context, view: CalendarView?): Intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://view/${view?.name ?: "default"}".toUri()
|
||||||
|
putExtra(EXTRA_OPEN_VIEW, view?.name ?: "")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,27 @@ internal fun toProviderAllDayMinutes(
|
|||||||
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next date on or after [today] carrying [month]/[day] — the date to sample
|
||||||
|
* a yearly all-day reminder's UTC offset at. A managed birthday's DTSTART is an
|
||||||
|
* ancient anchor (1972 for year-less dates), a year whose timezone rules
|
||||||
|
* (pre-DST offsets) differ from today's; sampling the offset there skews every
|
||||||
|
* modern occurrence by hours. Sampling at the upcoming occurrence makes the
|
||||||
|
* stored offset correct for it and its neighbours (only ±1h DST drift remains,
|
||||||
|
* the inherent limit). Feb-29 skips forward to the next leap year.
|
||||||
|
*/
|
||||||
|
internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): LocalDate {
|
||||||
|
var year = today.year
|
||||||
|
// A Feb-29 date is valid only every ~4 years; 8 tries always reaches one.
|
||||||
|
repeat(8) {
|
||||||
|
val candidate = runCatching { LocalDate.of(year, month, day) }.getOrNull()
|
||||||
|
if (candidate != null && !candidate.isBefore(today)) return candidate
|
||||||
|
year++
|
||||||
|
}
|
||||||
|
// Unreachable for real month/day inputs; fall back to the raw anchor.
|
||||||
|
return LocalDate.of(today.year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
* 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
|
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||||
|
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
||||||
|
* hue (the managed special-dates calendars), so they can't drift from the
|
||||||
|
* swatches offered in the colour picker.
|
||||||
|
*/
|
||||||
|
object CalendarColorPalette {
|
||||||
|
val Red = 0xFFD50000.toInt()
|
||||||
|
val Orange = 0xFFE67C00.toInt()
|
||||||
|
val Amber = 0xFFF6BF26.toInt()
|
||||||
|
val Green = 0xFF33B679.toInt()
|
||||||
|
val DarkGreen = 0xFF0B8043.toInt()
|
||||||
|
val Blue = 0xFF039BE5.toInt()
|
||||||
|
val Indigo = 0xFF3F51B5.toInt()
|
||||||
|
val Purple = 0xFF8E24AA.toInt()
|
||||||
|
val Graphite = 0xFF616161.toInt()
|
||||||
|
|
||||||
|
/** The full palette, in swatch-row order. */
|
||||||
|
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
|
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,19 +15,25 @@ 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.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -43,6 +53,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
|
||||||
@@ -55,9 +73,11 @@ interface CalendarDataSource {
|
|||||||
/**
|
/**
|
||||||
* Every master/one-off event of the writable local calendars, mapped for a
|
* Every master/one-off event of the writable local calendars, mapped for a
|
||||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||||
* rows are excluded (see [EventExportProjection]).
|
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||||
|
* given, only those calendars are exported (still intersected with the
|
||||||
|
* eligible set); `null` exports every eligible calendar.
|
||||||
*/
|
*/
|
||||||
fun exportableEvents(): List<IcsEvent>
|
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||||
@@ -85,6 +105,55 @@ 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)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||||
|
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||||
|
* local calendar, so it inherits per-calendar colour/visibility/reminders.
|
||||||
|
*/
|
||||||
|
fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's special-dates calendars, found by their `CAL_SYNC2` marker.
|
||||||
|
* Lets the mirror re-adopt its calendars after the stored ids are lost, and
|
||||||
|
* detect when the user deleted one in system settings.
|
||||||
|
*/
|
||||||
|
fun findManagedCalendars(): List<ManagedCalendarRow>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The managed events in [calendarId] (those carrying a `UID_2445`, not
|
||||||
|
* soft-deleted) as [ManagedEventRow]s — the existing side of the sync diff.
|
||||||
|
*/
|
||||||
|
fun queryManagedEvents(calendarId: Long): List<ManagedEventRow>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a managed event carrying the deterministic [uid] in `UID_2445`;
|
||||||
|
* seeds its reminder rows from [form] (never touched again by sync). Returns
|
||||||
|
* the new `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
|
*/
|
||||||
|
fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite exactly the given managed columns on [eventId] (keys are
|
||||||
|
* `CalendarContract.Events` columns; a null value clears the column). A
|
||||||
|
* bare, targeted update — it never reconciles reminders or attendees, so
|
||||||
|
* user-owned data on the event survives every sync. No-ops on an empty map.
|
||||||
|
*/
|
||||||
|
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the reminders on **every managed** event in [calendarId] with
|
||||||
|
* [minutes] (all-day lead times), each encoded so it fires at
|
||||||
|
* [allDayReminderTimeMinutes] on its upcoming occurrence. Used by the
|
||||||
|
* special-dates section so a reminder change applies to existing events too,
|
||||||
|
* not just future ones — the managed calendars treat reminders as a
|
||||||
|
* calendar-level setting. Skips any non-managed (user) event in the calendar.
|
||||||
|
*/
|
||||||
|
fun applyManagedCalendarReminders(
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
minutes: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||||
* (minutes from local midnight) is the wall-clock time all-day reminders
|
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||||
@@ -162,14 +231,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
|
||||||
@@ -204,7 +311,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +349,142 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
|
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
|
||||||
|
put(CalendarContract.Calendars.NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
|
||||||
|
put(
|
||||||
|
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||||
|
CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||||
|
)
|
||||||
|
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||||
|
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||||
|
// The marker that identifies this as our special-dates calendar,
|
||||||
|
// independent of the (user-editable) display name. CAL_SYNC1 already
|
||||||
|
// holds the description, so the marker lives in CAL_SYNC2.
|
||||||
|
put(MANAGED_MARKER_COLUMN, markerFor(type))
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(localCalendarsUri(), values)
|
||||||
|
?: throw WriteFailedException("create managed calendar")
|
||||||
|
return ContentUris.parseId(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
|
||||||
|
CalendarContract.Calendars.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Calendars._ID, MANAGED_MARKER_COLUMN),
|
||||||
|
"${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " +
|
||||||
|
"${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
|
||||||
|
"$MANAGED_MARKER_COLUMN IS NOT NULL",
|
||||||
|
arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL, LOCAL_ACCOUNT_NAME),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val type = typeForMarker(c.getString(1)) ?: continue
|
||||||
|
add(ManagedCalendarRow(id = c.getLong(0), type = type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> = resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.Events._ID,
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
),
|
||||||
|
// Only our own mirror events (UID prefix), so a user event that somehow
|
||||||
|
// landed in this calendar (e.g. an .ics import) is never seen as
|
||||||
|
// "existing but not desired" and deleted by the diff.
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} LIKE ?",
|
||||||
|
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val uid = c.getString(1)?.takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
add(
|
||||||
|
ManagedEventRow(
|
||||||
|
eventId = c.getLong(0),
|
||||||
|
uid = uid,
|
||||||
|
title = c.getString(2).orEmpty(),
|
||||||
|
dtStartMillis = c.getLong(3),
|
||||||
|
rrule = c.getString(4)?.takeIf { it.isNotEmpty() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
|
||||||
|
// Deterministic UID = the reconciliation key. A re-sync reads it back
|
||||||
|
// (queryManagedEvents) and diffs on it, so a contact never doubles.
|
||||||
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
|
val values = buildEventInsertValues(form, uid, times)
|
||||||
|
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||||
|
?.let(ContentUris::parseId)
|
||||||
|
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
|
||||||
|
// Seeded once, then never re-touched by sync — so a user who moves a
|
||||||
|
// birthday reminder keeps their change. Encoded against the upcoming
|
||||||
|
// occurrence, not the ancient DTSTART anchor (wrong timezone offset).
|
||||||
|
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
insertReminderRows(
|
||||||
|
eventId,
|
||||||
|
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||||
|
if (values.isEmpty()) return
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values.toContentValues(),
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyManagedCalendarReminders(
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
minutes: List<Int>,
|
||||||
|
) {
|
||||||
|
resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
|
||||||
|
// Only our own mirror events, so a stray user event in this calendar
|
||||||
|
// isn't all-day-re-encoded or stripped of its own reminders.
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} LIKE ?",
|
||||||
|
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val eventId = c.getLong(0)
|
||||||
|
// Managed events are all-day: DTSTART is a UTC midnight anchor.
|
||||||
|
val anchor = Instant.ofEpochMilli(c.getLong(1))
|
||||||
|
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
reconcileReminders(
|
||||||
|
eventId,
|
||||||
|
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -254,6 +497,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)
|
||||||
@@ -289,12 +615,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun exportableEvents(): List<IcsEvent> {
|
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
||||||
// Only the local calendars the app owns and can write — synced calendars
|
// Only the local calendars the app owns and can write — synced calendars
|
||||||
// already have a backup (their server). Map id → display name for the
|
// already have a backup (their server). Exclude the managed special-dates
|
||||||
|
// mirror calendars: their events are derived from contacts, not authored
|
||||||
|
// here, and re-materialise from the contact sync — backing them up would
|
||||||
|
// just duplicate them on restore. A non-null [calendarIds] narrows the
|
||||||
|
// export to the user's chosen subset. Map id → display name for the
|
||||||
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||||
val names = calendars()
|
val names = calendars()
|
||||||
.filter { it.isLocal && it.canModifyContents }
|
.filter {
|
||||||
|
it.isLocal && it.canModifyContents && !it.isManaged &&
|
||||||
|
(calendarIds == null || it.id in calendarIds)
|
||||||
|
}
|
||||||
.associate { it.id to it.displayName }
|
.associate { it.id to it.displayName }
|
||||||
if (names.isEmpty()) return emptyList()
|
if (names.isEmpty()) return emptyList()
|
||||||
|
|
||||||
@@ -440,35 +773,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
.distinct()
|
.distinct()
|
||||||
|
|
||||||
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(
|
|
||||||
CalendarContract.Events.CALENDAR_ID,
|
|
||||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
|
||||||
)
|
|
||||||
// A globally-unique UID so a later .ics backup/restore can identify
|
// A globally-unique UID so a later .ics backup/restore can identify
|
||||||
// the event and not duplicate it on re-import (the provider leaves
|
// 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
|
// this null for events it didn't sync). Older rows without one fall
|
||||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||||
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
|
val values = buildEventInsertValues(
|
||||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
form,
|
||||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
uid = "${UUID.randomUUID()}@calendula",
|
||||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
times = form.toWriteTimes(ZoneId.systemDefault()),
|
||||||
// The provider's invariant: recurring rows carry RRULE+DURATION
|
).toContentValues().apply {
|
||||||
// (and no DTEND), one-off rows carry DTEND.
|
|
||||||
if (form.rrule == null) {
|
|
||||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
|
||||||
} else {
|
|
||||||
put(CalendarContract.Events.RRULE, form.rrule)
|
|
||||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
|
||||||
}
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
|
||||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
|
||||||
form.location.trim().takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
|
||||||
form.description.trim().takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
|
||||||
// A null colour just leaves both columns unset (the event inherits
|
// A null colour just leaves both columns unset (the event inherits
|
||||||
// its calendar's colour), so only the key/raw cases are written.
|
// its calendar's colour), so only the key/raw cases are written.
|
||||||
when {
|
when {
|
||||||
@@ -480,10 +793,25 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||||
val eventId = ContentUris.parseId(uri)
|
val eventId = ContentUris.parseId(uri)
|
||||||
// Best effort (spec §8): the event exists at this point — a reminder
|
seedReminders(eventId, form, allDayReminderTimeMinutes)
|
||||||
// that fails to attach is logged, not surfaced as a failed create.
|
// Guests are best-effort like reminders: a row that fails to attach is
|
||||||
encodedReminders(form, allDayReminderTimeMinutes)
|
// logged, not surfaced as a failed create. Calendula never sends an
|
||||||
.forEach { minutes ->
|
// invitation — it only writes the rows; the backend decides delivery.
|
||||||
|
insertAttendees(eventId, form.attendees)
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach [form]'s reminders to a freshly inserted event. Best effort
|
||||||
|
* (spec §8): the event exists at this point — a reminder that fails to
|
||||||
|
* attach is logged, not surfaced as a failed create.
|
||||||
|
*/
|
||||||
|
private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) =
|
||||||
|
insertReminderRows(eventId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||||
|
|
||||||
|
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
|
||||||
|
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
|
||||||
|
encodedMinutes.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)
|
||||||
@@ -493,7 +821,25 @@ 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return eventId
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A managed all-day event's reminders, encoded so they fire at
|
||||||
|
* [allDayReminderTimeMinutes] on its **upcoming** occurrence. The event's
|
||||||
|
* DTSTART is an ancient recurrence anchor (see [nextYearlyOccurrence]); using
|
||||||
|
* it directly would sample a stale timezone offset, so the offset is sampled
|
||||||
|
* at the next occurrence of the anchor's month/day instead.
|
||||||
|
*/
|
||||||
|
private fun managedAllDayReminderMinutes(
|
||||||
|
anchor: LocalDate,
|
||||||
|
reminders: List<Int>,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): List<Int> {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val onDate = nextYearlyOccurrence(anchor.monthValue, anchor.dayOfMonth, LocalDate.now(zone))
|
||||||
|
return reminders
|
||||||
|
.map { toProviderAllDayMinutes(it, onDate, zone, allDayReminderTimeMinutes) }
|
||||||
|
.distinct()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateEvent(
|
override fun updateEvent(
|
||||||
@@ -522,6 +868,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||||
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
|
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(
|
override fun updateOccurrence(
|
||||||
@@ -544,6 +896,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// Whether the provider copied the parent's reminder rows is its
|
// Whether the provider copied the parent's reminder rows is its
|
||||||
// business — reconciling against the actual rows handles both ways.
|
// business — reconciling against the actual rows handles both ways.
|
||||||
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||||
|
reconcileAttendees(exceptionId, form.attendees)
|
||||||
return exceptionId
|
return exceptionId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +1030,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) ->
|
||||||
@@ -685,7 +1167,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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -718,16 +1201,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(
|
||||||
@@ -758,6 +1245,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
|
||||||
|
private fun markerFor(type: SpecialDateType): String = when (type) {
|
||||||
|
SpecialDateType.Birthday -> "$MANAGED_MARKER_PREFIX:birthday"
|
||||||
|
SpecialDateType.Anniversary -> "$MANAGED_MARKER_PREFIX:anniversary"
|
||||||
|
SpecialDateType.Custom -> "$MANAGED_MARKER_PREFIX:custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of [markerFor]; null for a value that isn't one of ours. */
|
||||||
|
private fun typeForMarker(marker: String?): SpecialDateType? =
|
||||||
|
SpecialDateType.entries.firstOrNull { markerFor(it) == marker }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "CalendarDataSource"
|
const val TAG = "CalendarDataSource"
|
||||||
|
|
||||||
@@ -766,5 +1264,20 @@ 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"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column and namespace for the special-dates marker (shared with the
|
||||||
|
* calendar projection, which reads it into [CalendarSource.isManaged]).
|
||||||
|
* CAL_SYNC1 already holds the local-calendar description, so it uses CAL_SYNC2.
|
||||||
|
*/
|
||||||
|
val MANAGED_MARKER_COLUMN: String = CalendarProjection.MANAGED_MARKER_COLUMN
|
||||||
|
const val MANAGED_MARKER_PREFIX = CalendarProjection.MANAGED_MARKER_PREFIX
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,5 +24,12 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
// A special-dates mirror calendar, recognised by its durable CAL_SYNC2
|
||||||
|
// marker (only meaningful on the local calendars the app owns). This is
|
||||||
|
// the source of truth for the editor lock — independent of any stored
|
||||||
|
// preference that a backup restore could have wiped.
|
||||||
|
isManaged = isLocal &&
|
||||||
|
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
||||||
|
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,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]).
|
||||||
@@ -34,8 +41,9 @@ interface CalendarRepository {
|
|||||||
/**
|
/**
|
||||||
* Every event of the writable local calendars, ready to serialise into a
|
* Every event of the writable local calendars, ready to serialise into a
|
||||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||||
|
* [calendarIds] narrows the export to a chosen subset; `null` exports all.
|
||||||
*/
|
*/
|
||||||
suspend fun exportEvents(): List<IcsEvent>
|
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
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
|
||||||
@@ -14,6 +15,7 @@ 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.first
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
@@ -55,11 +57,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
|
||||||
@@ -71,15 +74,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) }
|
||||||
|
|
||||||
@@ -101,7 +118,8 @@ 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 exportEvents(calendarIds: Set<Long>?) =
|
||||||
|
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||||
|
|
||||||
override suspend fun importEvents(
|
override suspend fun importEvents(
|
||||||
targetCalendarId: Long,
|
targetCalendarId: Long,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
@@ -23,26 +24,29 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
attendees: List<Attendee>,
|
attendees: List<Attendee>,
|
||||||
reminders: List<Reminder>,
|
reminders: List<Reminder>,
|
||||||
): EventDetail? {
|
): EventDetail? {
|
||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||||
|
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||||
if (begin < 0L) {
|
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
|
||||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
// Dropping negatives made every occurrence of such a series un-openable
|
||||||
|
// (the detail loads the ancient series-master DTSTART), see issue #34.
|
||||||
|
if (isNull(EventDetailProjection.IDX_DTSTART)) {
|
||||||
|
Log.w(TAG, "Dropping event with missing dtstart")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||||
// specific occurrence supply the real per-occurrence times from
|
// specific occurrence supply the real per-occurrence times from
|
||||||
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
|
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
|
||||||
|
// but dropping the row would make the event un-openable — the same trap as
|
||||||
|
// the pre-1970 DTSTART bug above (issue #34): it would surface as the
|
||||||
|
// generic error screen with no way to open the event and fix it. Clamp to a
|
||||||
|
// zero-length event instead (matching SearchMapper's coerceAtLeast).
|
||||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||||
begin
|
begin
|
||||||
} else {
|
} else {
|
||||||
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
|
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||||
if (rawEnd < begin) {
|
|
||||||
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
rawEnd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||||
|
|||||||
@@ -49,6 +49,42 @@ internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (
|
|||||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values for a brand-new Events row: identity, times (the provider's
|
||||||
|
* invariant — recurring rows carry RRULE+DURATION and no DTEND, one-off rows
|
||||||
|
* carry DTEND), availability/access level and trimmed optional text. Shared by
|
||||||
|
* the user-event and managed-event insert paths, which differ only in the
|
||||||
|
* [uid] they stamp; colour and attendees are user-event concerns the caller
|
||||||
|
* layers on top.
|
||||||
|
*/
|
||||||
|
internal fun buildEventInsertValues(
|
||||||
|
form: EventForm,
|
||||||
|
uid: String,
|
||||||
|
times: EventWriteTimes,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.UID_2445, uid)
|
||||||
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
|
if (form.rrule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, form.rrule)
|
||||||
|
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||||
|
}
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
|
form.location.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
form.description.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dirty-checked column values for updating an existing Events row: only what
|
* Dirty-checked column values for updating an existing Events row: only what
|
||||||
* the user actually changed is written, so untouched fields can't stomp
|
* the user actually changed is written, so untouched fields can't stomp
|
||||||
@@ -118,8 +154,14 @@ internal fun buildEventUpdateValues(
|
|||||||
* provider clone the series row and apply these on top. Unlike the series
|
* provider clone the series row and apply these on top. Unlike the series
|
||||||
* update there is no dirty check — the exception is a fresh row, so every
|
* update there is no dirty check — the exception is a fresh row, so every
|
||||||
* form-backed column is written (empty optionals as explicit NULLs, since the
|
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||||
* clone starts from the parent's values). An exception is a single event:
|
* clone starts from the parent's values).
|
||||||
* DTEND, never RRULE/DURATION.
|
*
|
||||||
|
* The occurrence's length travels as DURATION, never DTEND: the provider
|
||||||
|
* rejects DTEND on an exception outright (`CalendarProvider2`:
|
||||||
|
* "Exceptions can't overwrite dtend") and derives the instance end from
|
||||||
|
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
||||||
|
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
||||||
|
* "only this event" fail on-device (Codeberg #16).
|
||||||
*/
|
*/
|
||||||
internal fun buildOccurrenceExceptionValues(
|
internal fun buildOccurrenceExceptionValues(
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
@@ -131,7 +173,7 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
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)
|
||||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the app's special-dates calendars, discovered by the marker stamped in
|
||||||
|
* its `CAL_SYNC2` column — so the mirror can re-adopt its calendars even if the
|
||||||
|
* stored ids in preferences were lost (e.g. an app-data wipe).
|
||||||
|
*/
|
||||||
|
data class ManagedCalendarRow(val id: Long, val type: SpecialDateType)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A managed event as read back for the sync diff. [uid] is the deterministic
|
||||||
|
* `Events.UID_2445` (`contact-<type>:<lookupKey>@calendula`) the mirror keys on;
|
||||||
|
* [title], [dtStartMillis] and [rrule] are the managed columns compared against
|
||||||
|
* the desired state to decide whether a targeted update is needed.
|
||||||
|
*/
|
||||||
|
data class ManagedEventRow(
|
||||||
|
val eventId: Long,
|
||||||
|
val uid: String,
|
||||||
|
val title: String,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val rrule: String?,
|
||||||
|
)
|
||||||
@@ -15,9 +15,17 @@ internal object CalendarProjection {
|
|||||||
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
|
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
|
||||||
// so the mapper only reads it for local calendars).
|
// so the mapper only reads it for local calendars).
|
||||||
DESCRIPTION_COLUMN,
|
DESCRIPTION_COLUMN,
|
||||||
|
// The special-dates marker (CAL_SYNC2) — the durable identity the app
|
||||||
|
// uses to recognise its own managed calendars, independent of any
|
||||||
|
// stored preference id (which a backup restore / data wipe can lose).
|
||||||
|
MANAGED_MARKER_COLUMN,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||||
|
const val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
|
||||||
|
|
||||||
|
/** Namespace prefix of every managed-calendar [MANAGED_MARKER_COLUMN] value. */
|
||||||
|
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
const val IDX_DISPLAY_NAME = 1
|
const val IDX_DISPLAY_NAME = 1
|
||||||
@@ -27,6 +35,7 @@ internal object CalendarProjection {
|
|||||||
const val IDX_VISIBLE = 5
|
const val IDX_VISIBLE = 5
|
||||||
const val IDX_ACCESS_LEVEL = 6
|
const val IDX_ACCESS_LEVEL = 6
|
||||||
const val IDX_DESCRIPTION = 7
|
const val IDX_DESCRIPTION = 7
|
||||||
|
const val IDX_MANAGED_MARKER = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object InstanceProjection {
|
internal object InstanceProjection {
|
||||||
@@ -139,6 +148,44 @@ internal object EventExportProjection {
|
|||||||
const val IDX_CALENDAR_ID = 13
|
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,44 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
|
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? {
|
||||||
|
// A pre-1970 series anchor is a legitimately negative epoch-millis DTSTART
|
||||||
|
// (see EventDetailMapper / issue #34); drop only a genuinely absent one, so
|
||||||
|
// long-running birthdays/anniversaries still surface in search.
|
||||||
|
if (isNull(SearchProjection.IDX_DTSTART)) return null
|
||||||
|
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
|
||||||
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
|
|
||||||
|
|
||||||
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the localized calendar names/templates and per-type colours the
|
||||||
|
* mirror creates its calendars with. The colours are picked from the shared
|
||||||
|
* palette so managed calendars look native alongside user calendars.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AndroidSpecialDatesCalendarSpec @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : SpecialDatesCalendarSpec {
|
||||||
|
|
||||||
|
override fun displayName(type: SpecialDateType): String = context.getString(
|
||||||
|
when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.special_dates_calendar_custom
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString(
|
||||||
|
when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.special_dates_default_title_custom
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun color(type: SpecialDateType): Int = when (type) {
|
||||||
|
SpecialDateType.Birthday -> CalendarColorPalette.Purple
|
||||||
|
SpecialDateType.Anniversary -> CalendarColorPalette.Red
|
||||||
|
SpecialDateType.Custom -> CalendarColorPalette.Blue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import android.provider.ContactsContract.CommonDataKinds.Event
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the dated `Event` rows (birthdays, anniversaries, custom dates) of the
|
||||||
|
* device's contacts. Read-only and offline — the one-way source for the
|
||||||
|
* special-dates mirror. Requires `READ_CONTACTS`; returns an empty list when
|
||||||
|
* the permission is absent so the sync can degrade to a stalled state rather
|
||||||
|
* than crash.
|
||||||
|
*/
|
||||||
|
interface ContactSpecialDatesDataSource {
|
||||||
|
fun hasPermission(): Boolean
|
||||||
|
|
||||||
|
/** All usable contact special-dates, deduplicated per contact and type. */
|
||||||
|
fun readSpecialDates(): List<ContactSpecialDate>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
|
||||||
|
fun Context.hasContactsPermission(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||||
|
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||||
|
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
|
||||||
|
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
|
||||||
|
else -> SpecialDateType.Custom
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ContactSpecialDatesDataSource {
|
||||||
|
|
||||||
|
override fun hasPermission(): Boolean = context.hasContactsPermission()
|
||||||
|
|
||||||
|
override fun readSpecialDates(): List<ContactSpecialDate> {
|
||||||
|
if (!hasPermission()) return emptyList()
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
// A contact can carry the same date more than once (multiple raw contacts
|
||||||
|
// under one aggregate); dedup on the mirror's reconciliation key so exact
|
||||||
|
// duplicates collapse while genuinely distinct dates (two custom events on
|
||||||
|
// one contact) are all kept. Ordered by Data._ID so which of two truly
|
||||||
|
// conflicting rows wins is stable across syncs (no event ping-pong).
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
val result = ArrayList<ContactSpecialDate>()
|
||||||
|
runCatching {
|
||||||
|
resolver.query(
|
||||||
|
ContactsContract.Data.CONTENT_URI,
|
||||||
|
PROJECTION,
|
||||||
|
"${ContactsContract.Data.MIMETYPE} = ?",
|
||||||
|
arrayOf(Event.CONTENT_ITEM_TYPE),
|
||||||
|
"${ContactsContract.Data._ID} ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
||||||
|
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
||||||
|
val idxLabel = c.getColumnIndexOrThrow(Event.LABEL)
|
||||||
|
val idxLookup = c.getColumnIndexOrThrow(ContactsContract.Data.LOOKUP_KEY)
|
||||||
|
val idxName = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
|
||||||
|
val type = specialDateTypeForRawEventType(c.getInt(idxType))
|
||||||
|
val date = ContactSpecialDate(
|
||||||
|
lookupKey = lookup,
|
||||||
|
displayName = c.getString(idxName)?.trim().orEmpty(),
|
||||||
|
type = type,
|
||||||
|
month = parts.month,
|
||||||
|
day = parts.day,
|
||||||
|
year = parts.year,
|
||||||
|
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
if (seen.add(date.managedUid())) result += date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ContactSpecialDates"
|
||||||
|
|
||||||
|
val PROJECTION = arrayOf(
|
||||||
|
ContactsContract.Data.LOOKUP_KEY,
|
||||||
|
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
|
||||||
|
Event.START_DATE,
|
||||||
|
Event.TYPE,
|
||||||
|
Event.LABEL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules the contact special-dates mirror. Birthdays change rarely, so this
|
||||||
|
* is deliberately cheap: a **daily** periodic reconcile, plus an immediate run
|
||||||
|
* on enable / "Sync now" and a debounced one when the app is foregrounded. No
|
||||||
|
* ContentObserver — it would need the process alive and buys almost nothing for
|
||||||
|
* once-a-year events. Everything stays offline (local contacts → local calendar).
|
||||||
|
*/
|
||||||
|
object SpecialDatesScheduler {
|
||||||
|
|
||||||
|
private const val WORK_NAME = "special-dates-sync"
|
||||||
|
private const val WORK_NAME_NOW = "special-dates-sync-now"
|
||||||
|
private const val WORK_NAME_FOREGROUND = "special-dates-sync-foreground"
|
||||||
|
private const val KEY_FOREGROUND = "foreground"
|
||||||
|
|
||||||
|
/** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */
|
||||||
|
fun apply(context: Context, enabled: Boolean) {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
if (!enabled) {
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME)
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME_FOREGROUND)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val request = PeriodicWorkRequestBuilder<SpecialDatesSyncWorker>(1, TimeUnit.DAYS)
|
||||||
|
// Delay the first periodic run so it never overlaps an immediate run.
|
||||||
|
.setInitialDelay(1, TimeUnit.DAYS)
|
||||||
|
.build()
|
||||||
|
workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one reconcile immediately. [foreground] runs (the app resuming) are
|
||||||
|
* debounced inside the worker and use their own work name, so a frequent
|
||||||
|
* foreground resync can never REPLACE — and swallow — a pending enable /
|
||||||
|
* "Sync now" run, which always syncs. The engine serializes the two if they
|
||||||
|
* overlap.
|
||||||
|
*/
|
||||||
|
fun runNow(context: Context, foreground: Boolean = false) {
|
||||||
|
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
|
||||||
|
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
|
||||||
|
.build()
|
||||||
|
val name = if (foreground) WORK_NAME_FOREGROUND else WORK_NAME_NOW
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val INPUT_FOREGROUND = KEY_FOREGROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the [SpecialDatesSyncEngine]. Pulls its collaborators through a Hilt
|
||||||
|
* [EntryPoint] so it works under WorkManager's default worker factory. Records
|
||||||
|
* the run for the settings status line; a missing permission parks the feature
|
||||||
|
* in a stalled state (surfaced in settings) rather than retrying forever.
|
||||||
|
*/
|
||||||
|
class SpecialDatesSyncWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun settingsPrefs(): SettingsPrefs
|
||||||
|
fun syncEngine(): SpecialDatesSyncEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||||
|
val prefs = deps.settingsPrefs()
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
// A foreground resume can fire often — skip if we synced recently.
|
||||||
|
val foreground = inputData.getBoolean(SpecialDatesScheduler.INPUT_FOREGROUND, false)
|
||||||
|
if (foreground && now - prefs.specialDatesLastForegroundSync.first() < FOREGROUND_DEBOUNCE_MILLIS) {
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
when (val result = deps.syncEngine().sync()) {
|
||||||
|
// The engine re-checks the toggle, so already-queued work never
|
||||||
|
// revives a disabled feature; don't record a run either.
|
||||||
|
SpecialDatesSyncResult.Disabled -> return Result.success()
|
||||||
|
else -> {
|
||||||
|
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
|
||||||
|
SpecialDatesStalledReason.PermissionRevoked
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
prefs.recordSpecialDatesRun(now, stalled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
|
||||||
|
Result.success()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// A revoked calendar/contacts permission won't fix itself on retry —
|
||||||
|
// park the feature in a stalled state (surfaced in settings) instead
|
||||||
|
// of retrying with backoff forever.
|
||||||
|
Log.w(TAG, "Special-dates sync lacks a required permission", e)
|
||||||
|
prefs.recordSpecialDatesRun(now, SpecialDatesStalledReason.PermissionRevoked)
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Special-dates sync failed", e)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SpecialDatesSync"
|
||||||
|
|
||||||
|
/** Skip a foreground-triggered sync if one ran within this window (4h). */
|
||||||
|
private const val FOREGROUND_DEBOUNCE_MILLIS = 4L * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Outcome of one mirror reconcile, reported back so the worker can record status. */
|
||||||
|
enum class SpecialDatesSyncResult { Success, Disabled, PermissionMissing }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localized, per-type presentation the engine needs but can't derive purely
|
||||||
|
* (calendar display name, colour, and the default title template). Kept behind
|
||||||
|
* an interface so the engine's diff stays unit-testable without Android
|
||||||
|
* resources.
|
||||||
|
*/
|
||||||
|
interface SpecialDatesCalendarSpec {
|
||||||
|
fun displayName(type: SpecialDateType): String
|
||||||
|
fun color(type: SpecialDateType): Int
|
||||||
|
fun defaultTitleTemplate(type: SpecialDateType): String
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored title template for [type], falling back to the localized default
|
||||||
|
* when blank/absent — the one resolution rule shared by the sync (what gets
|
||||||
|
* written) and the settings editor (what gets shown).
|
||||||
|
*/
|
||||||
|
fun SpecialDatesCalendarSpec.resolveTitleTemplate(
|
||||||
|
type: SpecialDateType,
|
||||||
|
stored: Map<SpecialDateType, String>,
|
||||||
|
): String = stored[type]?.takeIf { it.isNotBlank() } ?: defaultTitleTemplate(type)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors contact birthdays/anniversaries/custom dates into local calendars,
|
||||||
|
* one per type. Every reconcile is an idempotent diff keyed on the deterministic
|
||||||
|
* `UID_2445` ([managedEventUid]): new contacts are inserted (seeding user-owned
|
||||||
|
* fields once), changed contacts get a *targeted* managed-column update, and
|
||||||
|
* removed contacts are deleted — so a user's own edits (reminders, location,
|
||||||
|
* notes) are never clobbered. See docs/design/contact-special-dates.md.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SpecialDatesSyncEngine @Inject constructor(
|
||||||
|
private val contacts: ContactSpecialDatesDataSource,
|
||||||
|
private val calendars: CalendarDataSource,
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
private val spec: SpecialDatesCalendarSpec,
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Serializes calendar-lifecycle work: two overlapping syncs (the daily job
|
||||||
|
// racing a "Sync now"/foreground run) would each see no managed calendar and
|
||||||
|
// both create one; a teardown racing an in-flight sync would delete calendars
|
||||||
|
// the sync then recreates. Holding this for the whole of sync()/teardown()
|
||||||
|
// makes those check-then-act sequences atomic, and — because sync() re-reads
|
||||||
|
// the enabled flag inside the lock — a teardown always wins the race.
|
||||||
|
private val lifecycleMutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile every enabled type against the device's contacts. Returns why it
|
||||||
|
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
|
||||||
|
*/
|
||||||
|
suspend fun sync(): SpecialDatesSyncResult = lifecycleMutex.withLock {
|
||||||
|
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
|
||||||
|
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
|
||||||
|
|
||||||
|
val enabledTypes = prefs.specialDatesTypes.first()
|
||||||
|
val calendarByType = reconcileCalendars(enabledTypes)
|
||||||
|
|
||||||
|
val desiredByType = contacts.readSpecialDates().groupBy { it.type }
|
||||||
|
val reminderCtx = readReminderContext()
|
||||||
|
val templates = prefs.specialDatesTitleTemplates.first()
|
||||||
|
val showYear = prefs.specialDatesShowYear.first()
|
||||||
|
|
||||||
|
calendarByType.forEach { (type, calendarId) ->
|
||||||
|
val template = spec.resolveTitleTemplate(type, templates)
|
||||||
|
syncType(
|
||||||
|
calendarId = calendarId,
|
||||||
|
type = type,
|
||||||
|
contactsOfType = desiredByType[type].orEmpty(),
|
||||||
|
template = template,
|
||||||
|
showYear = showYear,
|
||||||
|
reminderCtx = reminderCtx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SpecialDatesSyncResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the reminder default for a type's managed calendar and apply it to
|
||||||
|
* **all** its existing events too (not just future ones) — for managed
|
||||||
|
* calendars the reminder is a calendar-level setting. Persists the per-calendar
|
||||||
|
* all-day override so new events keep matching. No-op if the calendar for
|
||||||
|
* [type] doesn't exist yet.
|
||||||
|
*/
|
||||||
|
suspend fun applyReminders(type: SpecialDateType, override: ReminderOverride) {
|
||||||
|
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||||
|
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||||
|
val minutes = when (override) {
|
||||||
|
ReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||||
|
ReminderOverride.None -> emptyList()
|
||||||
|
is ReminderOverride.Minutes -> override.minutes
|
||||||
|
}
|
||||||
|
calendars.applyManagedCalendarReminders(
|
||||||
|
calendarId = calendarId,
|
||||||
|
allDayReminderTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||||
|
minutes = minutes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
|
||||||
|
suspend fun teardown() = lifecycleMutex.withLock {
|
||||||
|
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
|
||||||
|
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure each enabled type has exactly one managed calendar (adopting an
|
||||||
|
* existing one, or the stored id if it still exists, else creating one) and
|
||||||
|
* that disabled types have none. Returns the calendar id per enabled type.
|
||||||
|
*/
|
||||||
|
private suspend fun reconcileCalendars(enabledTypes: Set<SpecialDateType>): Map<SpecialDateType, Long> {
|
||||||
|
val foundByType = calendars.findManagedCalendars()
|
||||||
|
.groupBy({ it.type }, { it.id })
|
||||||
|
.mapValues { it.value.first() }
|
||||||
|
val stored = prefs.specialDatesCalendars.first()
|
||||||
|
val result = LinkedHashMap<SpecialDateType, Long>()
|
||||||
|
for (type in SpecialDateType.entries) {
|
||||||
|
// A stored id counts only if the calendar still exists (the user may
|
||||||
|
// have deleted it in system settings); otherwise adopt a found one.
|
||||||
|
val existingId = stored[type]?.takeIf { id -> foundByType.containsValue(id) }
|
||||||
|
?: foundByType[type]
|
||||||
|
if (type in enabledTypes) {
|
||||||
|
val id = existingId ?: createCalendar(type)
|
||||||
|
if (stored[type] != id) prefs.setSpecialDatesCalendarId(type, id)
|
||||||
|
result[type] = id
|
||||||
|
} else {
|
||||||
|
if (existingId != null) calendars.deleteCalendar(existingId)
|
||||||
|
if (stored.containsKey(type)) prefs.setSpecialDatesCalendarId(type, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createCalendar(type: SpecialDateType): Long {
|
||||||
|
// reconcileCalendars persists the id — the single place ids are recorded.
|
||||||
|
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type)
|
||||||
|
// Seed a useful all-day reminder default (on the day + a week before), so
|
||||||
|
// birthdays get lead time out of the box. Per-calendar, user-adjustable —
|
||||||
|
// only set when the user hasn't already configured this calendar.
|
||||||
|
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||||
|
prefs.setCalendarAllDayReminderOverride(
|
||||||
|
id,
|
||||||
|
ReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncType(
|
||||||
|
calendarId: Long,
|
||||||
|
type: SpecialDateType,
|
||||||
|
contactsOfType: List<ContactSpecialDate>,
|
||||||
|
template: String,
|
||||||
|
showYear: Boolean,
|
||||||
|
reminderCtx: ReminderContext,
|
||||||
|
) {
|
||||||
|
val built = contactsOfType.map { sd ->
|
||||||
|
buildManagedEvent(calendarId, type, sd, template, showYear, reminderCtx)
|
||||||
|
}
|
||||||
|
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
|
||||||
|
val formByUid = built.associate { it.desired.uid to it.form }
|
||||||
|
diff.insertUids.forEach { uid ->
|
||||||
|
calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes)
|
||||||
|
}
|
||||||
|
diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) }
|
||||||
|
diff.deleteEventIds.forEach { calendars.deleteEvent(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildManagedEvent(
|
||||||
|
calendarId: Long,
|
||||||
|
type: SpecialDateType,
|
||||||
|
sd: ContactSpecialDate,
|
||||||
|
template: String,
|
||||||
|
showYear: Boolean,
|
||||||
|
reminderCtx: ReminderContext,
|
||||||
|
): BuiltManagedEvent {
|
||||||
|
val anchor = sd.anchorDate()
|
||||||
|
val start = LocalDateTime(anchor, LocalTime(0, 0))
|
||||||
|
// The source year is static and correct on every occurrence (unlike age).
|
||||||
|
val year = if (showYear) sd.year else null
|
||||||
|
val title = renderSpecialDateTitle(template, sd.displayName, year)
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = calendarId,
|
||||||
|
title = title,
|
||||||
|
isAllDay = true,
|
||||||
|
start = start,
|
||||||
|
end = start,
|
||||||
|
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||||
|
availability = Availability.Free,
|
||||||
|
rrule = YEARLY_RRULE,
|
||||||
|
)
|
||||||
|
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
|
||||||
|
return BuiltManagedEvent(
|
||||||
|
desired = ManagedEventDesired(
|
||||||
|
uid = sd.managedUid(),
|
||||||
|
title = title,
|
||||||
|
dtStartMillis = dtStartMillis,
|
||||||
|
rrule = YEARLY_RRULE,
|
||||||
|
),
|
||||||
|
form = form,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Managed events are always all-day, so only the all-day defaults apply.
|
||||||
|
private suspend fun readReminderContext(): ReminderContext = ReminderContext(
|
||||||
|
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
|
||||||
|
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
|
||||||
|
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReminderContext(
|
||||||
|
val allDayGlobal: List<Int>,
|
||||||
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
|
val allDayTimeMinutes: Int,
|
||||||
|
) {
|
||||||
|
/** Same semantics as `resolveDefaultReminder`: present-empty = explicit none. */
|
||||||
|
fun resolveAllDay(calendarId: Long): List<Int> =
|
||||||
|
allDayOverrides[calendarId] ?: allDayGlobal
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** "On the day" (0) + one week before (7 days), as all-day lead minutes. */
|
||||||
|
val DEFAULT_REMINDER_MINUTES = listOf(0, 7 * 24 * 60)
|
||||||
|
val YEARLY_RRULE = SimpleRecurrence(freq = RecurrenceFreq.Yearly).toRRule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The managed columns of a desired event, compared against the existing row. */
|
||||||
|
internal data class ManagedEventDesired(
|
||||||
|
val uid: String,
|
||||||
|
val title: String,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val rrule: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class ManagedFieldUpdate(val eventId: Long, val columns: Map<String, Any?>)
|
||||||
|
|
||||||
|
internal data class ManagedDiff(
|
||||||
|
val insertUids: List<String>,
|
||||||
|
val updates: List<ManagedFieldUpdate>,
|
||||||
|
val deleteEventIds: List<Long>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The idempotent diff at the heart of the mirror, keyed on `UID_2445`:
|
||||||
|
* desired-not-existing → insert, existing-not-desired → delete, and for events
|
||||||
|
* in both only the *changed* managed columns (title/dtstart/rrule) are emitted —
|
||||||
|
* so a re-run with no contact changes produces nothing, and a managed update
|
||||||
|
* never touches user-owned columns or reminder rows. Pure, so it's unit-tested.
|
||||||
|
*/
|
||||||
|
internal fun diffManagedEvents(
|
||||||
|
desired: List<ManagedEventDesired>,
|
||||||
|
existing: List<ManagedEventRow>,
|
||||||
|
): ManagedDiff {
|
||||||
|
val desiredByUid = desired.associateBy { it.uid }
|
||||||
|
val existingByUid = existing.associateBy { it.uid }
|
||||||
|
val insertUids = desired.filter { it.uid !in existingByUid }.map { it.uid }
|
||||||
|
val deleteEventIds = existing.filter { it.uid !in desiredByUid }.map { it.eventId }
|
||||||
|
val updates = buildList {
|
||||||
|
for (d in desired) {
|
||||||
|
val row = existingByUid[d.uid] ?: continue
|
||||||
|
val columns = buildMap<String, Any?> {
|
||||||
|
if (row.title != d.title) put(CalendarContract.Events.TITLE, d.title)
|
||||||
|
if (row.dtStartMillis != d.dtStartMillis) {
|
||||||
|
put(CalendarContract.Events.DTSTART, d.dtStartMillis)
|
||||||
|
}
|
||||||
|
if (row.rrule != d.rrule) put(CalendarContract.Events.RRULE, d.rrule)
|
||||||
|
}
|
||||||
|
if (columns.isNotEmpty()) add(ManagedFieldUpdate(row.eventId, columns))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ManagedDiff(insertUids, updates, deleteEventIds)
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -45,6 +49,18 @@ abstract class DataBindModule {
|
|||||||
abstract fun bindReminderAlertStore(
|
abstract fun bindReminderAlertStore(
|
||||||
impl: AndroidReminderAlertStore,
|
impl: AndroidReminderAlertStore,
|
||||||
): ReminderAlertStore
|
): ReminderAlertStore
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindContactSpecialDatesDataSource(
|
||||||
|
impl: AndroidContactSpecialDatesDataSource,
|
||||||
|
): ContactSpecialDatesDataSource
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSpecialDatesCalendarSpec(
|
||||||
|
impl: AndroidSpecialDatesCalendarSpec,
|
||||||
|
): SpecialDatesCalendarSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.fonts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
import android.graphics.fonts.Font as PlatformFont
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage for user-loaded custom fonts (issue #19). A font a user picks via the
|
||||||
|
* system file picker is copied into the app's private storage — one file per
|
||||||
|
* [FontRole] — so the selection survives even if the original is later moved or
|
||||||
|
* deleted, and never leaves the app. The chosen file is validated as a real font
|
||||||
|
* before it replaces the previous one, so a bad pick can't wedge the app-wide
|
||||||
|
* typography.
|
||||||
|
*/
|
||||||
|
object CustomFontStore {
|
||||||
|
|
||||||
|
private fun dir(context: Context): File = File(context.filesDir, "fonts")
|
||||||
|
|
||||||
|
/** The stored font file for [role] (may not exist yet). */
|
||||||
|
fun file(context: Context, role: FontRole): File =
|
||||||
|
File(dir(context), "${role.name.lowercase(Locale.ROOT)}.ttf")
|
||||||
|
|
||||||
|
fun exists(context: Context, role: FontRole): Boolean =
|
||||||
|
file(context, role).let { it.exists() && it.length() > 0 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy [uri] into per-role storage, first validating that it parses as a
|
||||||
|
* font. Returns true on success; on any failure the previous file is left
|
||||||
|
* untouched and false is returned (the caller keeps the old selection).
|
||||||
|
*/
|
||||||
|
fun import(context: Context, role: FontRole, uri: Uri): Boolean {
|
||||||
|
val target = file(context, role)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val tmp = File(target.parentFile, "${role.name.lowercase(Locale.ROOT)}.tmp")
|
||||||
|
return try {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
tmp.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
} ?: return false
|
||||||
|
// Font.Builder throws IOException for anything that isn't a valid
|
||||||
|
// font file (API 29+, which is our minSdk) — a cheap, reliable check.
|
||||||
|
PlatformFont.Builder(tmp).build()
|
||||||
|
if (target.exists() && !target.delete()) return false
|
||||||
|
tmp.renameTo(target)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
tmp.delete()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the custom font for [role] (used when switching away from it). */
|
||||||
|
fun clear(context: Context, role: FontRole) {
|
||||||
|
file(context, role).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.ics
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -24,7 +25,38 @@ class IcsExporter @Inject constructor(
|
|||||||
fun writeDocument(uri: Uri, content: String) {
|
fun writeDocument(uri: Uri, content: String) {
|
||||||
context.contentResolver.openOutputStream(uri)?.use { out ->
|
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||||
out.write(content.toByteArray(Charsets.UTF_8))
|
out.write(content.toByteArray(Charsets.UTF_8))
|
||||||
} ?: throw IOException("Could not open $uri for writing")
|
// Only the scheme — the full Uri can embed the user's chosen filename.
|
||||||
|
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write [content] to [fileName] inside the persisted SAF tree [folder],
|
||||||
|
* overwriting it if it already exists (the automatic-backup destination).
|
||||||
|
* Requires a persisted write grant on [folder]. Throws on failure.
|
||||||
|
*/
|
||||||
|
fun writeToFolder(folder: Uri, fileName: String, content: String) {
|
||||||
|
val dir = DocumentFile.fromTreeUri(context, folder)
|
||||||
|
?: throw IOException("Backup folder is not accessible")
|
||||||
|
if (!dir.exists() || !dir.canWrite()) {
|
||||||
|
throw IOException("Backup folder is missing or not writable")
|
||||||
|
}
|
||||||
|
// Reuse the canonical file if present and clean up any "name (1).ics"
|
||||||
|
// duplicates SAF may have created if two runs ever raced — so we always
|
||||||
|
// converge on a single overwritten file.
|
||||||
|
val base = fileName.substringBeforeLast('.')
|
||||||
|
val ext = fileName.substringAfterLast('.', "")
|
||||||
|
var target: DocumentFile? = null
|
||||||
|
for (child in dir.listFiles()) {
|
||||||
|
val name = child.name ?: continue
|
||||||
|
when {
|
||||||
|
name == fileName -> target = child
|
||||||
|
name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val file = target
|
||||||
|
?: dir.createFile(MIME_CALENDAR, fileName)
|
||||||
|
?: throw IOException("Could not create backup file in the chosen folder")
|
||||||
|
writeDocument(file.uri, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,5 +73,6 @@ class IcsExporter @Inject constructor(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SHARE_DIR = "shared_ics"
|
const val SHARE_DIR = "shared_ics"
|
||||||
|
const val MIME_CALENDAR = "text/calendar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ class CalendarPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-side preference for "calendars the user has disabled in this app" — a
|
||||||
|
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
|
||||||
|
* every surface (drawer filter, event-form picker, import picker) and its
|
||||||
|
* events never appear; it stays listed only in Settings → Calendars so it can
|
||||||
|
* be re-enabled. Stored exactly like the hidden set; never touches the
|
||||||
|
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
|
||||||
|
*/
|
||||||
|
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||||
|
prefs[DISABLED_IDS_KEY].orEmpty()
|
||||||
|
.split(',')
|
||||||
|
.mapNotNull { it.trim().toLongOrNull() }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
prefs.remove(DISABLED_IDS_KEY)
|
||||||
|
} else {
|
||||||
|
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The calendar the user last created an event in; preselected in the
|
* 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,23 @@ 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.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
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
|
||||||
@@ -18,18 +33,52 @@ 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 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the Agenda screen treats events that have already finished today: [SHOW]
|
||||||
|
* leaves them as-is (the historical behaviour), [DIM] fades them, [HIDE] drops
|
||||||
|
* them from the list entirely.
|
||||||
|
*/
|
||||||
|
enum class PastEventDisplay { SHOW, DIM, HIDE }
|
||||||
|
|
||||||
|
/** Parse a stored [PastEventDisplay.name]; unknown/null falls back to [default]. */
|
||||||
|
fun parsePastEventDisplay(
|
||||||
|
stored: String?,
|
||||||
|
default: PastEventDisplay = PastEventDisplay.SHOW,
|
||||||
|
): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
|
||||||
|
* device's `DateFormat.is24HourFormat` value).
|
||||||
|
*/
|
||||||
|
fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
|
||||||
|
TimeFormatPref.AUTO -> systemIs24Hour
|
||||||
|
TimeFormatPref.TWELVE_HOUR -> false
|
||||||
|
TimeFormatPref.TWENTY_FOUR_HOUR -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the preference to a concrete first-day-of-week. [WeekStartPref.Auto]
|
||||||
|
* reads the locale's convention (e.g. Monday in DE, Sunday in en-US).
|
||||||
*/
|
*/
|
||||||
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +103,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,8 +114,197 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
|
||||||
|
* strings — "system", "custom", or a bundled font's token — resolved to a
|
||||||
|
* FontFamily at the theme layer; an unknown token degrades to the default.
|
||||||
|
*/
|
||||||
|
val brandFont: Flow<String> = store.data.map { prefs ->
|
||||||
|
prefs[BRAND_FONT_KEY] ?: FONT_SYSTEM_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
val plainFont: Flow<String> = store.data.map { prefs ->
|
||||||
|
prefs[PLAIN_FONT_KEY] ?: FONT_SYSTEM_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setBrandFont(token: String) {
|
||||||
|
store.edit { it[BRAND_FONT_KEY] = token }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setPlainFont(token: String) {
|
||||||
|
store.edit { it[PLAIN_FONT_KEY] = token }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A per-role bump counter for the user-loaded custom font. Re-importing
|
||||||
|
* overwrites the same file under the same "custom" token, so the token alone
|
||||||
|
* can't signal the change; this stamp — carried into AppFontSettings — breaks
|
||||||
|
* value equality so the resolved FontFamily (and its previews) refresh. A
|
||||||
|
* missing key is 0 (existing installs); the first import bumps it to 1.
|
||||||
|
*/
|
||||||
|
val brandFontStamp: Flow<Int> = store.data.map { prefs -> prefs[BRAND_FONT_STAMP_KEY] ?: 0 }
|
||||||
|
|
||||||
|
val plainFontStamp: Flow<Int> = store.data.map { prefs -> prefs[PLAIN_FONT_STAMP_KEY] ?: 0 }
|
||||||
|
|
||||||
|
/** Bump [role]'s custom-font stamp after a re-import so equality-keyed caches refresh. */
|
||||||
|
suspend fun bumpCustomFontStamp(role: FontRole) {
|
||||||
|
val key = when (role) {
|
||||||
|
FontRole.BRAND -> BRAND_FONT_STAMP_KEY
|
||||||
|
FontRole.PLAIN -> PLAIN_FONT_STAMP_KEY
|
||||||
|
}
|
||||||
|
store.edit { it[key] = (it[key] ?: 0) + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
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 the Agenda screen treats events that already ended today. Defaults to
|
||||||
|
* [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming
|
||||||
|
* or hiding.
|
||||||
|
*/
|
||||||
|
val pastEventDisplay: Flow<PastEventDisplay> = store.data.map { prefs ->
|
||||||
|
prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||||
|
store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the month/week grids fade events that have already finished.
|
||||||
|
* Defaults to OFF — independent of the Agenda's [pastEventDisplay].
|
||||||
|
*/
|
||||||
|
val dimCompletedEvents: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[DIM_COMPLETED_EVENTS_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDimCompletedEvents(enabled: Boolean) {
|
||||||
|
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the Month grid shows the calendar-week (ISO) number in a left
|
||||||
|
* gutter (#25). Defaults to OFF — users opt in, since it narrows the day
|
||||||
|
* cells slightly. The Week view shows its number unconditionally.
|
||||||
|
*/
|
||||||
|
val showWeekNumbers: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[SHOW_WEEK_NUMBERS_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setShowWeekNumbers(enabled: Boolean) {
|
||||||
|
store.edit { it[SHOW_WEEK_NUMBERS_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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick-switch button customisation (#24): the ordered full view list plus
|
||||||
|
* which views are enabled in the cycle. Stored comma-joined by enum name, a
|
||||||
|
* "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing
|
||||||
|
* views are appended enabled and unknown names dropped, so a future view
|
||||||
|
* defaults into the cycle. An absent key means [QuickSwitchConfig.Default].
|
||||||
|
*/
|
||||||
|
val quickSwitchConfig: Flow<QuickSwitchConfig> = store.data.map { prefs ->
|
||||||
|
parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) {
|
||||||
|
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic read-modify-write of the quick-switch config: [transform] sees the
|
||||||
|
* value currently stored, parsed inside the edit block, not the async-echoed
|
||||||
|
* UI snapshot. Two rapid intents (a toggle then a drag) therefore compose
|
||||||
|
* instead of each re-serialising a stale copy of the other field over it.
|
||||||
|
*/
|
||||||
|
suspend fun updateQuickSwitch(transform: (QuickSwitchConfig) -> QuickSwitchConfig) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||||
|
prefs[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(transform(current))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
|
||||||
|
* are appended in default order and unknown names dropped. Absent key means
|
||||||
|
* [IMPLEMENTED_VIEWS] (the historical fixed order).
|
||||||
|
*/
|
||||||
|
val drawerViewOrder: Flow<List<CalendarView>> = store.data.map { prefs ->
|
||||||
|
parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
|
store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,6 +325,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether opening the new-event form focuses the title field and raises the
|
||||||
|
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||||
|
* gets a title, so this saves a tap; users who set the time/calendar first
|
||||||
|
* can turn it off. Only the create form auto-focuses — editing never does.
|
||||||
|
*/
|
||||||
|
val autofocusEventTitle: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[AUTOFOCUS_EVENT_TITLE_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAutofocusEventTitle(enabled: Boolean) {
|
||||||
|
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||||
* for users whose only calendar app this is, reminders are essential; the
|
* for users whose only calendar app this is, reminders are essential; the
|
||||||
@@ -129,35 +381,36 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default reminder lead time (minutes before start) prefilled on new
|
* The default reminder lead times (minutes before start) prefilled on new
|
||||||
* **timed** events. `null` = no default reminder — the prior behaviour, kept
|
* **timed** events. The empty list = no default reminder — the prior
|
||||||
* as the factory default so existing users aren't surprised by reminders they
|
* behaviour, kept as the factory default so existing users aren't surprised by
|
||||||
* never asked for. Stored as a string so "none" is distinct from a numeric
|
* reminders they never asked for. Stored as a comma-joined list of minutes, or
|
||||||
* value (and from an unset key, which is also "none"). Per-calendar overrides
|
* "none"/empty for no reminder (also the unset state). A legacy single value
|
||||||
* in [perCalendarReminderOverride] take precedence; all-day events instead use
|
* ("30") parses transparently to a one-element list. Per-calendar overrides in
|
||||||
|
* [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||||
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
||||||
*/
|
*/
|
||||||
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||||
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
|
prefs[DEFAULT_REMINDER_KEY].toReminderList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default reminder lead time prefilled on new **all-day** events, in
|
* The default reminder lead times prefilled on new **all-day** events, in
|
||||||
* minutes before the start of the day. All-day events want day-scale lead
|
* 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
|
* 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
|
* the timed one. Empty list = no default. Per-calendar overrides do **not**
|
||||||
* to all-day events — they always use this global value.
|
* apply to all-day events — they always use this global value.
|
||||||
*/
|
*/
|
||||||
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||||
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
|
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -178,23 +431,37 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
* How long the notification "Snooze" action defers a reminder, in minutes.
|
||||||
* keyed by calendar id. A calendar **present** in the map overrides the global
|
* Tapping Snooze cancels the notification and re-posts it after this delay
|
||||||
* timed default for its new events: a `null` value means "no reminder", an int
|
* via an exact alarm (the calendar provider won't re-fire). Default 10 min;
|
||||||
* means that lead time. A calendar **absent** from the map inherits the global
|
* clamped to at least 1.
|
||||||
* 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 ->
|
val snoozeMinutes: Flow<Int> = store.data.map { prefs ->
|
||||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
(prefs[SNOOZE_MINUTES_KEY] ?: DEFAULT_SNOOZE_MINUTES).coerceAtLeast(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
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: an **empty** list means "no reminder", a
|
||||||
|
* non-empty list means those lead times. A calendar **absent** from the map
|
||||||
|
* inherits the global default. Serialised as `id=value;id=value`, where a value
|
||||||
|
* is a comma-joined minute list or `none` for an explicit no-reminder override.
|
||||||
|
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||||
|
*/
|
||||||
|
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||||
|
reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
val current = reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
current.applyOverride(calendarId, override)
|
current.applyReminderOverride(calendarId, override)
|
||||||
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,22 +470,157 @@ class SettingsPrefs @Inject constructor(
|
|||||||
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
||||||
* inherit the global all-day default; present null = no reminder).
|
* inherit the global all-day default; present null = no reminder).
|
||||||
*/
|
*/
|
||||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setCalendarAllDayReminderOverride(
|
suspend fun setCalendarAllDayReminderOverride(
|
||||||
calendarId: Long,
|
calendarId: Long,
|
||||||
override: CalendarReminderOverride,
|
override: ReminderOverride,
|
||||||
) {
|
) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
val current =
|
val current =
|
||||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
current.applyOverride(calendarId, override)
|
current.applyReminderOverride(calendarId, override)
|
||||||
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Contact special dates (issue #15) ------------------------------
|
||||||
|
|
||||||
|
/** Master switch for the contact special-dates mirror. Default OFF (opt-in). */
|
||||||
|
val specialDatesEnabled: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_ENABLED_KEY] ?: false }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||||
|
store.edit { it[SPECIAL_DATES_ENABLED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The date types the user wants mirrored (default: all three). */
|
||||||
|
val specialDatesTypes: Flow<Set<SpecialDateType>> = store.data.map { prefs ->
|
||||||
|
SpecialDateType.entries.filterTo(mutableSetOf()) { prefs[typeEnabledKey(it)] ?: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||||
|
store.edit { it[typeEnabledKey(type)] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The managed calendar id per type, for the ones that currently exist. */
|
||||||
|
val specialDatesCalendars: Flow<Map<SpecialDateType, Long>> = store.data.map { prefs ->
|
||||||
|
SpecialDateType.entries.mapNotNull { type ->
|
||||||
|
prefs[calendarIdKey(type)]?.let { type to it }
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every managed calendar id — the editor locks title/date/recurrence for their events. */
|
||||||
|
val managedCalendarIds: Flow<Set<Long>> = specialDatesCalendars.map { it.values.toSet() }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesCalendarId(type: SpecialDateType, calendarId: Long?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (calendarId == null) prefs.remove(calendarIdKey(type)) else prefs[calendarIdKey(type)] = calendarId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title template per type ("{name}'s birthday"); an empty string means
|
||||||
|
* unset, so the settings screen can seed the localized default. `{name}` is
|
||||||
|
* the contact name, `{age}` the age at the upcoming date (empty when the
|
||||||
|
* birth year is unknown or [specialDatesShowAge] is off).
|
||||||
|
*/
|
||||||
|
val specialDatesTitleTemplates: Flow<Map<SpecialDateType, String>> = store.data.map { prefs ->
|
||||||
|
SpecialDateType.entries.associateWith { prefs[titleTemplateKey(it)].orEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||||
|
store.edit { it[titleTemplateKey(type)] = template }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `{year}` resolves in the title (only meaningful when the year is known). Default ON. */
|
||||||
|
val specialDatesShowYear: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||||
|
store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of the last mirror sync, for the settings status line. */
|
||||||
|
val specialDatesStatus: Flow<SpecialDatesStatus> = store.data.map { prefs ->
|
||||||
|
SpecialDatesStatus(
|
||||||
|
lastRun = prefs[SPECIAL_DATES_LAST_RUN_KEY] ?: 0L,
|
||||||
|
stalled = prefs[SPECIAL_DATES_STALLED_KEY]
|
||||||
|
?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */
|
||||||
|
suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[SPECIAL_DATES_LAST_RUN_KEY] = atMillis
|
||||||
|
if (stalled == null) prefs.remove(SPECIAL_DATES_STALLED_KEY) else prefs[SPECIAL_DATES_STALLED_KEY] = stalled.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Epoch millis of the last foreground-triggered sync, to debounce ON_RESUME. */
|
||||||
|
val specialDatesLastForegroundSync: Flow<Long> =
|
||||||
|
store.data.map { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] ?: 0L }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) {
|
||||||
|
store.edit { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] = atMillis }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun typeEnabledKey(type: SpecialDateType) =
|
||||||
|
booleanPreferencesKey("special_dates_type_${type.name}")
|
||||||
|
|
||||||
|
private fun calendarIdKey(type: SpecialDateType) =
|
||||||
|
longPreferencesKey("special_dates_calendar_${type.name}")
|
||||||
|
|
||||||
|
private fun titleTemplateKey(type: SpecialDateType) =
|
||||||
|
stringPreferencesKey("special_dates_title_${type.name}")
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -226,11 +628,61 @@ class SettingsPrefs @Inject constructor(
|
|||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse a plain comma-joined view order, completed to every implemented view. */
|
||||||
|
private fun parseViewOrder(stored: String?): List<CalendarView> =
|
||||||
|
completeViewOrder(
|
||||||
|
stored?.split(',').orEmpty()
|
||||||
|
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Parse the quick-switch config; "!"-prefixed names are disabled. */
|
||||||
|
private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
|
||||||
|
if (stored == null) return QuickSwitchConfig.Default
|
||||||
|
val parsed = stored.split(',').mapNotNull { raw ->
|
||||||
|
val token = raw.trim()
|
||||||
|
val disabled = token.startsWith("!")
|
||||||
|
val name = if (disabled) token.drop(1) else token
|
||||||
|
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
|
||||||
|
}
|
||||||
|
val order = completeViewOrder(parsed.map { it.first })
|
||||||
|
// Only views explicitly stored disabled are excluded; anything appended
|
||||||
|
// (a view added in a later release) defaults into the cycle.
|
||||||
|
val disabled = parsed.filter { it.second }.map { it.first }.toSet()
|
||||||
|
return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeQuickSwitch(config: QuickSwitchConfig): String =
|
||||||
|
completeViewOrder(config.order).joinToString(",") { view ->
|
||||||
|
if (view in config.enabled) view.name else "!${view.name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the given order (de-duplicated), then append any views it omits. */
|
||||||
|
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
||||||
|
val ordered = seen.distinct()
|
||||||
|
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
|
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
||||||
|
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
||||||
|
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
|
||||||
|
internal val PLAIN_FONT_STAMP_KEY = intPreferencesKey("plain_font_stamp")
|
||||||
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 PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||||
|
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||||
|
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||||
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
|
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
|
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
|
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val 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 =
|
||||||
@@ -243,82 +695,128 @@ class SettingsPrefs @Inject constructor(
|
|||||||
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
||||||
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||||
private const val MINUTES_PER_DAY = 1_440
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
||||||
|
/** Default snooze delay for the notification "Snooze" action. */
|
||||||
|
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||||
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||||
stringPreferencesKey("per_calendar_reminder_override")
|
stringPreferencesKey("per_calendar_reminder_override")
|
||||||
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
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
|
||||||
|
|
||||||
|
internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled")
|
||||||
|
internal val SPECIAL_DATES_SHOW_YEAR_KEY = booleanPreferencesKey("special_dates_show_year")
|
||||||
|
internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run")
|
||||||
|
internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason")
|
||||||
|
internal val SPECIAL_DATES_LAST_FG_SYNC_KEY =
|
||||||
|
longPreferencesKey("special_dates_last_foreground_sync")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
/** Snapshot of the special-dates mirror's last outcome (see [SettingsPrefs.specialDatesStatus]). */
|
||||||
sealed interface CalendarReminderOverride {
|
data class SpecialDatesStatus(
|
||||||
/** No override — the calendar uses the global default. */
|
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||||
data object Inherit : CalendarReminderOverride
|
val lastRun: Long,
|
||||||
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
/** Non-null when the mirror is paused and why; null when healthy. */
|
||||||
data object None : CalendarReminderOverride
|
val stalled: SpecialDatesStalledReason?,
|
||||||
/** A specific lead time in minutes before the event start. */
|
)
|
||||||
data class Minutes(val minutes: Int) : CalendarReminderOverride
|
|
||||||
|
/** Why the special-dates mirror is paused. */
|
||||||
|
enum class SpecialDatesStalledReason {
|
||||||
|
/** READ_CONTACTS was revoked in system settings after the feature was enabled. */
|
||||||
|
PermissionRevoked,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
||||||
|
data class BackupStatus(
|
||||||
|
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||||
|
val lastRun: Long,
|
||||||
|
val lastSuccess: Boolean,
|
||||||
|
val consecutiveFailures: Int,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lead time to prefill on a new event: the matching per-calendar override
|
* The lead times to prefill on a new event: the matching per-calendar override
|
||||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||||
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
||||||
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
|
* events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
|
||||||
* it can be unit-tested.
|
* Pure so it can be unit-tested.
|
||||||
*/
|
*/
|
||||||
fun resolveDefaultReminder(
|
fun resolveDefaultReminder(
|
||||||
timedGlobal: Int?,
|
timedGlobal: List<Int>,
|
||||||
allDayGlobal: Int?,
|
allDayGlobal: List<Int>,
|
||||||
timedOverrides: Map<Long, Int?>,
|
timedOverrides: Map<Long, List<Int>>,
|
||||||
allDayOverrides: Map<Long, Int?>,
|
allDayOverrides: Map<Long, List<Int>>,
|
||||||
calendarId: Long?,
|
calendarId: Long?,
|
||||||
isAllDay: Boolean,
|
isAllDay: Boolean,
|
||||||
): Int? {
|
): List<Int> {
|
||||||
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||||
val global = if (isAllDay) allDayGlobal else timedGlobal
|
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||||
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||||
overrides[calendarId]
|
overrides.getValue(calendarId)
|
||||||
} else {
|
} else {
|
||||||
global
|
global
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
|
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||||
private fun MutableMap<Long, Int?>.applyOverride(
|
private const val WEEK_START_AUTO = "AUTO"
|
||||||
calendarId: Long,
|
|
||||||
override: CalendarReminderOverride,
|
private fun WeekStartPref.storageValue(): String = when (this) {
|
||||||
) {
|
WeekStartPref.Auto -> WEEK_START_AUTO
|
||||||
when (override) {
|
is WeekStartPref.Day -> day.name
|
||||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
}
|
||||||
CalendarReminderOverride.None -> put(calendarId, null)
|
|
||||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
/**
|
||||||
}
|
* 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 NONE = "none"
|
||||||
private const val ENTRY_SEP = ";"
|
private const val ENTRY_SEP = ";"
|
||||||
private const val KEY_VALUE_SEP = "="
|
private const val KEY_VALUE_SEP = "="
|
||||||
|
private const val LIST_SEP = ","
|
||||||
|
|
||||||
private fun String?.toReminderMinutes(): Int? = when (this) {
|
/**
|
||||||
null, "", NONE -> null
|
* The per-calendar override map codec, in Calendula's stored dialect
|
||||||
else -> toIntOrNull()
|
* (`id=minutes` entries joined by `;`, minutes comma-joined, `none` for an
|
||||||
|
* explicit no-reminder). The model and codec live in floret-kit; the dialect
|
||||||
|
* (fixed at release) stays here.
|
||||||
|
*/
|
||||||
|
private val reminderOverrideCodec =
|
||||||
|
ReminderOverrideCodec(entrySep = ENTRY_SEP, keyValueSep = KEY_VALUE_SEP, listSep = LIST_SEP, noneToken = NONE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||||
|
* list; a comma-joined list → its minutes; a legacy single value ("30") → a
|
||||||
|
* one-element list. Non-numeric parts are dropped defensively.
|
||||||
|
*/
|
||||||
|
private fun String?.toReminderList(): List<Int> = when {
|
||||||
|
this == null || isEmpty() || this == NONE -> emptyList()
|
||||||
|
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
/** Serialise lead times for storage: "none" when empty, else comma-joined. */
|
||||||
if (stored.isNullOrBlank()) return emptyMap()
|
private fun List<Int>.toStoredReminders(): String =
|
||||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||||
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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.content.pm.PackageManager
|
|||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -16,6 +17,29 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [this] alert belongs to a calendar the user disabled in-app, so its
|
||||||
|
* reminder must be suppressed (mirroring the event filtering in
|
||||||
|
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
|
||||||
|
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
|
||||||
|
* never treated as disabled. This is the one predicate the disabled-calendar
|
||||||
|
* gate is built from: [postableAlerts] here and the choke point in
|
||||||
|
* [ReminderNotifier.post] both use it.
|
||||||
|
*/
|
||||||
|
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
|
||||||
|
calendarId != 0L && calendarId in disabledCalendarIds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The due alerts that should actually surface as notifications: everything
|
||||||
|
* except alerts whose calendar the user has disabled in-app. The caller still
|
||||||
|
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
|
||||||
|
* provider.
|
||||||
|
*/
|
||||||
|
internal fun postableAlerts(
|
||||||
|
due: List<ReminderAlert>,
|
||||||
|
disabledCalendarIds: Set<Long>,
|
||||||
|
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||||
* visible notifications (the Etar model — the provider broadcasts
|
* visible notifications (the Etar model — the provider broadcasts
|
||||||
@@ -32,6 +56,8 @@ class EventReminderReceiver : BroadcastReceiver() {
|
|||||||
@Inject lateinit var alertStore: ReminderAlertStore
|
@Inject lateinit var alertStore: ReminderAlertStore
|
||||||
@Inject lateinit var notifier: ReminderNotifier
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||||
|
@Inject lateinit var suppressedStore: SuppressedReminderStore
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||||
@@ -46,8 +72,17 @@ 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)
|
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||||
|
val postable = postableAlerts(due, disabled)
|
||||||
|
// Suppress reminders for disabled calendars, but still mark
|
||||||
|
// every due alert fired so the provider stops re-broadcasting
|
||||||
|
// the suppressed ones. Stash those suppressed alerts so
|
||||||
|
// re-enabling their calendar can recover them (they would
|
||||||
|
// otherwise stay STATE_FIRED forever with no re-scan).
|
||||||
|
postable.forEach { notifier.post(it) }
|
||||||
alertStore.markFired(due.map { it.alertId }, now)
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
|
suppressedStore.stash(due - postable.toSet(), now)
|
||||||
|
suppressedStore.purgeExpired(now)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pendingResult.finish()
|
pendingResult.finish()
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
|
||||||
|
* the internal re-show when a snooze elapses. All three are app-internal
|
||||||
|
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||||
|
* alarm), so the receiver is not exported.
|
||||||
|
*
|
||||||
|
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
||||||
|
* already fired, so nothing re-posts it.
|
||||||
|
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||||
|
* it after the user's snooze delay.
|
||||||
|
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||||
|
* or dismiss it again.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ReminderActionReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
|
@Inject lateinit var scheduler: ReminderSnoozeScheduler
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val alert = alertFrom(intent) ?: return
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_DISMISS -> notifier.cancel(alert)
|
||||||
|
|
||||||
|
ACTION_SNOOZE -> {
|
||||||
|
// Cancel now so the notification doesn't linger until the alarm;
|
||||||
|
// the snooze delay read is the only async work.
|
||||||
|
notifier.cancel(alert)
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val minutes = settingsPrefs.snoozeMinutes.first()
|
||||||
|
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
|
||||||
|
scheduler.schedule(alert, triggerAt)
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTION_SHOW -> {
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||||
|
notifier.post(alert)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_SNOOZE = "de.jeanlucmakiola.calendula.reminders.SNOOZE"
|
||||||
|
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||||
|
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||||
|
|
||||||
|
private const val EXTRA_ALERT_ID = "alert_id"
|
||||||
|
private const val EXTRA_EVENT_ID = "event_id"
|
||||||
|
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||||
|
private const val EXTRA_BEGIN = "begin"
|
||||||
|
private const val EXTRA_END = "end"
|
||||||
|
private const val EXTRA_TITLE = "title"
|
||||||
|
private const val EXTRA_LOCATION = "location"
|
||||||
|
private const val EXTRA_ALL_DAY = "all_day"
|
||||||
|
|
||||||
|
/** An explicit intent to this receiver carrying [alert] as extras. */
|
||||||
|
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||||
|
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||||
|
this.action = action
|
||||||
|
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||||
|
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||||
|
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||||
|
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||||
|
putExtra(EXTRA_END, alert.endMillis)
|
||||||
|
putExtra(EXTRA_TITLE, alert.title)
|
||||||
|
putExtra(EXTRA_LOCATION, alert.location)
|
||||||
|
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stable request code per (alert, action) so the three PendingIntents
|
||||||
|
* of one notification stay distinct and don't clobber each other.
|
||||||
|
*/
|
||||||
|
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||||
|
val actionOffset = when (action) {
|
||||||
|
ACTION_SNOOZE -> 1
|
||||||
|
ACTION_DISMISS -> 2
|
||||||
|
ACTION_SHOW -> 3
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
return alert.alertId.toInt() * 8 + actionOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||||
|
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
||||||
|
return ReminderAlert(
|
||||||
|
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||||
|
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||||
|
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||||
|
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||||
|
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
||||||
|
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||||
|
location = intent.getStringExtra(EXTRA_LOCATION),
|
||||||
|
isAllDay = intent.getBooleanExtra(EXTRA_ALL_DAY, false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import javax.inject.Singleton
|
|||||||
data class ReminderAlert(
|
data class ReminderAlert(
|
||||||
val alertId: Long,
|
val alertId: Long,
|
||||||
val eventId: Long,
|
val eventId: Long,
|
||||||
|
val calendarId: Long,
|
||||||
val beginMillis: Long,
|
val beginMillis: Long,
|
||||||
val endMillis: Long,
|
val endMillis: Long,
|
||||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||||
@@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor(
|
|||||||
ReminderAlert(
|
ReminderAlert(
|
||||||
alertId = c.getLong(0),
|
alertId = c.getLong(0),
|
||||||
eventId = c.getLong(1),
|
eventId = c.getLong(1),
|
||||||
beginMillis = c.getLong(2),
|
calendarId = c.getLong(2),
|
||||||
endMillis = c.getLong(3),
|
beginMillis = c.getLong(3),
|
||||||
title = c.getString(4).orEmpty(),
|
endMillis = c.getLong(4),
|
||||||
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
title = c.getString(5).orEmpty(),
|
||||||
isAllDay = c.getInt(6) == 1,
|
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||||
|
isAllDay = c.getInt(7) == 1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor(
|
|||||||
val PROJECTION = arrayOf(
|
val PROJECTION = arrayOf(
|
||||||
CalendarContract.CalendarAlerts._ID,
|
CalendarContract.CalendarAlerts._ID,
|
||||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||||
|
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||||
CalendarContract.CalendarAlerts.BEGIN,
|
CalendarContract.CalendarAlerts.BEGIN,
|
||||||
CalendarContract.CalendarAlerts.END,
|
CalendarContract.CalendarAlerts.END,
|
||||||
CalendarContract.CalendarAlerts.TITLE,
|
CalendarContract.CalendarAlerts.TITLE,
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ 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.CalendarPrefs
|
||||||
|
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 +33,8 @@ 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,
|
||||||
|
private val calendarPrefs: CalendarPrefs,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
@@ -39,15 +45,23 @@ 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) {
|
||||||
|
// The single choke point for the disabled-calendar gate: it covers both
|
||||||
|
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
|
||||||
|
// (ReminderActionReceiver), so a calendar disabled after a snooze no
|
||||||
|
// longer notifies — without either receiver duplicating the check.
|
||||||
|
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
|
||||||
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 +75,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 +95,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Still relevant while the event has not ended: a reminder for an event that is
|
||||||
|
* already over is pointless to re-surface. Falls back to the begin time when the
|
||||||
|
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
|
||||||
|
*/
|
||||||
|
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
|
||||||
|
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local stash of reminder alerts that fired while their calendar was disabled
|
||||||
|
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
|
||||||
|
* (so the provider stops re-broadcasting the suppressed ones), which would
|
||||||
|
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
|
||||||
|
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
|
||||||
|
* the user re-enables the calendar before the event is over.
|
||||||
|
*
|
||||||
|
* Persisted in the shared preferences DataStore as a set of self-describing
|
||||||
|
* strings (one per alert); the stash never reaches a screen, so there is no
|
||||||
|
* domain model. Entries whose event has already ended are dropped on the next
|
||||||
|
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SuppressedReminderStore @Inject constructor(
|
||||||
|
private val store: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
|
||||||
|
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
|
||||||
|
if (alerts.isEmpty()) return
|
||||||
|
store.edit { prefs ->
|
||||||
|
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
|
||||||
|
alerts.forEach { byId[it.alertId] = it }
|
||||||
|
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove and return the still-relevant stashed alerts belonging to any of
|
||||||
|
* [calendarIds]; drops expired entries for every calendar in passing.
|
||||||
|
*/
|
||||||
|
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
|
||||||
|
val recovered = mutableListOf<ReminderAlert>()
|
||||||
|
store.edit { prefs ->
|
||||||
|
val kept = decodeAll(prefs).filter { alert ->
|
||||||
|
when {
|
||||||
|
!alert.isRelevantAt(nowMillis) -> false // expired: drop
|
||||||
|
alert.calendarId in calendarIds -> { recovered += alert; false }
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.putStash(kept)
|
||||||
|
}
|
||||||
|
return recovered
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
|
||||||
|
suspend fun purgeExpired(nowMillis: Long) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
|
||||||
|
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
|
||||||
|
|
||||||
|
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
|
||||||
|
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
|
||||||
|
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val KEY = stringSetPreferencesKey("suppressed_reminders")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One stash entry as a delimited string. The '|' separator is safe because every
|
||||||
|
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
|
||||||
|
// a null location is stored as a distinct sentinel that Base64 also never yields.
|
||||||
|
private const val FIELD_SEP = "|"
|
||||||
|
private const val NULL_LOCATION = "-"
|
||||||
|
|
||||||
|
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
|
||||||
|
alert.alertId.toString(),
|
||||||
|
alert.eventId.toString(),
|
||||||
|
alert.calendarId.toString(),
|
||||||
|
alert.beginMillis.toString(),
|
||||||
|
alert.endMillis.toString(),
|
||||||
|
if (alert.isAllDay) "1" else "0",
|
||||||
|
alert.title.toBase64(),
|
||||||
|
alert.location?.toBase64() ?: NULL_LOCATION,
|
||||||
|
).joinToString(FIELD_SEP)
|
||||||
|
|
||||||
|
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
|
||||||
|
internal fun decodeStashEntry(raw: String): ReminderAlert? {
|
||||||
|
val parts = raw.split(FIELD_SEP)
|
||||||
|
if (parts.size != 8) return null
|
||||||
|
return try {
|
||||||
|
ReminderAlert(
|
||||||
|
alertId = parts[0].toLong(),
|
||||||
|
eventId = parts[1].toLong(),
|
||||||
|
calendarId = parts[2].toLong(),
|
||||||
|
beginMillis = parts[3].toLong(),
|
||||||
|
endMillis = parts[4].toLong(),
|
||||||
|
title = parts[6].fromBase64(),
|
||||||
|
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
|
||||||
|
isAllDay = parts[5] == "1",
|
||||||
|
)
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
null
|
||||||
|
} catch (e: IllegalArgumentException) { // bad Base64
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toBase64(): String =
|
||||||
|
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
private fun String.fromBase64(): String =
|
||||||
|
String(Base64.getDecoder().decode(this), Charsets.UTF_8)
|
||||||
@@ -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,8 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two Material 3 typeface roles a user can set independently (issue #19):
|
||||||
|
* [BRAND] drives the display/headline styles (expression), [PLAIN] the
|
||||||
|
* title/body/label styles (readability). Each defaults to the system typeface.
|
||||||
|
*/
|
||||||
|
enum class FontRole { BRAND, PLAIN }
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a prefilled [EventForm] from an `ACTION_INSERT` intent's extras (issue
|
||||||
|
* #30). External apps and widgets (e.g. the Todo Agenda widget) launch the
|
||||||
|
* calendar this way to create a new event, passing the fields as
|
||||||
|
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
||||||
|
* back to the same defaults the in-app "new event" uses — a timed start at the
|
||||||
|
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
|
||||||
|
* it resolves to the last-used / first-writable calendar, exactly like the
|
||||||
|
* `.ics` single-event and plain new-event paths.
|
||||||
|
*
|
||||||
|
* Pure (no Android types) so it is unit-testable; the intent parsing that reads
|
||||||
|
* the extras lives in `MainActivity.insertFormOrNull`.
|
||||||
|
*/
|
||||||
|
fun buildInsertEventForm(
|
||||||
|
beginMillis: Long?,
|
||||||
|
endMillis: Long?,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
title: String?,
|
||||||
|
description: String?,
|
||||||
|
location: String?,
|
||||||
|
rrule: String?,
|
||||||
|
zone: TimeZone,
|
||||||
|
now: Instant,
|
||||||
|
): EventForm {
|
||||||
|
val (start, end) = if (isAllDay) {
|
||||||
|
// All-day provider times are UTC midnights with an exclusive end; show
|
||||||
|
// the last covered day and keep placeholder wall-clock times in case the
|
||||||
|
// user switches the event to timed (mirrors EventDetail.toEditForm).
|
||||||
|
val startDate = beginMillis
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||||
|
?: now.toLocalDateTime(zone).date
|
||||||
|
val endDate = endMillis
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||||
|
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
|
||||||
|
?: startDate
|
||||||
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
|
} else {
|
||||||
|
val startTime = beginMillis
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||||
|
?: nextFullHour(now, zone)
|
||||||
|
val endTime = endMillis
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||||
|
?.takeIf { it >= startTime }
|
||||||
|
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||||
|
startTime to endTime
|
||||||
|
}
|
||||||
|
return EventForm(
|
||||||
|
calendarId = null,
|
||||||
|
title = title.orEmpty(),
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
location = location.orEmpty(),
|
||||||
|
description = description.orEmpty(),
|
||||||
|
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
|
||||||
|
// some callers include.
|
||||||
|
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
|
||||||
|
val hourMillis = 3_600_000L
|
||||||
|
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
||||||
|
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
||||||
|
}
|
||||||
@@ -26,6 +26,14 @@ data class CalendarSource(
|
|||||||
* owns for its own calendars). Always null for synced calendars.
|
* owns for its own calendars). Always null for synced calendars.
|
||||||
*/
|
*/
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
|
/**
|
||||||
|
* A special-dates mirror calendar the app manages (birthdays/anniversaries
|
||||||
|
* from contacts). Its events' title/date/recurrence are owned by the sync,
|
||||||
|
* so it's hidden from the new-event calendar picker and its events lock those
|
||||||
|
* fields in the editor. Recognised by a durable provider marker, so it holds
|
||||||
|
* even after a backup restore clears the app's stored ids.
|
||||||
|
*/
|
||||||
|
val isManaged: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EventInstance(
|
data class EventInstance(
|
||||||
@@ -40,6 +48,14 @@ data class EventInstance(
|
|||||||
val location: String?,
|
val location: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this event has finished relative to [now] — its end is at or before
|
||||||
|
* the current instant. An in-progress event (already started but not yet ended)
|
||||||
|
* is *not* considered ended. All-day events end at the exclusive next-midnight,
|
||||||
|
* so they only count as ended once their day is fully over.
|
||||||
|
*/
|
||||||
|
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||||
|
|
||||||
data class EventDetail(
|
data class EventDetail(
|
||||||
val instance: EventInstance,
|
val instance: EventInstance,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kinds of contact "special date" Calendula mirrors into local calendars.
|
||||||
|
* Each kind gets its own local calendar, so it inherits per-calendar colour,
|
||||||
|
* visibility and reminder defaults for free. See
|
||||||
|
* docs/design/contact-special-dates.md.
|
||||||
|
*/
|
||||||
|
enum class SpecialDateType {
|
||||||
|
Birthday,
|
||||||
|
Anniversary,
|
||||||
|
|
||||||
|
/** Everything else — a contact "Event" that is neither birthday nor anniversary. */
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One dated event read from a device contact (a `ContactsContract` `Event`
|
||||||
|
* row). The [lookupKey] is the stable contact identity used to reconcile the
|
||||||
|
* mirror without duplicating; [year] is null when the contact stored the date
|
||||||
|
* without one (`--MM-dd`), in which case age can't be shown.
|
||||||
|
*/
|
||||||
|
data class ContactSpecialDate(
|
||||||
|
val lookupKey: String,
|
||||||
|
val displayName: String,
|
||||||
|
val type: SpecialDateType,
|
||||||
|
val month: Int,
|
||||||
|
val day: Int,
|
||||||
|
val year: Int?,
|
||||||
|
/** The contact's custom label for a [SpecialDateType.Custom] date, if any. */
|
||||||
|
val label: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The parsed month/day (+ optional year) of a contact date. */
|
||||||
|
data class ContactDateParts(val year: Int?, val month: Int, val day: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fixed leap year to validate and anchor year-less dates against, so that a
|
||||||
|
* `--02-29` birthday is representable (and, once anchored, only recurs in leap
|
||||||
|
* years — matching how the calendar provider expands a yearly Feb-29 series).
|
||||||
|
*/
|
||||||
|
const val YEARLESS_ANCHOR_YEAR = 1972
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `ContactsContract.CommonDataKinds.Event.START_DATE` value into its
|
||||||
|
* calendar parts, or null if it isn't a usable date. Handles the three shapes
|
||||||
|
* seen in the wild:
|
||||||
|
* - full `yyyy-MM-dd` (year known),
|
||||||
|
* - year-less `--MM-dd` (year null),
|
||||||
|
* - compact `yyyyMMdd`.
|
||||||
|
*
|
||||||
|
* A date that names an impossible day (e.g. `1999-02-29`) is rejected. Pure, so
|
||||||
|
* it's unit-tested without a device.
|
||||||
|
*/
|
||||||
|
fun parseContactEventDate(raw: String?): ContactDateParts? {
|
||||||
|
val s = raw?.trim().orEmpty()
|
||||||
|
if (s.isEmpty()) return null
|
||||||
|
|
||||||
|
// Year-less: "--MM-dd" or "--MMdd".
|
||||||
|
if (s.startsWith("--")) {
|
||||||
|
val (m, d) = parseMonthDay(s.substring(2)) ?: return null
|
||||||
|
return validated(null, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.contains('-')) {
|
||||||
|
val parts = s.split('-').filter { it.isNotEmpty() }
|
||||||
|
return when (parts.size) {
|
||||||
|
// "yyyy-MM-dd" — but a leading '-' would have dropped the empty
|
||||||
|
// first part, so require the first token to look like a year.
|
||||||
|
3 -> if (s.startsWith('-')) null else validated(
|
||||||
|
year = parts[0].toIntOrNull() ?: return null,
|
||||||
|
month = parts[1].toIntOrNull() ?: return null,
|
||||||
|
day = parts[2].toIntOrNull() ?: return null,
|
||||||
|
)
|
||||||
|
// Year-less "MM-dd" without the "--" prefix.
|
||||||
|
2 -> validated(
|
||||||
|
year = null,
|
||||||
|
month = parts[0].toIntOrNull() ?: return null,
|
||||||
|
day = parts[1].toIntOrNull() ?: return null,
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact "yyyyMMdd".
|
||||||
|
if (s.length == 8 && s.all { it.isDigit() }) {
|
||||||
|
return validated(
|
||||||
|
year = s.substring(0, 4).toInt(),
|
||||||
|
month = s.substring(4, 6).toInt(),
|
||||||
|
day = s.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "MM-dd" or compact "MMdd". */
|
||||||
|
private fun parseMonthDay(rest: String): Pair<Int, Int>? {
|
||||||
|
if (rest.contains('-')) {
|
||||||
|
val p = rest.split('-').filter { it.isNotEmpty() }
|
||||||
|
if (p.size != 2) return null
|
||||||
|
return (p[0].toIntOrNull() ?: return null) to (p[1].toIntOrNull() ?: return null)
|
||||||
|
}
|
||||||
|
if (rest.length == 4 && rest.all { it.isDigit() }) {
|
||||||
|
return rest.substring(0, 2).toInt() to rest.substring(2, 4).toInt()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm month/day form a real calendar date (validated against the actual
|
||||||
|
* year when known, otherwise the leap anchor so `02-29` survives).
|
||||||
|
*/
|
||||||
|
private fun validated(year: Int?, month: Int, day: Int): ContactDateParts? {
|
||||||
|
if (month !in 1..12 || day !in 1..31) return null
|
||||||
|
val checkYear = year ?: YEARLESS_ANCHOR_YEAR
|
||||||
|
return runCatching { LocalDate.of(checkYear, month, day) }
|
||||||
|
.map { ContactDateParts(year, month, day) }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix of every managed-event `UID_2445`. Distinguishes mirror events from
|
||||||
|
* user-created ones (which carry a random `<uuid>@calendula` UID), so the sync
|
||||||
|
* only ever reconciles — and never deletes — events it actually owns.
|
||||||
|
*/
|
||||||
|
const val MANAGED_UID_PREFIX = "contact-"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
||||||
|
* contact date. Stable across syncs (the reconciliation key), namespaced by
|
||||||
|
* type so the same contact's birthday and anniversary never collide, and — when
|
||||||
|
* a [discriminator] is given — by it too, so two Custom dates on one contact
|
||||||
|
* (e.g. "Wedding" and "Graduation") get distinct events instead of clobbering
|
||||||
|
* each other.
|
||||||
|
*/
|
||||||
|
fun managedEventUid(type: SpecialDateType, lookupKey: String, discriminator: String? = null): String {
|
||||||
|
val disc = discriminator?.takeIf { it.isNotBlank() }?.let { ":$it" }.orEmpty()
|
||||||
|
return "$MANAGED_UID_PREFIX${type.name.lowercase()}:$lookupKey$disc@calendula"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reconciliation key for this date's mirrored event. Birthdays/anniversaries
|
||||||
|
* are one-per-contact, so they key on the contact alone; a [SpecialDateType.Custom]
|
||||||
|
* date adds a discriminator (its label, else its month-day) so distinct custom
|
||||||
|
* dates on one contact don't collapse into a single event.
|
||||||
|
*/
|
||||||
|
fun ContactSpecialDate.managedUid(): String =
|
||||||
|
managedEventUid(type, lookupKey, customDiscriminator())
|
||||||
|
|
||||||
|
private fun ContactSpecialDate.customDiscriminator(): String? =
|
||||||
|
if (type == SpecialDateType.Custom) {
|
||||||
|
label?.trim()?.lowercase()?.ifBlank { null } ?: "%02d-%02d".format(month, day)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
|
||||||
|
* date when the year is known (so age can be derived), otherwise the month/day
|
||||||
|
* on a fixed leap anchor year so a `--02-29` date stays representable and the
|
||||||
|
* anchor never drifts between syncs.
|
||||||
|
*/
|
||||||
|
fun ContactSpecialDate.anchorDate(): LocalDate =
|
||||||
|
LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a title [template] for a contact, substituting `{name}` and `{year}`
|
||||||
|
* (the source year — a birthday's birth year or an anniversary's start year;
|
||||||
|
* empty when [year] is null). Unlike an age, the year is static and correct on
|
||||||
|
* every occurrence of the yearly event. Collapses the whitespace an empty
|
||||||
|
* `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to
|
||||||
|
* "Jane's birthday" when no year is known.
|
||||||
|
*/
|
||||||
|
fun renderSpecialDateTitle(template: String, name: String, year: Int?): String =
|
||||||
|
template
|
||||||
|
.replace("{name}", name)
|
||||||
|
.replace("{year}", year?.toString().orEmpty())
|
||||||
|
// Drop an empty "()" left by an unresolved {year}, then tidy spacing.
|
||||||
|
.replace(Regex("""\(\s*\)"""), "")
|
||||||
|
.replace(Regex("""\s+"""), " ")
|
||||||
|
.trim()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.qs
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.os.Build
|
||||||
|
import android.service.quicksettings.TileService
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick Settings tile: tapping it opens the create-event form on today — the
|
||||||
|
* same action as the launcher "New event" shortcut and the agenda widget's "+".
|
||||||
|
* A stateless action tile, so there is no on/off state to keep in sync.
|
||||||
|
*/
|
||||||
|
class NewEventTileService : TileService() {
|
||||||
|
|
||||||
|
// The pre-34 branch intentionally uses the deprecated Intent overload: it is
|
||||||
|
// the only form available below UpsideDownCake, and is reached only there.
|
||||||
|
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
|
||||||
|
override fun onClick() {
|
||||||
|
super.onClick()
|
||||||
|
val today = Clock.System.now()
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
val intent = MainActivity.openCreateIntent(this, today)
|
||||||
|
// Launch only once the device is unlocked: creating an event behind the
|
||||||
|
// keyguard makes no sense, and the shade can't start an activity over a
|
||||||
|
// locked screen anyway.
|
||||||
|
unlockAndRun {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
val pending = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
startActivityAndCollapse(pending)
|
||||||
|
} else {
|
||||||
|
startActivityAndCollapse(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui
|
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,20 +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.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.floret.identity.fadeThrough
|
||||||
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.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
|
||||||
@@ -34,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(
|
||||||
@@ -52,15 +72,31 @@ fun CalendarHost(
|
|||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
|
requestedInsertForm: EventForm? = null,
|
||||||
|
onInsertConsumed: () -> 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
|
||||||
|
|
||||||
|
// View customisation (#24): the quick-switch cycle and drawer order. Both have
|
||||||
|
// sensible non-empty initial values, so they're ready before the first frame.
|
||||||
|
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||||
|
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
||||||
|
|
||||||
|
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
|
||||||
@@ -97,6 +133,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) }
|
||||||
@@ -130,23 +172,86 @@ fun CalendarHost(
|
|||||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||||
|
// A restore (in-app "Restore from .ics" button) always runs the full import
|
||||||
|
// flow — picker + summary — even for a single-event file, because the intent
|
||||||
|
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||||
|
// keeps routing a single event straight into the prefilled create form.
|
||||||
|
var importForceMany by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(requestedImportUri) {
|
LaunchedEffect(requestedImportUri) {
|
||||||
if (requestedImportUri != null) {
|
if (requestedImportUri != null) {
|
||||||
importUri = requestedImportUri
|
importUri = requestedImportUri
|
||||||
|
importForceMany = false
|
||||||
onImportConsumed()
|
onImportConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An external ACTION_INSERT launch (another app/widget creating an event,
|
||||||
|
// issue #30) arrives already prefilled — open it in the same create form the
|
||||||
|
// single-event .ics path uses. [importForm] is the topmost overlay, so it
|
||||||
|
// reveals on top of whatever was open without extra dismissal.
|
||||||
|
LaunchedEffect(requestedInsertForm) {
|
||||||
|
if (requestedInsertForm != null) {
|
||||||
|
importForm = requestedInsertForm
|
||||||
|
onInsertConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
// Close every overlay that can sit over the calendar, so an externally
|
||||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
// 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
|
// No widget source (an external date tap) roots over the default
|
||||||
|
// home view, so backing out of the day returns home then exits.
|
||||||
|
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||||
|
onWidgetNavConsumed()
|
||||||
|
}
|
||||||
|
is WidgetNavRequest.OpenEvent -> {
|
||||||
|
// Root the stack in the widget's view, then open the occurrence
|
||||||
|
// detail over it (same key shape as a tapped event / reminder).
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
createDateIso = null
|
||||||
|
viewStack = viewBaseStack(defaultView, req.source)
|
||||||
|
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
||||||
|
heldKey = key
|
||||||
|
detailKey = key
|
||||||
|
onWidgetNavConsumed()
|
||||||
|
}
|
||||||
|
is WidgetNavRequest.OpenView -> {
|
||||||
|
// A widget header tap: land on a top-level view with no date
|
||||||
|
// drill-in. Reveal it by dropping any covering overlay, then root
|
||||||
|
// the stack on the target (null → the default home) over the
|
||||||
|
// default home — so backing out returns to the default, then exits.
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
createDateIso = null
|
||||||
|
pendingDayIso = null
|
||||||
|
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||||
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
|
||||||
@@ -161,36 +266,83 @@ 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 = fadeThrough()
|
||||||
|
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,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
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,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
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,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
selectedView = view,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +420,10 @@ fun CalendarHost(
|
|||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
CalendarsScreen(onBack = { showCalendars = false })
|
CalendarsScreen(
|
||||||
|
onBack = { showCalendars = false },
|
||||||
|
onImport = { importUri = it; importForceMany = true },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import flow for an opened/received .ics file. A single event routes
|
// Import flow for an opened/received .ics file. A single event routes
|
||||||
@@ -276,6 +431,7 @@ fun CalendarHost(
|
|||||||
importUri?.let { uri ->
|
importUri?.let { uri ->
|
||||||
ImportScreen(
|
ImportScreen(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
|
forceMany = importForceMany,
|
||||||
onClose = { importUri = null },
|
onClose = { importUri = null },
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
@@ -293,3 +449,9 @@ fun CalendarHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persists the view back stack across config change / process death by ordinal. */
|
||||||
|
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||||
|
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||||
|
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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 de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
|
||||||
|
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
|
||||||
|
.map { it.cycle }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = IMPLEMENTED_VIEWS,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Order of the views in the navigation drawer (#24); every view always shown. */
|
||||||
|
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = IMPLEMENTED_VIEWS,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -16,10 +19,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
import de.jeanlucmakiola.calendula.ui.permission.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,
|
||||||
@@ -29,6 +35,8 @@ fun RootScreen(
|
|||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
|
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||||
|
onInsertConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -45,19 +53,31 @@ fun RootScreen(
|
|||||||
hasPermission = ContextCompat.checkSelfPermission(
|
hasPermission = ContextCompat.checkSelfPermission(
|
||||||
context, Manifest.permission.READ_CALENDAR
|
context, Manifest.permission.READ_CALENDAR
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
// Refresh the contact special-dates mirror on foreground (the
|
||||||
|
// worker is debounced and no-ops when the feature is off). Gated
|
||||||
|
// on the permission so users without the opt-in never enqueue it.
|
||||||
|
if (context.hasContactsPermission()) {
|
||||||
|
SpecialDatesScheduler.runNow(context, foreground = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lifecycle.addObserver(obs)
|
lifecycle.addObserver(obs)
|
||||||
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,
|
||||||
@@ -66,6 +86,8 @@ fun RootScreen(
|
|||||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = onImportConsumed,
|
onImportConsumed = onImportConsumed,
|
||||||
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
onInsertConsumed = onInsertConsumed,
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
@@ -73,10 +95,12 @@ fun RootScreen(
|
|||||||
)
|
)
|
||||||
null -> {}
|
null -> {}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
PermissionScreen(
|
PermissionScreen(
|
||||||
onGranted = { hasPermission = true },
|
onGranted = { hasPermission = true },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,43 @@
|
|||||||
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). */
|
/**
|
||||||
data class OpenDate(val dateIso: String) : WidgetNavRequest
|
* Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
|
||||||
|
* [source]. A null [source] means the request came from outside the app (a
|
||||||
|
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
|
||||||
|
* the default home view instead of a widget's view.
|
||||||
|
*/
|
||||||
|
data class OpenDate(val dateIso: String, val source: CalendarView?) : WidgetNavRequest
|
||||||
|
|
||||||
|
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
||||||
|
data class OpenEvent(
|
||||||
|
val eventId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
val source: CalendarView,
|
||||||
|
) : WidgetNavRequest
|
||||||
|
|
||||||
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
/** 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the app rooted on a top-level [view] with no date drill-in — a widget
|
||||||
|
* header tap. A null [view] means "the user's default home view" (the agenda
|
||||||
|
* widget's "Upcoming" title, issue #20); a concrete view roots there over the
|
||||||
|
* default home (the month widget's month/year title → [CalendarView.Month],
|
||||||
|
* issue #18), so backing out returns to the default view, then exits.
|
||||||
|
*/
|
||||||
|
data class OpenView(val view: CalendarView?) : 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,9 +39,13 @@ 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.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -45,17 +54,28 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
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
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
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.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -73,23 +93,30 @@ private val zone = TimeZone.currentSystemDefault()
|
|||||||
fun AgendaScreen(
|
fun AgendaScreen(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val 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,
|
||||||
@@ -97,6 +124,7 @@ fun AgendaScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = anchor,
|
currentDate = anchor,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -117,8 +145,9 @@ fun AgendaScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
AgendaTopBar(
|
AgendaTopBar(
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -131,23 +160,138 @@ fun AgendaScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
AgendaContent(
|
Column(
|
||||||
state = state,
|
|
||||||
onRetry = viewModel::goToToday,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.fillMaxSize()
|
||||||
.fillMaxSize(),
|
.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
// One bar at the top: the "showing …" header on the left and the
|
||||||
|
// session range switcher on the right (one settings toggle).
|
||||||
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
AgendaRangeBanner(
|
||||||
|
range = s.range,
|
||||||
|
start = s.anchor,
|
||||||
|
end = s.rangeEnd,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
AgendaRangePill(
|
||||||
|
range = s.range,
|
||||||
|
isOverride = s.rangeIsOverride,
|
||||||
|
onClick = { showRangePicker = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AgendaContent(
|
||||||
|
state = state,
|
||||||
|
pastDisplay = pastDisplay,
|
||||||
|
onRetry = viewModel::goToToday,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showRangePicker) {
|
||||||
|
AgendaRangePicker(
|
||||||
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
description = stringResource(R.string.agenda_range_override_hint),
|
||||||
|
selected = successState?.range ?: AgendaRange.Month,
|
||||||
|
onSelect = viewModel::setRangeOverride,
|
||||||
|
onDismiss = { showRangePicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
||||||
|
* range picker as a session-only override. Filled with the primary container
|
||||||
|
* while an override is active, so the temporary state is obvious.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AgendaRangePill(
|
||||||
|
range: AgendaRange,
|
||||||
|
isOverride: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val container = if (isOverride) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
val content = if (isOverride) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = container,
|
||||||
|
contentColor = content,
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
modifier = modifier.clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.DateRange,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = agendaRangeLabel(range),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A header naming the concrete window currently shown, e.g. "Showing all events
|
||||||
|
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AgendaRangeBanner(
|
||||||
|
range: AgendaRange,
|
||||||
|
start: LocalDate,
|
||||||
|
end: LocalDate,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
|
val window = agendaRangeWindowSummary(range, start, end, locale)
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.agenda_range_showing_label),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${agendaRangeLabel(range)}, $window",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaContent(
|
private fun AgendaContent(
|
||||||
state: AgendaUiState,
|
state: AgendaUiState,
|
||||||
|
pastDisplay: PastEventDisplay,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
@@ -155,11 +299,32 @@ private fun AgendaContent(
|
|||||||
is AgendaUiState.Failure -> Box(modifier) {
|
is AgendaUiState.Failure -> Box(modifier) {
|
||||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||||
}
|
}
|
||||||
is AgendaUiState.Success ->
|
is AgendaUiState.Success -> {
|
||||||
if (state.days.isEmpty()) {
|
val now by rememberCurrentMinute()
|
||||||
|
// Hiding drops finished events — and any day they leave empty; dimming
|
||||||
|
// keeps them but fades the row. Recomputed each minute so events fall
|
||||||
|
// away (or fade) as they end while the screen stays open.
|
||||||
|
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
|
state.days.mapNotNull { day ->
|
||||||
|
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||||
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.days
|
||||||
|
}
|
||||||
|
if (days.isEmpty()) {
|
||||||
AgendaEmpty(modifier)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
AgendaList(
|
||||||
|
days = days,
|
||||||
|
today = state.today,
|
||||||
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
|
now = now,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,8 +332,12 @@ private fun AgendaContent(
|
|||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaList(
|
private fun AgendaList(
|
||||||
state: AgendaUiState.Success,
|
days: List<AgendaDay>,
|
||||||
|
today: LocalDate,
|
||||||
|
dimPast: Boolean,
|
||||||
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -176,9 +345,9 @@ private fun AgendaList(
|
|||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
state.days.forEach { day ->
|
days.forEach { day ->
|
||||||
stickyHeader(key = "header-${day.date}") {
|
stickyHeader(key = "header-${day.date}") {
|
||||||
AgendaDayHeader(date = day.date, today = state.today)
|
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||||
}
|
}
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = day.events,
|
items = day.events,
|
||||||
@@ -187,6 +356,8 @@ private fun AgendaList(
|
|||||||
AgendaEventRow(
|
AgendaEventRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
|
modifier = animateItemMotion(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -196,10 +367,16 @@ private fun AgendaList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
private fun AgendaDayHeader(
|
||||||
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onOpenDay(date) },
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = agendaDayLabel(date, today),
|
text = agendaDayLabel(date, today),
|
||||||
@@ -218,11 +395,14 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
|||||||
private fun AgendaEventRow(
|
private fun AgendaEventRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
position: Position,
|
position: Position,
|
||||||
|
dimmed: Boolean,
|
||||||
|
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 = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
title = title,
|
title = title,
|
||||||
summary = agendaTimeSummary(event),
|
summary = agendaTimeSummary(event),
|
||||||
position = position,
|
position = position,
|
||||||
@@ -247,7 +427,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 +438,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 +447,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 +466,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 +504,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,9 @@ 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.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.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 +20,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 +36,36 @@ 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()) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How to treat events that already ended today (show / dim / hide). A display
|
||||||
|
* concern only, so it rides alongside the data state rather than re-querying;
|
||||||
|
* the screen combines it with a per-minute "now" to fade or drop past rows.
|
||||||
|
*/
|
||||||
|
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = PastEventDisplay.SHOW,
|
||||||
|
)
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
@@ -48,14 +74,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 +119,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,14 +4,16 @@ 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.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
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
|
||||||
@@ -21,31 +23,40 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.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.FileDownload
|
||||||
import androidx.compose.material.icons.filled.OpenInNew
|
import androidx.compose.material.icons.filled.FileUpload
|
||||||
|
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.Checkbox
|
||||||
|
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
|
||||||
@@ -55,36 +66,64 @@ 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.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.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.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.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.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
|
||||||
import java.time.LocalDate
|
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
|
||||||
|
|
||||||
|
// SAF mime filter for the restore picker. `.ics` files reach us under several
|
||||||
|
// mimes depending on the source app (our own export uses text/calendar; others
|
||||||
|
// hand them out as octet-stream or text/plain), so accept the common set rather
|
||||||
|
// than hide valid backups behind an over-tight filter.
|
||||||
|
private val RESTORE_MIME_TYPES = arrayOf(
|
||||||
|
"text/calendar",
|
||||||
|
"application/octet-stream",
|
||||||
|
"text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar manager (reached from Settings). Lists the app's own device-only
|
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||||
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
||||||
@@ -95,11 +134,14 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
|||||||
@Composable
|
@Composable
|
||||||
fun CalendarsScreen(
|
fun CalendarsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onImport: (android.net.Uri) -> Unit,
|
||||||
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 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
|
||||||
@@ -113,7 +155,7 @@ fun CalendarsScreen(
|
|||||||
sessionKey = editorSession,
|
sessionKey = editorSession,
|
||||||
isNew = editorId == NEW_CALENDAR_ID,
|
isNew = editorId == NEW_CALENDAR_ID,
|
||||||
initialName = editing?.displayName.orEmpty(),
|
initialName = editing?.displayName.orEmpty(),
|
||||||
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
|
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||||
initialDescription = editing?.description.orEmpty(),
|
initialDescription = editing?.description.orEmpty(),
|
||||||
onSave = { name, color, description ->
|
onSave = { name, color, description ->
|
||||||
val id = editorId
|
val id = editorId
|
||||||
@@ -134,14 +176,22 @@ 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,
|
backupResult = backupResult,
|
||||||
onExportBackup = viewModel::exportBackup,
|
onExportBackup = viewModel::exportBackup,
|
||||||
|
onImport = onImport,
|
||||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,17 +200,29 @@ 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?,
|
backupResult: BackupResult?,
|
||||||
onExportBackup: (android.net.Uri) -> Unit,
|
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
|
||||||
|
onImport: (android.net.Uri) -> Unit,
|
||||||
onConsumeBackupResult: () -> 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) {
|
||||||
@@ -171,10 +233,26 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SAF "create document" target for the backup file. The picked Uri is handed
|
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||||
// to the VM to stream the .ics into.
|
// to the VM to stream the .ics into. This launcher exports everything
|
||||||
|
// eligible (null); the per-calendar selector owns its own launcher.
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
) { uri -> uri?.let(onExportBackup) }
|
) { uri -> uri?.let { onExportBackup(it, null) } }
|
||||||
|
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// SAF "open document" picker for restoring events from a .ics file. The
|
||||||
|
// picked Uri is handed up to the host, which runs it through the same import
|
||||||
|
// flow as an externally opened .ics (parse, dedup by UID, target picker).
|
||||||
|
val openBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
) { uri -> uri?.let(onImport) }
|
||||||
|
|
||||||
|
// 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)
|
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||||
LaunchedEffect(backupResult) {
|
LaunchedEffect(backupResult) {
|
||||||
@@ -199,82 +277,275 @@ private fun CalendarsList(
|
|||||||
title = stringResource(R.string.calendars_title),
|
title = stringResource(R.string.calendars_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
// What the per-calendar / per-account switches below actually do.
|
||||||
|
HintText(stringResource(R.string.calendars_disable_hint))
|
||||||
|
|
||||||
|
// Local (device-only) calendars — one collapsible group. The header's
|
||||||
|
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||||
|
// tapping a calendar row opens its editor.
|
||||||
|
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds }
|
||||||
|
CalendarGroup(
|
||||||
|
title = stringResource(R.string.calendars_local_header),
|
||||||
|
expanded = localExpanded,
|
||||||
|
bodyHasRows = local.isNotEmpty(),
|
||||||
|
headerDisabled = localDisabled,
|
||||||
|
leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } },
|
||||||
|
manageIcon = Icons.Default.Add,
|
||||||
|
manageLabel = stringResource(R.string.calendars_add),
|
||||||
|
onManage = onAdd,
|
||||||
|
onToggleExpand = { localExpanded = !localExpanded },
|
||||||
|
showToggleAll = local.isNotEmpty(),
|
||||||
|
allEnabled = local.none { it.id in disabledIds },
|
||||||
|
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
|
||||||
) {
|
) {
|
||||||
// Local (device-only) calendars — the calendars the app owns. The
|
|
||||||
// "Add calendar" entry closes the group as its final row.
|
|
||||||
SectionHeader(stringResource(R.string.calendars_local_header))
|
|
||||||
if (local.isEmpty()) {
|
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) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GroupedRow(
|
}
|
||||||
title = stringResource(R.string.calendars_add),
|
}
|
||||||
position = positionOf(local.size, localCount),
|
|
||||||
leading = { AddAvatar() },
|
|
||||||
onClick = onAdd,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Backup — local calendars have no sync, so a .ics export is their only
|
// Backup — local calendars have no sync, so a .ics export is their only
|
||||||
// safety net. Offered only when there is something to back up.
|
// safety net. Offered only when there is something exportable: the user's
|
||||||
if (local.isNotEmpty()) {
|
// own local calendars (managed special-dates mirrors don't count).
|
||||||
|
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
||||||
|
// Restore/import can target any writable, non-managed calendar (local or
|
||||||
|
// synced), so its availability is broader than export's.
|
||||||
|
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
|
||||||
|
if (exportable.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
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_backup_action),
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
position = Position.Alone,
|
position = Position.Top,
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
onClick = {
|
onClick = {
|
||||||
|
// With more than one exportable calendar, let the user choose
|
||||||
|
// which to include; a single one exports straight away.
|
||||||
|
if (exportable.size == 1) {
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
} else {
|
||||||
|
showExportPicker = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
summary = stringResource(R.string.calendars_restore_hint),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = {
|
||||||
|
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup),
|
||||||
|
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||||
|
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = autoBackup.enabled, onCheckedChange = 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))
|
||||||
|
}
|
||||||
|
} else if (canImport) {
|
||||||
|
// Nothing to back up (no writable local calendar), but events can
|
||||||
|
// still be restored into a writable calendar — offer restore on its
|
||||||
|
// own so it isn't hidden behind export eligibility.
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||||
|
HintText(stringResource(R.string.calendars_restore_hint))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
leading = { AddAvatar() },
|
|
||||||
onClick = {
|
|
||||||
runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) }
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showInterval) {
|
||||||
|
BackupIntervalDialog(
|
||||||
|
currentMinutes = autoBackup.intervalMinutes,
|
||||||
|
onConfirm = onSetAutoBackupInterval,
|
||||||
|
onDismiss = { showInterval = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showExportPicker) {
|
||||||
|
ExportCalendarPicker(
|
||||||
|
calendars = local.filter { it.canModifyContents && !it.isManaged },
|
||||||
|
onExport = onExportBackup,
|
||||||
|
onDismiss = { showExportPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||||
|
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||||
|
* the picked file with the chosen calendar ids.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ExportCalendarPicker(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
onExport: (android.net.Uri, Set<Long>?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Seed once with everything selected and hold it across recomposition and
|
||||||
|
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
|
||||||
|
// it would silently reset the user's de-selections whenever the provider
|
||||||
|
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
|
||||||
|
// — the data layer intersects the chosen set with the eligible calendars.
|
||||||
|
var selected by rememberSaveable(
|
||||||
|
stateSaver = listSaver(
|
||||||
|
save = { it.toList() },
|
||||||
|
restore = { it.toSet() },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
mutableStateOf(calendars.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
onExport(uri, selected)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.calendars_export_title),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
) {
|
||||||
|
HintText(stringResource(R.string.calendars_export_hint))
|
||||||
|
calendars.forEachIndexed { index, calendar ->
|
||||||
|
val isSelected = calendar.id in selected
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = calendar.description,
|
||||||
|
position = positionOf(index, calendars.size),
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
},
|
||||||
|
enabled = selected.isNotEmpty(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.calendars_export_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -295,10 +566,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 = {
|
||||||
@@ -375,7 +645,7 @@ private fun CalendarEditor(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
ColorSwatchRow(
|
ColorSwatchRow(
|
||||||
colors = CALENDAR_COLOR_PALETTE,
|
colors = CalendarColorPalette.all,
|
||||||
selected = color,
|
selected = color,
|
||||||
onSelect = { color = it },
|
onSelect = { color = it },
|
||||||
dark = dark,
|
dark = dark,
|
||||||
@@ -425,6 +695,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(
|
||||||
@@ -456,68 +750,135 @@ 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,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = title,
|
||||||
|
position = if (expanded && bodyHasRows) Position.Top else Position.Alone,
|
||||||
|
dimmed = headerDisabled,
|
||||||
|
leading = leading,
|
||||||
|
trailing = {
|
||||||
|
CalendarGroupMenu(
|
||||||
|
title = title,
|
||||||
|
showToggleAll = showToggleAll,
|
||||||
|
allEnabled = allEnabled,
|
||||||
|
onToggleAll = onToggleAll,
|
||||||
|
manageIcon = manageIcon,
|
||||||
|
manageLabel = manageLabel,
|
||||||
|
onManage = onManage,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onToggleExpand,
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = expanded,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
|
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(
|
Text(
|
||||||
text = account,
|
stringResource(
|
||||||
style = MaterialTheme.typography.titleSmall,
|
if (allEnabled) R.string.calendars_disable_all
|
||||||
fontWeight = FontWeight.SemiBold,
|
else R.string.calendars_enable_all,
|
||||||
modifier = Modifier.weight(1f),
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
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 carrying an arbitrary icon — matches [AddAvatar]'s shape. */
|
|
||||||
@Composable
|
|
||||||
private fun LeadingAvatar(icon: ImageVector) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
|
||||||
@Composable
|
|
||||||
private fun AddAvatar() {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Add,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionHeader(text: String) {
|
private fun SectionHeader(text: String) {
|
||||||
@@ -539,6 +900,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
|
||||||
@@ -563,9 +1013,3 @@ private fun sourceAppIntent(context: Context, accountType: String): Intent {
|
|||||||
return Intent(Settings.ACTION_SYNC_SETTINGS)
|
return Intent(Settings.ACTION_SYNC_SETTINGS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preferred app for account types whose authenticator isn't the app to open. */
|
|
||||||
private fun curatedSourcePackage(accountType: String): String? = when {
|
|
||||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
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 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.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.data.reminders.ReminderNotifier
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -15,6 +24,8 @@ 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
|
||||||
@@ -31,8 +42,13 @@ 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 icsExporter: IcsExporter,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
private val suppressedStore: SuppressedReminderStore,
|
||||||
|
private val notifier: ReminderNotifier,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -46,6 +62,36 @@ 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()
|
||||||
|
|
||||||
@@ -61,11 +107,11 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||||
* in [backupResult] for a one-shot message.
|
* in [backupResult] for a one-shot message.
|
||||||
*/
|
*/
|
||||||
fun exportBackup(uri: Uri) {
|
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_backupResult.value = try {
|
_backupResult.value = try {
|
||||||
val count = withContext(io) {
|
val count = withContext(io) {
|
||||||
val events = repository.exportEvents()
|
val events = repository.exportEvents(calendarIds)
|
||||||
icsExporter.writeDocument(
|
icsExporter.writeDocument(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||||
@@ -93,6 +139,101 @@ 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)
|
||||||
|
if (!disabled) recoverReminders(setOf(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
if (!disabled) recoverReminders(current intersect ids.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
|
||||||
|
* still relevant (event not yet over), then drop them from the stash. Runs
|
||||||
|
* after the disabled set is written, so the notifier's own disabled gate lets
|
||||||
|
* them through. Best-effort at re-enable time: it mirrors the receiver gates
|
||||||
|
* (reminders on + postable), and there is no later re-scan, so alerts left
|
||||||
|
* unposted because those gates are closed are simply released.
|
||||||
|
*/
|
||||||
|
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
|
||||||
|
if (reEnabledIds.isEmpty()) return
|
||||||
|
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
|
||||||
|
if (recovered.isNotEmpty() &&
|
||||||
|
settingsPrefs.remindersEnabled.first() &&
|
||||||
|
notifier.canPost()
|
||||||
|
) {
|
||||||
|
recovered.forEach { notifier.post(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 {
|
||||||
@@ -106,6 +247,14 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Automatic-backup settings + last-run status for the Backup section. */
|
||||||
|
data class AutoBackupUiState(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL,
|
||||||
|
val folderUri: String? = null,
|
||||||
|
val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0),
|
||||||
|
)
|
||||||
|
|
||||||
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||||
sealed interface BackupResult {
|
sealed interface BackupResult {
|
||||||
data class Success(val eventCount: Int) : BackupResult
|
data class Success(val eventCount: Int) : BackupResult
|
||||||
|
|||||||
@@ -13,22 +13,8 @@ import androidx.compose.runtime.Composable
|
|||||||
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
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
/**
|
|
||||||
* Soften a raw calendar color toward a pastel that fits the active theme.
|
|
||||||
* - Keeps the hue (so users still recognise their calendars)
|
|
||||||
* - Caps saturation so harsh provider colors stop screaming
|
|
||||||
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
|
||||||
*/
|
|
||||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
|
||||||
val hsv = FloatArray(3)
|
|
||||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
|
||||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
|
||||||
hsv[2] = if (dark) 0.82f else 0.72f
|
|
||||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +62,7 @@ fun CalendarDrawer(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
|
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
) {
|
) {
|
||||||
var showDatePicker by remember { mutableStateOf(false) }
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -73,10 +77,10 @@ fun CalendarDrawer(
|
|||||||
DrawerHeader()
|
DrawerHeader()
|
||||||
|
|
||||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
viewOrder.forEachIndexed { index, view ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(view.labelRes),
|
title = stringResource(view.labelRes),
|
||||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
position = positionOf(index, viewOrder.size),
|
||||||
selected = view == currentView,
|
selected = view == currentView,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { Icon(view.icon, contentDescription = null) },
|
leading = { Icon(view.icon, contentDescription = null) },
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.accounts.AccountManager
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's single "which calendar" selection list, shared by the event editor
|
||||||
|
* and the .ics import screen. Renders the same grouped-card system as the
|
||||||
|
* calendar-manager screen: a category header per source — the device chip for
|
||||||
|
* the app's own calendars, the owning app's launcher icon for each synced
|
||||||
|
* account — with the calendars beneath it as a connected card, a colour chip on
|
||||||
|
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
||||||
|
* (a scrolling column), so the caller owns the surrounding chrome.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ColumnScope.CalendarPickerGroups(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
selectedId: Long?,
|
||||||
|
onSelect: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||||
|
val syncedGroups = remember(calendars) {
|
||||||
|
calendars.filterNot { it.isLocal }
|
||||||
|
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local.isNotEmpty()) {
|
||||||
|
CalendarPickerGroup(
|
||||||
|
title = stringResource(R.string.calendars_local_header),
|
||||||
|
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
|
||||||
|
calendars = local,
|
||||||
|
selectedId = selectedId,
|
||||||
|
onSelect = onSelect,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
syncedGroups.forEachIndexed { index, (account, cals) ->
|
||||||
|
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||||
|
CalendarPickerGroup(
|
||||||
|
title = account,
|
||||||
|
leading = { SourceLogo(cals.first().accountType) },
|
||||||
|
calendars = cals,
|
||||||
|
selectedId = selectedId,
|
||||||
|
onSelect = onSelect,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||||
|
@Composable
|
||||||
|
private fun CalendarPickerGroup(
|
||||||
|
title: String,
|
||||||
|
leading: @Composable () -> Unit,
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
selectedId: Long?,
|
||||||
|
onSelect: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = title,
|
||||||
|
position = Position.Top,
|
||||||
|
leading = leading,
|
||||||
|
)
|
||||||
|
calendars.forEachIndexed { index, calendar ->
|
||||||
|
val isSelected = calendar.id == selectedId
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||||
|
selected = isSelected,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { onSelect(calendar.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 [android.content.pm.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
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
|
||||||
|
@Composable
|
||||||
|
fun LeadingAvatar(icon: ImageVector) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||||
|
internal fun curatedSourcePackage(accountType: String): String? = when {
|
||||||
|
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
|
|
||||||
import androidx.compose.animation.ContentTransform
|
import androidx.compose.animation.ContentTransform
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
@@ -10,6 +12,16 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar-specific motion. The family's generic content transitions — section
|
||||||
|
* expand/collapse, item reveal, list relayout, cross-fade, predictive-back, and
|
||||||
|
* the reduce-motion check — now live in floret-kit's identity module
|
||||||
|
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
||||||
|
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
||||||
|
* here is only what's specific to paging the calendar grid: the directional
|
||||||
|
* month/week/day slide and the specs that feed it.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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*
|
||||||
* spring-physics spec from the active motion scheme — snappy with a subtle
|
* spring-physics spec from the active motion scheme — snappy with a subtle
|
||||||
@@ -24,16 +36,34 @@ 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 })
|
||||||
|
|||||||
@@ -45,7 +45,63 @@ val IMPLEMENTED_VIEWS: List<CalendarView> =
|
|||||||
|
|
||||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||||
|
if (available.isEmpty()) return this
|
||||||
val i = available.indexOf(this)
|
val i = available.indexOf(this)
|
||||||
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
if (i < 0) return available.first()
|
||||||
return available[(i + 1) % available.size]
|
return available[(i + 1) % available.size]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's customisation of the top-bar quick-switch button (#24): which views
|
||||||
|
* it cycles through ([enabled]) and in what [order]. [order] always lists every
|
||||||
|
* implemented view — the settings screen reorders the whole set — while [cycle]
|
||||||
|
* is the subset the pill actually steps through, in [order]. The navigation
|
||||||
|
* drawer keeps its own separate order and always lists every view, so a view
|
||||||
|
* disabled here stays reachable there.
|
||||||
|
*/
|
||||||
|
data class QuickSwitchConfig(
|
||||||
|
val order: List<CalendarView>,
|
||||||
|
val enabled: Set<CalendarView>,
|
||||||
|
) {
|
||||||
|
/** Views the pill steps through, in [order]. */
|
||||||
|
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** All views, in default order, all enabled. */
|
||||||
|
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fewest views that keep the switch meaningful — a "switch" needs at
|
||||||
|
* least two targets, so the settings screen blocks disabling below this.
|
||||||
|
*/
|
||||||
|
const val MIN_ENABLED = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
|
|||||||
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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapping row of round colour swatches; the one matching [selected] is
|
* A wrapping row of round colour swatches; the one matching [selected] is
|
||||||
@@ -67,16 +68,3 @@ fun ColorSwatchRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */
|
|
||||||
val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
|
|
||||||
0xFFD50000, // red
|
|
||||||
0xFFE67C00, // orange
|
|
||||||
0xFFF6BF26, // amber
|
|
||||||
0xFF33B679, // green
|
|
||||||
0xFF0B8043, // dark green
|
|
||||||
0xFF039BE5, // blue
|
|
||||||
0xFF3F51B5, // indigo
|
|
||||||
0xFF8E24AA, // purple
|
|
||||||
0xFF616161, // graphite
|
|
||||||
).map { it.toInt() }
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
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,205 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LargeTopAppBar
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Position of a row within a grouped list, after the Android-15 settings
|
|
||||||
* pattern: a run of rows shares one rounded container, with full corners at the
|
|
||||||
* group's outer edges and small corners between, separated by small gaps.
|
|
||||||
*/
|
|
||||||
enum class Position { Top, Middle, Bottom, Alone }
|
|
||||||
|
|
||||||
/** Maps an index within a group of [count] rows to its [Position]. */
|
|
||||||
fun positionOf(index: Int, count: Int): Position = when {
|
|
||||||
count <= 1 -> Position.Alone
|
|
||||||
index == 0 -> Position.Top
|
|
||||||
index == count - 1 -> Position.Bottom
|
|
||||||
else -> Position.Middle
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
|
|
||||||
* whose title shrinks into the bar (next to the back button) as the content
|
|
||||||
* scrolls. Content is a scrollable column that feeds the toolbar via nested
|
|
||||||
* scroll. Used by Settings and the calendar manager so they share one shell.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun CollapsingScaffold(
|
|
||||||
title: String,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
snackbarHost: @Composable () -> Unit = {},
|
|
||||||
actions: @Composable RowScope.() -> Unit = {},
|
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
val scrollBehavior =
|
|
||||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
topBar = {
|
|
||||||
LargeTopAppBar(
|
|
||||||
title = { Text(title) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.settings_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = actions,
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = snackbarHost,
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(innerPadding)
|
|
||||||
// Mark the scaffold's system-bar insets as consumed so the
|
|
||||||
// imePadding below adds only the keyboard height beyond them
|
|
||||||
// (max, not sum) — otherwise the nav-bar inset double-counts and
|
|
||||||
// leaves an empty strip above the keyboard.
|
|
||||||
.consumeWindowInsets(innerPadding)
|
|
||||||
.fillMaxSize()
|
|
||||||
// Paint the surface across the full area before imePadding carves
|
|
||||||
// into it, so any sliver above the keyboard reads as surface — not
|
|
||||||
// the dialog window's black — during the IME animation.
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
// Shrink the scroll viewport by the keyboard inset so a focused
|
|
||||||
// field (e.g. the custom-reminder amount) can scroll into view.
|
|
||||||
.imePadding()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(top = 8.dp, bottom = 24.dp),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
|
||||||
* corner radii come from its [position] (so a run of rows reads as a single
|
|
||||||
* rounded card). Corners round further on press. A null [onClick] makes the
|
|
||||||
* row non-interactive (e.g. read-only entries).
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun GroupedRow(
|
|
||||||
title: String,
|
|
||||||
position: Position,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
summary: String? = null,
|
|
||||||
selected: Boolean = false,
|
|
||||||
minHeight: Dp = 72.dp,
|
|
||||||
leading: @Composable (() -> Unit)? = null,
|
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val interaction = remember { MutableInteractionSource() }
|
|
||||||
val pressed by interaction.collectIsPressedAsState()
|
|
||||||
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
|
||||||
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
|
||||||
val shape = when (position) {
|
|
||||||
Position.Alone -> RoundedCornerShape(full)
|
|
||||||
Position.Top -> RoundedCornerShape(
|
|
||||||
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
|
||||||
)
|
|
||||||
Position.Middle -> RoundedCornerShape(small)
|
|
||||||
Position.Bottom -> RoundedCornerShape(
|
|
||||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val gap = when (position) {
|
|
||||||
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
|
||||||
Position.Bottom, Position.Alone -> Modifier
|
|
||||||
}
|
|
||||||
val itemColors = if (selected) {
|
|
||||||
ListItemDefaults.colors(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
|
||||||
}
|
|
||||||
val item: @Composable () -> Unit = {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(title) },
|
|
||||||
supportingContent = summary?.let { text -> { Text(text) } },
|
|
||||||
leadingContent = leading,
|
|
||||||
trailingContent = trailing,
|
|
||||||
colors = itemColors,
|
|
||||||
modifier = Modifier.heightIn(min = minHeight),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val base = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.then(gap)
|
|
||||||
val containerColor = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
}
|
|
||||||
if (onClick != null) {
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
color = containerColor,
|
|
||||||
shape = shape,
|
|
||||||
interactionSource = interaction,
|
|
||||||
modifier = base,
|
|
||||||
) { item() }
|
|
||||||
} else {
|
|
||||||
Surface(color = containerColor, shape = shape, modifier = base) { item() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's borderless text input: no underline, no outline, just the tonal
|
|
||||||
* card behind it. This is the standard input across the app — we deliberately
|
|
||||||
* don't use Material's outlined/filled text fields, so anything that takes text
|
|
||||||
* (the event form, the calendar manager, dialogs) uses this inside a tonal
|
|
||||||
* [androidx.compose.material3.Surface].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun InlineTextField(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
placeholder: String,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
|
||||||
singleLine: Boolean = true,
|
|
||||||
minLines: Int = 1,
|
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
|
||||||
) {
|
|
||||||
val resolvedStyle = textStyle.copy(
|
|
||||||
color = if (textStyle.color.isSpecified) {
|
|
||||||
textStyle.color
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
)
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
textStyle = resolvedStyle,
|
|
||||||
singleLine = singleLine,
|
|
||||||
minLines = minLines,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = keyboardType,
|
|
||||||
capitalization = capitalization,
|
|
||||||
),
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box {
|
|
||||||
if (value.isEmpty()) {
|
|
||||||
// Clearly fainter than typed text, so a hint never reads as
|
|
||||||
// prefilled content.
|
|
||||||
Text(
|
|
||||||
text = placeholder,
|
|
||||||
style = resolvedStyle,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
private val DotSize = 8.dp
|
||||||
|
private val LineThickness = 2.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current wall-clock instant that updates once a minute, re-aligning to each
|
||||||
|
* minute boundary (rather than ticking a fixed 60 s) so it never drifts toward
|
||||||
|
* the middle of a minute. Drives the "now" line in the day/week grids.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberCurrentMinute(): State<Instant> {
|
||||||
|
val instant = remember { mutableStateOf(Clock.System.now()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
val now = Clock.System.now()
|
||||||
|
instant.value = now
|
||||||
|
delay(60_000L - now.toEpochMilliseconds() % 60_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instant
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thin "current time" indicator — a leading dot plus a line — drawn across a
|
||||||
|
* day column. Positioned on the same [hourHeight] scale the event blocks use so
|
||||||
|
* it lines up with the grid, and refreshed each minute. Renders nothing unless
|
||||||
|
* the wall clock is on [date]; callers mount it only for the column showing
|
||||||
|
* today, so the per-minute tick runs on a single column.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NowLine(
|
||||||
|
date: LocalDate,
|
||||||
|
hourHeight: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val now by rememberCurrentMinute()
|
||||||
|
val local = now.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
|
if (local.date != date) return
|
||||||
|
|
||||||
|
val minutes = local.hour * 60 + local.minute
|
||||||
|
val top = hourHeight * (minutes / 60f)
|
||||||
|
val color = MaterialTheme.colorScheme.primary
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(DotSize)
|
||||||
|
.offset(y = top - DotSize / 2),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(LineThickness)
|
||||||
|
.background(color),
|
||||||
|
)
|
||||||
|
// The dot anchors the line to the gutter edge, mirroring the standard
|
||||||
|
// calendar "now" marker; drawn after the line so it sits on top.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(DotSize)
|
||||||
|
.background(color, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's standard pick in a selection dialog: a full-width tonal card,
|
|
||||||
* optionally with a leading icon and a supporting line; the selected option
|
|
||||||
* is highlighted. Stack with 8dp gaps inside an AlertDialog — this is the
|
|
||||||
* only sanctioned selection-modal style (no radio rows, no bare text lists).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun OptionCard(
|
|
||||||
label: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
icon: ImageVector? = null,
|
|
||||||
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
|
|
||||||
iconTint: Color = Color.Unspecified,
|
|
||||||
supportingText: String? = null,
|
|
||||||
selected: Boolean = false,
|
|
||||||
/** Label colour override, e.g. primary for an emphasised "Custom" entry. */
|
|
||||||
labelColor: Color = Color.Unspecified,
|
|
||||||
) {
|
|
||||||
val contentColor = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
}
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
color = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
|
||||||
) {
|
|
||||||
if (icon != null) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = when {
|
|
||||||
iconTint.isSpecified -> iconTint
|
|
||||||
selected -> MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
}
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = if (labelColor.isSpecified) labelColor else contentColor,
|
|
||||||
)
|
|
||||||
if (supportingText != null) {
|
|
||||||
Text(
|
|
||||||
text = supportingText,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cut-off instant before which a calendar event counts as "completed" and is
|
||||||
|
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
|
||||||
|
* the "dim completed events" setting is on, or `null` when it is off (nothing
|
||||||
|
* dims). Provided per grid screen so only the event chips that read it recompose
|
||||||
|
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
|
||||||
|
*/
|
||||||
|
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||||
|
|
||||||
|
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||||
|
const val EventDimAlpha = 0.4f
|
||||||
@@ -7,11 +7,13 @@ import androidx.compose.foundation.layout.ColumnScope
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
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.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.FilledTonalButton
|
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
|
||||||
@@ -20,174 +22,163 @@ import androidx.compose.material3.SegmentedButtonDefaults
|
|||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
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.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
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
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.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||||
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
|
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||||
* picker is visually identical to a Settings sub-page and uses the full width.
|
* independently, so a default can carry several reminders ("1 week before" *and*
|
||||||
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
* "on the day"). Two exclusive checkmark rows sit above the list: an explicit
|
||||||
*/
|
* "No reminder" (both pickers, so the empty state stays visible and reachable)
|
||||||
@Composable
|
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
||||||
fun FullScreenPicker(
|
* defers to the global default. Clearing the last checked time reverts to "Use
|
||||||
title: String,
|
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
|
||||||
onDismiss: () -> Unit,
|
* wipe the calendar's default) and to explicit [ReminderOverride.None]
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
* on the global default (where empty legitimately means no reminder). A "Custom"
|
||||||
) {
|
* row expands an inline number field plus a unit selector to add an arbitrary
|
||||||
Dialog(
|
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||||
onDismissRequest = onDismiss,
|
* back.
|
||||||
properties = DialogProperties(
|
|
||||||
usePlatformDefaultWidth = false,
|
|
||||||
decorFitsSystemWindows = false,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
// The dialog window pans by default when the keyboard opens, which —
|
|
||||||
// combined with the content's own imePadding — leaves a fixed black gap
|
|
||||||
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
|
||||||
// full-screen and imePadding alone lifts the focused field.
|
|
||||||
val view = LocalView.current
|
|
||||||
SideEffect {
|
|
||||||
(view.parent as? DialogWindowProvider)?.window
|
|
||||||
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
|
||||||
}
|
|
||||||
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* General single-select picker, full-screen: each option is a connected grouped
|
|
||||||
* row and the current one carries a check. Drop-in for the former dialog
|
|
||||||
* (theme, week start, language, …).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun <T> OptionPicker(
|
|
||||||
title: String,
|
|
||||||
options: List<T>,
|
|
||||||
selected: T,
|
|
||||||
label: @Composable (T) -> String,
|
|
||||||
onSelect: (T) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
|
||||||
options.forEachIndexed { index, option ->
|
|
||||||
val isSelected = option == selected
|
|
||||||
GroupedRow(
|
|
||||||
title = label(option),
|
|
||||||
position = positionOf(index, options.size),
|
|
||||||
selected = isSelected,
|
|
||||||
trailing = if (isSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
onSelect(option)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
|
||||||
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
|
||||||
* a "Custom" row that expands an inline number field plus a segmented unit
|
|
||||||
* selector. Returns the choice as a [CalendarReminderOverride].
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderDefaultPicker(
|
fun ReminderDefaultPicker(
|
||||||
title: String,
|
title: String,
|
||||||
presets: List<Int>,
|
presets: List<Int>,
|
||||||
selected: CalendarReminderOverride,
|
selected: ReminderOverride,
|
||||||
allowInherit: Boolean,
|
allowInherit: Boolean,
|
||||||
onSelect: (CalendarReminderOverride) -> Unit,
|
onSelect: (ReminderOverride) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
|
// Optimistic local state: once the user edits, the chosen override is
|
||||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
// authoritative while the picker is open, so quick successive toggles compose
|
||||||
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
// on each other instead of racing the round-trip through the settings flow
|
||||||
|
// (which would drop a toggle made before the previous write echoes back).
|
||||||
|
var current by remember { mutableStateOf(selected) }
|
||||||
|
// Whether the user has toggled anything yet. Until then, [current] keeps
|
||||||
|
// mirroring [selected]: a picker that first composed against the settings
|
||||||
|
// flow's initialValue (empty — reachable after process-death restore straight
|
||||||
|
// onto the Notifications page, before the CalendarProvider-gated combine
|
||||||
|
// resolves) then adopts the real stored default instead of persisting empty
|
||||||
|
// on the first tap. After the first edit, local state is authoritative.
|
||||||
|
var userEdited by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(selected) {
|
||||||
|
if (!userEdited) current = selected
|
||||||
|
}
|
||||||
|
val inherits = current is ReminderOverride.Inherit
|
||||||
|
val isNone = current is ReminderOverride.None
|
||||||
|
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
|
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
||||||
|
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
||||||
|
// which would strand a hand-entered value with no way to re-check it.
|
||||||
|
val seenCustom = remember { mutableSetOf<Int>() }
|
||||||
|
seenCustom += selectedMinutes.filter { it !in presets }
|
||||||
|
// Presets plus every custom value seen this session, each as its own row.
|
||||||
|
val rows = presets + seenCustom.sorted()
|
||||||
|
|
||||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
var amountText by rememberSaveable { mutableStateOf("") }
|
||||||
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||||
|
|
||||||
val options = buildList {
|
fun apply(override: ReminderOverride) {
|
||||||
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
userEdited = true
|
||||||
add(CalendarReminderOverride.None)
|
current = override
|
||||||
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
onSelect(override)
|
||||||
}
|
}
|
||||||
val rowCount = options.size + 1 // + the custom row
|
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
|
||||||
|
fun toggle(minute: Int) =
|
||||||
|
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
options.forEachIndexed { index, option ->
|
// Exclusive choices in their own group above the multi-select list, since
|
||||||
val isSelected = option == selected
|
// each is mutually exclusive with picking specific times: "use default"
|
||||||
|
// (per-calendar only) and an explicit "no reminder" (both pickers, so the
|
||||||
|
// empty state stays visible and deliberately reachable).
|
||||||
|
if (allowInherit) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = reminderOverrideLabel(option),
|
title = stringResource(R.string.reminder_use_default),
|
||||||
position = positionOf(index, rowCount),
|
position = Position.Top,
|
||||||
selected = isSelected,
|
selected = inherits,
|
||||||
trailing = if (isSelected) {
|
trailing = if (inherits) {
|
||||||
{ SelectedCheck() }
|
{ SelectedCheck() }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = { apply(ReminderOverride.Inherit) },
|
||||||
onSelect(option)
|
)
|
||||||
onDismiss()
|
}
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.reminder_none),
|
||||||
|
position = if (allowInherit) Position.Bottom else Position.Alone,
|
||||||
|
selected = isNone,
|
||||||
|
trailing = if (isNone) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
},
|
},
|
||||||
|
onClick = { apply(ReminderOverride.None) },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
val rowCount = rows.size + 1 // + the custom row
|
||||||
|
rows.forEachIndexed { index, minute ->
|
||||||
|
val checked = minute in selectedMinutes
|
||||||
|
GroupedRow(
|
||||||
|
title = reminderLeadTimeLabel(minute),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = checked,
|
||||||
|
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||||
|
onClick = { toggle(minute) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// When expanded, the Custom row connects downward into the editor card
|
// When expanded, the Custom row connects downward into the editor card
|
||||||
// so the two read as one grouped container (the per-calendar pattern).
|
// so the two read as one grouped container (the per-calendar pattern).
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = if (customSelected) {
|
title = stringResource(R.string.event_edit_reminder_custom),
|
||||||
stringResource(
|
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
|
||||||
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 },
|
onClick = { customExpanded = !customExpanded },
|
||||||
)
|
)
|
||||||
AnimatedVisibility(visible = customExpanded) {
|
AnimatedVisibility(
|
||||||
|
visible = customExpanded,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
CustomReminderEditor(
|
CustomReminderEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
onAmountChange = { amountText = it },
|
onAmountChange = { amountText = it },
|
||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(CalendarReminderOverride.Minutes(minutes))
|
emit(selectedMinutes + minutes)
|
||||||
onDismiss()
|
amountText = ""
|
||||||
|
customExpanded = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
* 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
|
* row above it (matching the grouped-row system, so the two read as one
|
||||||
@@ -256,6 +247,17 @@ private fun CustomReminderEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||||
|
@Composable
|
||||||
|
private fun PickerDescription(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SelectedCheck() {
|
private fun SelectedCheck() {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -265,18 +267,146 @@ private fun SelectedCheck() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||||
|
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||||
|
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
||||||
|
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
fun AgendaRangePicker(
|
||||||
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
title: String,
|
||||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
description: String,
|
||||||
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
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 = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
CustomDaysEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
onConfirm = { days ->
|
||||||
|
onSelect(AgendaRange.Custom(days))
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
/** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */
|
||||||
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
@Composable
|
||||||
minutes == null -> "" to ReminderUnit.Minutes
|
private fun CustomDaysEditor(
|
||||||
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
amountText: String,
|
||||||
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
onAmountChange: (String) -> Unit,
|
||||||
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
onConfirm: (Int) -> Unit,
|
||||||
else -> minutes.toString() to ReminderUnit.Minutes
|
) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,11 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
|
||||||
/** Common reminder lead times offered as quick picks in the form and settings. */
|
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||||
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
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
|
@StringRes
|
||||||
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
ReminderUnit.Minutes -> R.string.reminder_unit_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
|
||||||
@@ -4,14 +4,9 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
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.TimePicker
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -27,10 +22,13 @@ fun TimePickerAlert(
|
|||||||
onConfirm: (LocalTime) -> Unit,
|
onConfirm: (LocalTime) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// Honour the app's own time-format preference (the value every time label
|
||||||
|
// reads), not the system/locale clock — otherwise an explicit 24h setting
|
||||||
|
// still showed an AM/PM dial (issue #27).
|
||||||
val state = rememberTimePickerState(
|
val state = rememberTimePickerState(
|
||||||
initialHour = initial.hour,
|
initialHour = initial.hour,
|
||||||
initialMinute = initial.minute,
|
initialMinute = initial.minute,
|
||||||
is24Hour = deviceUses24HourClock(LocalContext.current),
|
is24Hour = LocalUse24HourFormat.current,
|
||||||
)
|
)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -45,24 +43,3 @@ fun TimePickerAlert(
|
|||||||
text = { TimePicker(state = state) },
|
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,61 @@
|
|||||||
|
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.ui.theme.CalendulaTheme
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,21 @@ 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.IMPLEMENTED_VIEWS
|
||||||
|
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.floret.identity.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.floret.components.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,7 +115,10 @@ 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,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -165,6 +178,7 @@ fun DayScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = date,
|
currentDate = date,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -186,8 +200,9 @@ fun DayScreen(
|
|||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -233,6 +248,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 +300,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 +357,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 +376,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 +475,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 +499,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 +522,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 +539,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 +559,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 +589,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 +605,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 +654,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
|
||||||
@@ -92,10 +91,13 @@ 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.floret.identity.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.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -132,8 +134,6 @@ 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 scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -214,6 +214,7 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.predictiveBack(onBack = onBack),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -249,6 +250,9 @@ fun EventDetailScreen(
|
|||||||
contentDescription = stringResource(R.string.event_detail_edit),
|
contentDescription = stringResource(R.string.event_detail_edit),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Managed special-dates events have no delete — the sync
|
||||||
|
// would resurrect them; the date is removed at the contact.
|
||||||
|
if (!s.isManaged) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onDeleteClick,
|
onClick = onDeleteClick,
|
||||||
enabled = deleteState != DeleteUiState.Deleting,
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
@@ -259,6 +263,7 @@ fun EventDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
@@ -590,10 +595,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),
|
||||||
@@ -770,7 +787,7 @@ 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)
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ sealed interface EventDetailUiState {
|
|||||||
val calendarName: String?,
|
val calendarName: String?,
|
||||||
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
||||||
val canModify: Boolean = false,
|
val canModify: Boolean = false,
|
||||||
|
/**
|
||||||
|
* The event belongs to a managed special-dates calendar: its editable
|
||||||
|
* fields (reminders, notes) can still be edited, but delete is hidden —
|
||||||
|
* the sync would just resurrect it. Contact dates are removed at the source.
|
||||||
|
*/
|
||||||
|
val isManaged: Boolean = false,
|
||||||
) : EventDetailUiState
|
) : EventDetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
detail = corrected,
|
detail = corrected,
|
||||||
calendarName = calendar?.displayName,
|
calendarName = calendar?.displayName,
|
||||||
canModify = calendar?.canModifyContents == true,
|
canModify = calendar?.canModifyContents == true,
|
||||||
|
isManaged = calendar?.isManaged == true,
|
||||||
)
|
)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,18 @@ data class EventEditUiState(
|
|||||||
val hiddenFields: List<EventFormField> = emptyList(),
|
val hiddenFields: List<EventFormField> = emptyList(),
|
||||||
/** True while editing an existing event (the calendar is then fixed). */
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
|
/**
|
||||||
|
* True when the event's calendar is a contact special-dates calendar the app
|
||||||
|
* manages. While editing such an event the title, date and recurrence are
|
||||||
|
* locked (sync overwrites them); reminders, location and notes stay editable.
|
||||||
|
*/
|
||||||
|
val isManaged: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Whether to focus the title and raise the keyboard when this is a fresh
|
||||||
|
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||||
|
* on it for a new event with an empty title, never when editing/importing.
|
||||||
|
*/
|
||||||
|
val autofocusTitle: Boolean = true,
|
||||||
/**
|
/**
|
||||||
* True while an edit changed the recurrence rule — the save-scope dialog
|
* True while an edit changed the recurrence rule — the save-scope dialog
|
||||||
* then drops "only this event" (an exception row can't carry a rule).
|
* then drops "only this event" (an exception row can't carry a rule).
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
|
|||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.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
|
||||||
@@ -107,10 +108,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private data class ReminderDefaults(
|
private data class ReminderDefaults(
|
||||||
val timed: Int?,
|
val timed: List<Int>,
|
||||||
val allDay: Int?,
|
val allDay: List<Int>,
|
||||||
val timedOverrides: Map<Long, Int?>,
|
val timedOverrides: Map<Long, List<Int>>,
|
||||||
val allDayOverrides: Map<Long, Int?>,
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
@@ -118,12 +119,27 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val lastUsed: Long?,
|
val lastUsed: Long?,
|
||||||
val defaultFields: Set<EventFormField>,
|
val defaultFields: Set<EventFormField>,
|
||||||
val allowColorOnUnsupported: Boolean,
|
val allowColorOnUnsupported: Boolean,
|
||||||
|
val autofocusTitle: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Writable calendars — the only valid event targets. */
|
/** Every calendar the provider exposes; the source for both lists below. */
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
private val allCalendars: Flow<List<CalendarSource>> =
|
||||||
.map { calendars -> calendars.filter { it.canModifyContents } }
|
repository.calendars().catch { emit(emptyList()) }
|
||||||
.catch { emit(emptyList()) }
|
|
||||||
|
/**
|
||||||
|
* Writable calendars — the only valid event targets. Disabled calendars are
|
||||||
|
* excluded, so you can't create into a calendar you've removed from the app;
|
||||||
|
* a last-used preselect landing on a now-disabled calendar falls back to the
|
||||||
|
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
||||||
|
* Managed special-dates calendars are excluded too: their events are owned by
|
||||||
|
* the contact sync, which would delete any user event created there.
|
||||||
|
*/
|
||||||
|
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
||||||
|
allCalendars,
|
||||||
|
prefs.disabledCalendarIds,
|
||||||
|
) { calendars, disabled ->
|
||||||
|
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
|
||||||
|
}
|
||||||
|
|
||||||
/** 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(
|
||||||
@@ -151,24 +167,38 @@ class EventEditViewModel @Inject constructor(
|
|||||||
prefs.lastUsedCalendarId,
|
prefs.lastUsedCalendarId,
|
||||||
settingsPrefs.defaultFormFields,
|
settingsPrefs.defaultFormFields,
|
||||||
settingsPrefs.allowColorOnUnsupportedCalendars,
|
settingsPrefs.allowColorOnUnsupportedCalendars,
|
||||||
|
settingsPrefs.autofocusEventTitle,
|
||||||
::ExternalInputs,
|
::ExternalInputs,
|
||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
colorPalette,
|
colorPalette,
|
||||||
) { local, external, palette ->
|
allCalendars,
|
||||||
|
) { local, external, palette, allCalendars ->
|
||||||
val form = local.form ?: return@combine null
|
val form = local.form ?: return@combine null
|
||||||
val resolvedId = form.calendarId
|
val resolvedId = form.calendarId
|
||||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||||
?: external.writable.firstOrNull()?.id
|
?: external.writable.firstOrNull()?.id
|
||||||
val resolved = form.copy(calendarId = resolvedId)
|
val resolved = form.copy(calendarId = resolvedId)
|
||||||
|
val resolvedCalendar = allCalendars.firstOrNull { it.id == resolvedId }
|
||||||
|
// Editing an event in a managed calendar locks its synced fields. Keyed
|
||||||
|
// off the calendar's durable marker, not a stored id, so it holds after a
|
||||||
|
// backup restore too.
|
||||||
|
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
|
||||||
|
// The picker offers writable calendars only; when editing a managed event
|
||||||
|
// its own (excluded) calendar is added back so the row still names it.
|
||||||
|
val pickerCalendars =
|
||||||
|
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||||
|
else external.writable
|
||||||
val visibleFields = external.defaultFields + local.revealed
|
val visibleFields = external.defaultFields + local.revealed
|
||||||
EventEditUiState(
|
EventEditUiState(
|
||||||
form = resolved,
|
form = resolved,
|
||||||
calendars = external.writable,
|
calendars = pickerCalendars,
|
||||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||||
saveState = local.saveState,
|
saveState = local.saveState,
|
||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||||
isEditing = local.editTarget != null,
|
isEditing = local.editTarget != null,
|
||||||
|
isManaged = isManaged,
|
||||||
|
autofocusTitle = external.autofocusTitle,
|
||||||
// A modified-occurrence exception can't carry its own rule, so
|
// A modified-occurrence exception can't carry its own rule, so
|
||||||
// the scope dialog drops "only this event" after a rule change.
|
// the scope dialog drops "only this event" after a rule change.
|
||||||
recurrenceChanged = local.editTarget != null &&
|
recurrenceChanged = local.editTarget != null &&
|
||||||
@@ -248,7 +278,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// Re-check after suspending: bail if the form closed or the user edited.
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
val form = _form.value ?: return@launch
|
val form = _form.value ?: return@launch
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
val default = resolveDefaultReminder(
|
val reminders = resolveDefaultReminder(
|
||||||
timedGlobal = defaults.timed,
|
timedGlobal = defaults.timed,
|
||||||
allDayGlobal = defaults.allDay,
|
allDayGlobal = defaults.allDay,
|
||||||
timedOverrides = defaults.timedOverrides,
|
timedOverrides = defaults.timedOverrides,
|
||||||
@@ -256,7 +286,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
calendarId = targetId,
|
calendarId = targetId,
|
||||||
isAllDay = form.isAllDay,
|
isAllDay = form.isAllDay,
|
||||||
)
|
)
|
||||||
val reminders = listOfNotNull(default)
|
|
||||||
_form.value = form.copy(reminders = reminders)
|
_form.value = form.copy(reminders = reminders)
|
||||||
// Surface the section so an auto-applied default is visible and
|
// Surface the section so an auto-applied default is visible and
|
||||||
// removable, even when Reminders isn't a default-shown field.
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
@@ -307,7 +336,11 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_revealed.value = _revealed.value + field
|
_revealed.value = _revealed.value + field
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTitle(value: String) = update { it.copy(title = value) }
|
// The title field wraps (multi-line) so long titles stay visible (#33), but
|
||||||
|
// a title is one logical line: drop any newline the IME's Enter key or a
|
||||||
|
// paste would introduce, so it never reaches the provider's TITLE column.
|
||||||
|
fun setTitle(value: String) =
|
||||||
|
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
|
||||||
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) {
|
fun setAllDay(value: Boolean) {
|
||||||
@@ -353,6 +386,33 @@ class EventEditViewModel @Inject constructor(
|
|||||||
update { it.copy(reminders = it.reminders - minutes) }
|
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. */
|
||||||
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
||||||
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
||||||
@@ -379,7 +439,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_saveState.value = SaveUiState.Saved
|
_saveState.value = SaveUiState.Saved
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (target != null && target.original.rrule != null) {
|
// Managed events are a yearly series whose editable fields (reminders,
|
||||||
|
// notes, location) live on the series row — never offer the scope dialog,
|
||||||
|
// which would split the series into an exception the sync then reverts.
|
||||||
|
if (target != null && target.original.rrule != null && !current.isManaged) {
|
||||||
_saveState.value = SaveUiState.AwaitingScope
|
_saveState.value = SaveUiState.AwaitingScope
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
||||||
|
|||||||
@@ -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)) }
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.imports
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -19,6 +30,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -31,6 +43,12 @@ 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.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -39,13 +57,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles an opened/received `.ics` file. A single event is handed straight to
|
* Handles an opened/received `.ics` file. A single event is handed straight to
|
||||||
* the prefilled create form via [onOpenSingle]; several events show a target-
|
* the prefilled create form via [onOpenSingle]; several events show a target-
|
||||||
* calendar picker and import in bulk (dedup by UID), then a result summary.
|
* calendar picker and import in bulk (dedup by UID), then a result summary.
|
||||||
* Empty/failed files show a short message and close.
|
* Empty/failed files show a short message and close. [forceMany] keeps a
|
||||||
|
* single-event file on the bulk path — used by the in-app restore, whose intent
|
||||||
|
* is "restore a backup" rather than "add this one event".
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -53,27 +74,72 @@ fun ImportScreen(
|
|||||||
uri: Uri,
|
uri: Uri,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenSingle: (EventForm) -> Unit,
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
viewModel: ImportViewModel = hiltViewModel(),
|
forceMany: Boolean = false,
|
||||||
|
// Key the VM by the file uri. This screen has no nav backstack, so an
|
||||||
|
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
||||||
|
// across imports — its one-shot `load` guard would then show the *previous*
|
||||||
|
// file's parsed state on the next import (a second restore, export→restore,
|
||||||
|
// etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading
|
||||||
|
// state), while the same uri (rotation) reuses it and holds the result.
|
||||||
|
viewModel: ImportViewModel = hiltViewModel(key = uri.toString()),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(uri) { viewModel.load(uri) }
|
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
BackHandler(onBack = onClose)
|
|
||||||
|
|
||||||
// A single event isn't shown here — it opens the create form for review.
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
LaunchedEffect(state) {
|
LaunchedEffect(state) {
|
||||||
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hoisted target calendar so the always-visible top-bar Import action can
|
||||||
|
// read it without the user scrolling to a bottom button. Defaults to the
|
||||||
|
// first *local* calendar — the first row the picker shows ("Your calendars"
|
||||||
|
// group leads) — so the pre-selection lines up with the top of the list;
|
||||||
|
// falls back to the first calendar if there are no local ones. Re-defaults
|
||||||
|
// when the "many" list first arrives (keyed on it), then holds the pick.
|
||||||
|
val many = state as? ImportUiState.Many
|
||||||
|
val defaultTarget = many?.calendars?.let { cals ->
|
||||||
|
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
|
||||||
|
}
|
||||||
|
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.predictiveBack(onBack = onClose)
|
||||||
|
.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.import_title)) },
|
title = {
|
||||||
|
Text(
|
||||||
|
if (many != null) {
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_title_count,
|
||||||
|
many.events.size,
|
||||||
|
many.events.size,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.import_title)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onClose) {
|
IconButton(onClick = onClose) {
|
||||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
// Only meaningful in the multi-event picker with a writable
|
||||||
|
// target; every other state has nothing to confirm here.
|
||||||
|
if (many != null && many.calendars.isNotEmpty()) {
|
||||||
|
Button(
|
||||||
|
onClick = { selected?.let(viewModel::import) },
|
||||||
|
enabled = selected != null,
|
||||||
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.import_button))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
@@ -89,7 +155,7 @@ fun ImportScreen(
|
|||||||
|
|
||||||
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||||
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||||
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
|
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
|
||||||
is ImportUiState.Done -> DoneContent(s, onClose)
|
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,84 +163,160 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
|
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
|
||||||
// No writable calendar to import into — tell the user honestly.
|
// No writable calendar to import into — tell the user honestly.
|
||||||
if (state.calendars.isEmpty()) {
|
if (state.calendars.isEmpty()) {
|
||||||
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(top = 8.dp, bottom = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
CalendarPickerGroups(
|
||||||
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
|
calendars = state.calendars,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
selectedId = selected,
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
onSelect = onSelect,
|
||||||
)
|
)
|
||||||
Text(
|
if (state.warnings.isNotEmpty()) {
|
||||||
stringResource(R.string.import_target_header),
|
Column(
|
||||||
style = MaterialTheme.typography.labelLarge,
|
Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
color = MaterialTheme.colorScheme.primary,
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
)
|
) {
|
||||||
state.calendars.forEach { calendar ->
|
|
||||||
OptionCard(
|
|
||||||
label = calendar.displayName,
|
|
||||||
onClick = { selected = calendar.id },
|
|
||||||
selected = calendar.id == selected,
|
|
||||||
icon = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
state.warnings.forEach { WarningText(it) }
|
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
|
@Composable
|
||||||
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||||
|
// A little expressive pop on the success badge — springs in on first show.
|
||||||
|
val badgeScale = remember { Animatable(0.7f) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
badgeScale.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessLow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(24.dp),
|
Modifier.fillMaxSize().padding(24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(112.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = badgeScale.value
|
||||||
|
scaleY = badgeScale.value
|
||||||
|
}
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.import_done_title),
|
stringResource(R.string.import_done_title),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
modifier = Modifier.padding(top = 24.dp),
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
if (state.summary.skippedDuplicate > 0) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
pluralStringResource(
|
stringResource(R.string.import_done_dedup_note),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
ImportStatCard(
|
||||||
|
count = state.summary.imported,
|
||||||
|
label = stringResource(R.string.import_done_added_label),
|
||||||
|
contentDescription = pluralStringResource(
|
||||||
R.plurals.import_done_imported,
|
R.plurals.import_done_imported,
|
||||||
state.summary.imported,
|
state.summary.imported,
|
||||||
state.summary.imported,
|
state.summary.imported,
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
container = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
onContainer = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
)
|
)
|
||||||
if (state.summary.skippedDuplicate > 0) {
|
if (state.summary.skippedDuplicate > 0) {
|
||||||
Text(
|
ImportStatCard(
|
||||||
pluralStringResource(
|
count = state.summary.skippedDuplicate,
|
||||||
|
label = stringResource(R.string.import_done_skipped_label),
|
||||||
|
contentDescription = pluralStringResource(
|
||||||
R.plurals.import_done_skipped,
|
R.plurals.import_done_skipped,
|
||||||
state.summary.skippedDuplicate,
|
state.summary.skippedDuplicate,
|
||||||
state.summary.skippedDuplicate,
|
state.summary.skippedDuplicate,
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Button(
|
||||||
|
onClick = onClose,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
Text(stringResource(R.string.import_close))
|
Text(stringResource(R.string.import_close))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A big-number tonal tile summarising one import outcome (added / skipped). */
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.ImportStatCard(
|
||||||
|
count: Int,
|
||||||
|
label: String,
|
||||||
|
contentDescription: String,
|
||||||
|
container: Color,
|
||||||
|
onContainer: Color,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clearAndSetSemantics { this.contentDescription = contentDescription },
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = container,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(vertical = 20.dp, horizontal = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
count.toString(),
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
color = onContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = onContainer.copy(alpha = 0.85f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WarningText(warning: IcsParseWarning) {
|
private fun WarningText(warning: IcsParseWarning) {
|
||||||
val text = when (warning) {
|
val text = when (warning) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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.ics.IcsImporter
|
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.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
@@ -56,6 +57,7 @@ sealed interface ImportUiState {
|
|||||||
class ImportViewModel @Inject constructor(
|
class ImportViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val importer: IcsImporter,
|
private val importer: IcsImporter,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -64,8 +66,13 @@ class ImportViewModel @Inject constructor(
|
|||||||
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||||
private var started = false
|
private var started = false
|
||||||
|
|
||||||
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
|
/**
|
||||||
fun load(uri: Uri) {
|
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
|
||||||
|
* When [forceMany] is set (an in-app restore), a single-event file still goes
|
||||||
|
* through the bulk picker + summary rather than the prefilled create form —
|
||||||
|
* a restore is "bring back a backup", not "add this one event".
|
||||||
|
*/
|
||||||
|
fun load(uri: Uri, forceMany: Boolean = false) {
|
||||||
if (started) return
|
if (started) return
|
||||||
started = true
|
started = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -75,18 +82,26 @@ class ImportViewModel @Inject constructor(
|
|||||||
_state.value = when {
|
_state.value = when {
|
||||||
parsed == null -> ImportUiState.Failed
|
parsed == null -> ImportUiState.Failed
|
||||||
parsed.events.isEmpty() -> ImportUiState.Empty
|
parsed.events.isEmpty() -> ImportUiState.Empty
|
||||||
parsed.events.size == 1 -> ImportUiState.Single(
|
parsed.events.size == 1 && !forceMany -> ImportUiState.Single(
|
||||||
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
)
|
)
|
||||||
else -> ImportUiState.Many(
|
else -> {
|
||||||
|
// A disabled calendar is removed from the app, so it can't be
|
||||||
|
// an import target — exclude it alongside the read-only ones.
|
||||||
|
// Managed special-dates calendars are contact-derived and
|
||||||
|
// editor-locked, so they're not a valid destination either.
|
||||||
|
val disabled = prefs.disabledCalendarIds.first()
|
||||||
|
ImportUiState.Many(
|
||||||
events = parsed.events,
|
events = parsed.events,
|
||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
calendars = repository.calendars().first().filter { it.canModifyContents },
|
calendars = repository.calendars().first()
|
||||||
|
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
|
/** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
|
||||||
fun import(targetCalendarId: Long) {
|
fun import(targetCalendarId: Long) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -35,6 +36,8 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@@ -43,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -59,21 +63,30 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
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
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.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.floret.identity.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
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
@@ -86,13 +99,25 @@ 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,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||||
|
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
|
||||||
|
// The instant before which an event counts as completed, or null when dimming
|
||||||
|
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||||
|
// screen while the setting is off (it stays null regardless of the tick).
|
||||||
|
val nowState = rememberCurrentMinute()
|
||||||
|
val dimCutoff by remember(dimCompleted) {
|
||||||
|
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||||
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -138,6 +163,7 @@ fun MonthScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = LocalDate(month.year, month.month, 1),
|
currentDate = LocalDate(month.year, month.month, 1),
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -159,8 +185,9 @@ fun MonthScreen(
|
|||||||
MonthTopBar(
|
MonthTopBar(
|
||||||
month = month,
|
month = month,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -187,10 +214,12 @@ fun MonthScreen(
|
|||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
WeekdayHeader(weekStart = weekStart)
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||||
|
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||||
MonthContent(
|
MonthContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
@@ -199,12 +228,14 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthContent(
|
private fun MonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
|
showWeekNumbers: Boolean,
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
@@ -214,6 +245,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 +273,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) {
|
||||||
@@ -248,6 +281,7 @@ private fun MonthContent(
|
|||||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is MonthUiState.Success -> MonthGrid(
|
is MonthUiState.Success -> MonthGrid(
|
||||||
state = s,
|
state = s,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -261,6 +295,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 +314,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,
|
||||||
@@ -290,7 +331,7 @@ private fun MonthTopBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekdayHeader(weekStart: DayOfWeek) {
|
private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val days = remember(weekStart, locale) {
|
val days = remember(weekStart, locale) {
|
||||||
(0 until 7).map { offset ->
|
(0 until 7).map { offset ->
|
||||||
@@ -302,6 +343,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
|
// Reserve the gutter so the weekday labels stay over their day columns.
|
||||||
|
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
||||||
days.forEach { dow ->
|
days.forEach { dow ->
|
||||||
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
||||||
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
||||||
@@ -319,6 +362,9 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
|
|||||||
|
|
||||||
private val EVENT_ROW_HEIGHT = 20.dp
|
private val EVENT_ROW_HEIGHT = 20.dp
|
||||||
private val DAY_NUMBER_HEIGHT = 22.dp
|
private val DAY_NUMBER_HEIGHT = 22.dp
|
||||||
|
/** Width of the optional left calendar-week gutter (#25); narrow, since it only
|
||||||
|
* seats a one- or two-digit week number in a full-height tonal pill. */
|
||||||
|
private val WEEK_NUMBER_GUTTER = 40.dp
|
||||||
private val DAY_NUMBER_GAP = 4.dp
|
private val DAY_NUMBER_GAP = 4.dp
|
||||||
private val CELL_TOP_PADDING = 6.dp
|
private val CELL_TOP_PADDING = 6.dp
|
||||||
private val CELL_GAP = 2.dp
|
private val CELL_GAP = 2.dp
|
||||||
@@ -328,6 +374,7 @@ private const val MAX_EVENT_ROWS = 3
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthGrid(
|
private fun MonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -341,6 +388,7 @@ private fun MonthGrid(
|
|||||||
week = week,
|
week = week,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
month = state.month,
|
month = state.month,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -362,6 +410,7 @@ private fun MonthWeekRow(
|
|||||||
week: MonthWeek,
|
week: MonthWeek,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
month: YearMonth,
|
month: YearMonth,
|
||||||
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -369,7 +418,23 @@ private fun MonthWeekRow(
|
|||||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||||
|
|
||||||
BoxWithConstraints(modifier) {
|
Row(modifier) {
|
||||||
|
// Optional calendar-week gutter, sized so the seven day columns below
|
||||||
|
// divide the remaining width — the absolute bar offsets stay correct
|
||||||
|
// because they're measured inside the grid box, not the whole row.
|
||||||
|
if (showWeekNumbers) {
|
||||||
|
WeekNumberGutter(
|
||||||
|
weekStart = week.days.first(),
|
||||||
|
modifier = Modifier
|
||||||
|
.width(WEEK_NUMBER_GUTTER)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BoxWithConstraints(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
) {
|
||||||
val colW = maxWidth / 7
|
val colW = maxWidth / 7
|
||||||
|
|
||||||
// Per-day background pills — same surfaceContainer rounded surface the
|
// Per-day background pills — same surfaceContainer rounded surface the
|
||||||
@@ -496,6 +561,34 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||||
|
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
||||||
|
* Week view's badge), with the ISO week number centred like a day number. The
|
||||||
|
* week is computed on the row's first day — the same basis as the Week view — so
|
||||||
|
* the two agree.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
|
||||||
|
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||||
|
val label = stringResource(R.string.week_number_label)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
|
||||||
|
.semantics { contentDescription = "$label $weekNumber" },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = weekNumber.toString(),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -544,6 +637,8 @@ private fun MonthBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
val shape = RoundedCornerShape(
|
val shape = RoundedCornerShape(
|
||||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
@@ -551,7 +646,7 @@ private fun MonthBar(
|
|||||||
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.background(pastelize(event.color, dark), shape)
|
.background(pastelize(event.color, dark), shape)
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user