Compare commits
90 Commits
v0.2.0
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f735ffce7 | ||
| fdbc236ab4 | |||
| ec2e2eb59d | |||
| 9ff6027e50 | |||
| fcee1d1736 | |||
|
|
d7131087cb | ||
| dfb0a694de | |||
| 79970b5c65 | |||
|
|
5b31067e43 | ||
|
|
c69ff049a0 | ||
|
|
81220d53d1 | ||
| b3091a5aa3 | |||
|
|
b1bde2fab5 | ||
| abc381ca24 | |||
|
|
1fbd91fe82 | ||
| c2087de639 | |||
| faee90f8b1 | |||
| 90140112bb | |||
| 1d4fe5b301 | |||
| 5abcbfc956 | |||
| 1ed192f150 | |||
| 76f9ae6780 | |||
| 2e915da588 | |||
| 829a27da82 | |||
| fd8363e356 | |||
| 96a2995df4 | |||
| 11b20faf82 | |||
| 8c3cbcf928 | |||
| 13cb27b2ab | |||
| 98ed339346 | |||
| c5041d3f29 | |||
| f978c3727c | |||
| 3d54a896ce | |||
|
|
c47653c9cb | ||
| e639e250b7 | |||
|
|
58a50512bf | ||
|
|
1add1fcadb | ||
|
|
217d5d7afd | ||
| 47cf99af32 | |||
| 26628dc0bb | |||
| d2e3832ef2 | |||
| 93857135b3 | |||
| 36beb2d0ad | |||
| bc70ed3a9f | |||
| 3f166ef5f0 | |||
| 41bd49826a | |||
| 05c75bafa7 | |||
| cfa25b9730 | |||
| 9d7fc64b0b | |||
| 4aa65edb45 | |||
| a9843e25b7 | |||
| 5f4711dabe | |||
| 6d3fbc05c1 | |||
| 721b411579 | |||
| 046b8f7e9e | |||
| 06cc9b1c8b | |||
| 2d366a7be3 | |||
| 52aeebcb53 | |||
| 8f6b85008c | |||
| eb1e530ce7 | |||
| 7516972e9f | |||
| 7f58f81fe1 | |||
| 976d496d21 | |||
| 245f1db536 | |||
| 623e533547 | |||
| 2e50356f81 | |||
| e6f503c02a | |||
| c53511196d | |||
| a8595e26b4 | |||
| 411e27659f | |||
| b266653e4e | |||
| 2f5012dc34 | |||
| ccc07e86c7 | |||
| 211450bc46 | |||
| 9d134be621 | |||
| 37e3c4e659 | |||
| 78632865f9 | |||
| e7a62e71df | |||
| 4c05c86e95 | |||
| 37662e83bb | |||
| 63c6191676 | |||
| 4994f5ec2c | |||
| e4c8dbf2c1 | |||
| 069ae38b2c | |||
| a5ddf16537 | |||
| 18e03e27b6 | |||
| 152226c1b2 | |||
| ec7b696eb9 | |||
| 76a0139fda | |||
| d84ac60757 |
23
.forgejo/ISSUE_TEMPLATE/bug_report.md
Normal file
23
.forgejo/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
|
||||||
|
- Agendula version: <!-- Settings → bottom of the screen -->
|
||||||
|
- Android version:
|
||||||
|
- Device:
|
||||||
24
.forgejo/ISSUE_TEMPLATE/config.yml
Normal file
24
.forgejo/ISSUE_TEMPLATE/config.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Kept enabled so anything that doesn't fit the four templates still has a way
|
||||||
|
# in.
|
||||||
|
blank_issues_enabled: true
|
||||||
|
|
||||||
|
contact_links:
|
||||||
|
- name: Translate Agendula
|
||||||
|
url: https://weblate.dev.jeanlucmakiola.de/engage/agendula/
|
||||||
|
about: >-
|
||||||
|
Translations are managed on Weblate, not here — it owns every values-*
|
||||||
|
file, so a hand-edited translation gets overwritten on the next sync.
|
||||||
|
No coding needed: pick or request a language and translate in the browser.
|
||||||
|
|
||||||
|
- name: Contributing guide
|
||||||
|
url: https://codeberg.org/jlmakiola/agendula/src/branch/main/CONTRIBUTING.md
|
||||||
|
about: >-
|
||||||
|
Before opening a pull request: how to build (there's a submodule), where
|
||||||
|
code goes, and the one architectural rule a change is reviewed against.
|
||||||
|
|
||||||
|
- name: Sync sources and scope
|
||||||
|
url: https://codeberg.org/jlmakiola/agendula/src/branch/main/README.md
|
||||||
|
about: >-
|
||||||
|
Agendula is a front-end over the OpenTasks provider, so it works with
|
||||||
|
DAVx5, SmoothSync, DecSync and friends. Google Tasks and Microsoft To Do
|
||||||
|
are out of scope by design — check here before requesting a backend.
|
||||||
27
.forgejo/ISSUE_TEMPLATE/crash_report.md
Normal file
27
.forgejo/ISSUE_TEMPLATE/crash_report.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: Crash report
|
||||||
|
about: Report a crash. Agendula 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 Agendula!
|
||||||
|
|
||||||
|
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
.forgejo/ISSUE_TEMPLATE/feature_request.md
Normal file
16
.forgejo/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 Agendula to do?
|
||||||
|
|
||||||
|
|
||||||
|
### Why — what problem does it solve?
|
||||||
|
|
||||||
|
|
||||||
|
### Anything else
|
||||||
|
<!-- mockups, examples from other apps, alternatives you considered -->
|
||||||
19
.forgejo/ISSUE_TEMPLATE/question.md
Normal file
19
.forgejo/ISSUE_TEMPLATE/question.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: Question
|
||||||
|
about: Ask how something works or get help using Agendula
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- question
|
||||||
|
---
|
||||||
|
|
||||||
|
### Your question
|
||||||
|
|
||||||
|
|
||||||
|
### What you've tried
|
||||||
|
<!-- so far, if anything -->
|
||||||
|
|
||||||
|
|
||||||
|
### Context
|
||||||
|
- Agendula version: <!-- Settings → bottom of the screen -->
|
||||||
|
- Android version:
|
||||||
|
- Device:
|
||||||
42
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
42
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!--
|
||||||
|
Thanks for contributing to Agendula!
|
||||||
|
|
||||||
|
Please skim CONTRIBUTING.md if you haven't:
|
||||||
|
https://codeberg.org/jlmakiola/agendula/src/branch/main/CONTRIBUTING.md
|
||||||
|
|
||||||
|
Two things it's easy to get wrong:
|
||||||
|
• The one architectural rule — provider column names, `TaskContract`,
|
||||||
|
`ContentResolver` and the authority string never leak above `data/tasks/`.
|
||||||
|
• Don't bump `versionName` / `versionCode`. That bump reaching `main` is what
|
||||||
|
cuts a release, so it belongs only in a release PR.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### What this changes
|
||||||
|
|
||||||
|
|
||||||
|
### Why
|
||||||
|
|
||||||
|
<!-- Closes #123 — link the issue this implements or fixes. -->
|
||||||
|
|
||||||
|
|
||||||
|
### How it was tested
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Which of these ran green, and anything you exercised by hand. On-device notes
|
||||||
|
are especially useful for UI changes, and for anything touching the provider
|
||||||
|
read/write paths (OpenTasks / tasks.org installed).
|
||||||
|
|
||||||
|
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
|
||||||
|
python3 scripts/check_translations.py
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally
|
||||||
|
- [ ] New domain logic comes with JVM unit tests under `app/src/test/`
|
||||||
|
- [ ] Provider details stay inside `data/tasks/`
|
||||||
|
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
||||||
|
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
|
||||||
|
- [ ] No `versionName` / `versionCode` bump
|
||||||
|
- [ ] No planning or design documents committed
|
||||||
169
.forgejo/workflows/ci.yaml
Normal file
169
.forgejo/workflows/ci.yaml
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
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:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
# Cancel superseded runs for the same PR.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Single job named `ci` so the required "CI" status check is always reported,
|
||||||
|
# even for docs-only PRs: those just skip the Android build and the job still
|
||||||
|
# succeeds (fast green check) instead of being filtered out and leaving the
|
||||||
|
# required check pending forever.
|
||||||
|
ci:
|
||||||
|
runs-on: docker
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
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, store
|
||||||
|
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||||
|
# the SDK + Gradle work below but still report a green `ci`.
|
||||||
|
- name: Classify change scope
|
||||||
|
id: scope
|
||||||
|
env:
|
||||||
|
# Deliberately a skip-list, not a build-list: a path nobody thought
|
||||||
|
# about defaults to building. Only paths the Gradle build provably
|
||||||
|
# never reads belong here — note that the workflows themselves, the
|
||||||
|
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
|
||||||
|
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^design/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)'
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
BASE="${{ github.base_ref }}"
|
||||||
|
# Normally the bare branch name; tolerate a full ref, which would
|
||||||
|
# otherwise make the merge-base lookup fail and quietly degrade this
|
||||||
|
# guard into "always build".
|
||||||
|
BASE="${BASE#refs/heads/}"
|
||||||
|
if [ -z "$BASE" ]; then
|
||||||
|
echo "No base branch on this event — running the full build to be safe."
|
||||||
|
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# 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"
|
||||||
|
RELEVANT=$(echo "$CHANGED" | grep -vE "$SKIP_RE" || true)
|
||||||
|
if [ -n "$RELEVANT" ]; then
|
||||||
|
# Naming them makes "why did my docs PR build for four minutes?"
|
||||||
|
# answerable from the log alone.
|
||||||
|
echo "Build-relevant changes:"; echo "$RELEVANT"
|
||||||
|
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "Docs/metadata-only change — skipping the Android build."
|
||||||
|
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
|
||||||
|
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
|
||||||
|
# setup-java and cache all exist there, but android-actions/setup-android
|
||||||
|
# does not, and the job dies with "repository not found". Gitea's instance
|
||||||
|
# defaults to GitHub, which is why this never surfaced before the split.
|
||||||
|
- name: Setup Android SDK
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
uses: https://github.com/android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
# Default ("tools platform-tools") drags in the Android Emulator
|
||||||
|
# (~300 MB) which the build never uses.
|
||||||
|
packages: ''
|
||||||
|
|
||||||
|
- name: Setup Android SDK cache
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /opt/android-sdk
|
||||||
|
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||||
|
|
||||||
|
- name: Install Android SDK packages
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
run: |
|
||||||
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
|
sdkmanager \
|
||||||
|
"platform-tools" \
|
||||||
|
"platforms;android-37.0" \
|
||||||
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
|
- name: Setup Gradle cache
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
|
- name: Grant execute permission for gradlew
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
|
# No --no-daemon: the daemon lives only as long as this job container
|
||||||
|
# and lets the following steps skip JVM startup + reconfiguration.
|
||||||
|
- name: Lint (debug variant only)
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
run: ./gradlew lintDebug
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
|
- name: Assemble debug APK
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
|
- name: Trivy filesystem scan
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SUDO=""
|
||||||
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y wget apt-transport-https gnupg lsb-release
|
||||||
|
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | $SUDO tee /usr/share/keyrings/trivy.gpg > /dev/null
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | $SUDO tee /etc/apt/sources.list.d/trivy.list
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y trivy
|
||||||
|
fi
|
||||||
|
trivy filesystem --severity HIGH,CRITICAL --exit-code 0 .
|
||||||
|
continue-on-error: true
|
||||||
39
.forgejo/workflows/translations.yaml
Normal file
39
.forgejo/workflows/translations.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Translations
|
||||||
|
|
||||||
|
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||||
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
|
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||||
|
#
|
||||||
|
# Runs on every PR (no path filter) so the required "Translations / check"
|
||||||
|
# status is always reported — like the `ci` job. A path-filtered workflow is
|
||||||
|
# skipped on unrelated PRs and never posts its status, which leaves that
|
||||||
|
# required check pending forever and blocks the merge of any code-only PR into a
|
||||||
|
# release/* branch. The check itself is cheap and simply passes when the
|
||||||
|
# committed translations are consistent, so always running it costs nothing.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: translations-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure python3
|
||||||
|
run: |
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update && apt-get install -y python3
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
apk add --no-cache python3
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
python3 --version
|
||||||
|
|
||||||
|
- name: Check translation parity
|
||||||
|
run: python3 scripts/check_translations.py
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
tags-ignore:
|
|
||||||
- '**'
|
|
||||||
|
|
||||||
# Cancel superseded runs on the same branch.
|
|
||||||
concurrency:
|
|
||||||
group: ci-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
runs-on: docker
|
|
||||||
env:
|
|
||||||
ANDROID_HOME: /opt/android-sdk
|
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Java
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: 'zulu'
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: Setup Android SDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
with:
|
|
||||||
# Default ("tools platform-tools") drags in the Android Emulator
|
|
||||||
# (~300 MB) which the build never uses.
|
|
||||||
packages: ''
|
|
||||||
|
|
||||||
- name: Setup Android SDK cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: /opt/android-sdk
|
|
||||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
|
||||||
run: |
|
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
|
||||||
sdkmanager \
|
|
||||||
"platform-tools" \
|
|
||||||
"platforms;android-37.0" \
|
|
||||||
"build-tools;36.0.0"
|
|
||||||
|
|
||||||
- name: Setup Gradle cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-gradle-
|
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
|
||||||
run: chmod +x ./gradlew
|
|
||||||
|
|
||||||
# No --no-daemon: the daemon lives only as long as this job container
|
|
||||||
# and lets the following steps skip JVM startup + reconfiguration.
|
|
||||||
- name: Lint (debug variant only)
|
|
||||||
run: ./gradlew lintDebug
|
|
||||||
|
|
||||||
- name: Unit tests
|
|
||||||
run: ./gradlew testDebugUnitTest
|
|
||||||
|
|
||||||
- name: Assemble debug APK
|
|
||||||
run: ./gradlew assembleDebug
|
|
||||||
|
|
||||||
- name: Trivy filesystem scan
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
SUDO=""
|
|
||||||
if command -v sudo >/dev/null 2>&1; then
|
|
||||||
SUDO="sudo"
|
|
||||||
fi
|
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
|
||||||
$SUDO apt-get update
|
|
||||||
$SUDO apt-get install -y wget apt-transport-https gnupg lsb-release
|
|
||||||
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | $SUDO tee /usr/share/keyrings/trivy.gpg > /dev/null
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | $SUDO tee /etc/apt/sources.list.d/trivy.list
|
|
||||||
$SUDO apt-get update
|
|
||||||
$SUDO apt-get install -y trivy
|
|
||||||
fi
|
|
||||||
trivy filesystem --severity HIGH,CRITICAL --exit-code 0 .
|
|
||||||
continue-on-error: true
|
|
||||||
@@ -1,78 +1,134 @@
|
|||||||
name: Release — F-Droid repo + Gitea release
|
name: Release — F-Droid repo + Gitea/Codeberg 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, creates the vX.Y.Z tag + Gitea release, and publishes
|
||||||
|
# the release on Codeberg with the signed APK + a SHA-256 checksum as a
|
||||||
|
# direct-download channel — the tag is an output of the pipeline, not its
|
||||||
|
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||||
|
# nothing.
|
||||||
|
#
|
||||||
|
# This file lives in .gitea/workflows on purpose: Codeberg is canonical for git,
|
||||||
|
# issues, PRs and releases, but every secret (app key, F-Droid repo key, Hetzner
|
||||||
|
# credentials) lives on the self-hosted Gitea instance, and this is the only
|
||||||
|
# directory Codeberg cannot see. Contributor-triggerable work lives in
|
||||||
|
# .forgejo/workflows and references no secret. See docs/RELEASING.md.
|
||||||
|
#
|
||||||
|
# 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:
|
||||||
|
# Gitea only. The workflow directory split already keeps this file invisible
|
||||||
|
# to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
|
||||||
|
# exists — but that only holds while .forgejo/ is non-empty. Move the last
|
||||||
|
# file out of it and Codeberg would fall back to .gitea/workflows and start
|
||||||
|
# running the release pipeline on the contributor-facing runner, with no
|
||||||
|
# secrets. repository_owner differs between the two forges regardless of
|
||||||
|
# URL, proxy or instance rename, so this closes it permanently.
|
||||||
|
if: github.repository_owner == 'makiolaj'
|
||||||
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 }}
|
||||||
|
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||||
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: ''
|
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
||||||
|
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
||||||
- name: Setup Android SDK cache
|
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
||||||
uses: actions/cache@v4
|
# source does not have. A tag minted here on Gitea is therefore wiped
|
||||||
with:
|
# by the next sync (Codeberg does not have it yet) and only reappears
|
||||||
path: /opt/android-sdk
|
# once the tag push at the end of this workflow propagates back.
|
||||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
# Asking Gitea inside that window would report "no tag" for a release
|
||||||
|
# that already shipped, and cut it a second time.
|
||||||
- name: Install Android SDK packages
|
# Public repo, so this read needs no token.
|
||||||
|
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||||
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"
|
||||||
|
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
|
||||||
|
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
|
||||||
|
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
|
||||||
|
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
|
||||||
|
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.
|
||||||
|
#
|
||||||
|
# Anything other than a clean 200/404 is treated as fatal rather than
|
||||||
|
# as "no tag". A Codeberg outage or a network blip would otherwise
|
||||||
|
# read as absent and re-cut a release that has already shipped —
|
||||||
|
# republishing to F-Droid. Failing here is recoverable; a duplicate
|
||||||
|
# release is not.
|
||||||
|
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
||||||
|
case "$STATUS" in
|
||||||
|
200)
|
||||||
|
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
||||||
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
404)
|
||||||
|
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
||||||
|
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
||||||
|
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
- 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 }}
|
||||||
|
PRERELEASE: ${{ needs.detect.outputs.prerelease }}
|
||||||
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 +177,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 +211,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 +250,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 +263,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/agendula_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/floret_${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.agendula.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.agendula
|
||||||
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.floret/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 +310,47 @@ 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" "$PRERELEASE" <<'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,
|
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||||
|
"prerelease": sys.argv[3] == "true",
|
||||||
}))
|
}))
|
||||||
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 +364,140 @@ 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"
|
||||||
|
|
||||||
|
# Publish the release on Codeberg, which is canonical for tags and
|
||||||
|
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
|
||||||
|
# to Gitea, but releases aren't git objects and don't sync in either
|
||||||
|
# direction — so this step pushes the tag straight to Codeberg and creates
|
||||||
|
# the release there over the API, attaching the signed APK plus a SHA-256
|
||||||
|
# checksum as the direct-download channel for users who don't want
|
||||||
|
# F-Droid. The APK is identical to the F-Droid one (same app key), so this
|
||||||
|
# adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips
|
||||||
|
# cleanly if unset.
|
||||||
|
- name: Publish release to Codeberg
|
||||||
|
if: env.IS_RELEASE == 'true'
|
||||||
|
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
||||||
|
# 0.3.0, 0.3.1 and 0.3.2 while never once publishing, which is how a
|
||||||
|
# crash-fix release reached F-Droid but not the Codeberg/Obtainium
|
||||||
|
# users who needed it. A broken mirror must fail the release loudly.
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||||
|
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
if [ -z "${TOKEN:-}" ]; then
|
||||||
|
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
TAG="v$VERSION"
|
||||||
|
APK="app/build/outputs/apk/release/app-release.apk"
|
||||||
|
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
|
||||||
|
ASSET_APK="agendula_v${VERSION}.apk"
|
||||||
|
ASSET_SUM="${ASSET_APK}.sha256"
|
||||||
|
cp "$APK" "/tmp/$ASSET_APK"
|
||||||
|
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
||||||
|
|
||||||
|
# Release notes: reuse the section extracted for the Gitea release,
|
||||||
|
# fall back to the CHANGELOG entry if that step's file is gone.
|
||||||
|
if [ ! -s release-notes.md ]; then
|
||||||
|
awk -v ver="$VERSION" '
|
||||||
|
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||||
|
/^## \[/ { flag = 0 }
|
||||||
|
flag' CHANGELOG.md > release-notes.md
|
||||||
|
sed -i -e '/./,$!d' release-notes.md
|
||||||
|
fi
|
||||||
|
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
||||||
|
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the
|
||||||
|
# mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
|
||||||
|
# from Gitea (what 0.3.2 did) would wait forever. The tag this
|
||||||
|
# pipeline minted on Gitea is in fact *deleted* by the next mirror
|
||||||
|
# sync until Codeberg has it — so pushing it here is what makes it
|
||||||
|
# durable on both forges.
|
||||||
|
#
|
||||||
|
# Pushing the ref first and attaching with NO target_commitish is
|
||||||
|
# deliberate: a release POST carrying a target_commitish for a commit
|
||||||
|
# or tag Codeberg hasn't received yet is what produced the
|
||||||
|
# empty-bodied 500s. Attaching to a ref that already exists doesn't
|
||||||
|
# need the API to write one.
|
||||||
|
git tag -f "$TAG" "$SHA"
|
||||||
|
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
|
||||||
|
"refs/tags/$TAG"
|
||||||
|
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
|
||||||
|
import json, sys
|
||||||
|
tag, pre = sys.argv[1:3]
|
||||||
|
print(json.dumps({
|
||||||
|
"tag_name": tag,
|
||||||
|
"name": tag,
|
||||||
|
"body": open("release-notes.md").read(),
|
||||||
|
"draft": False,
|
||||||
|
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||||
|
"prerelease": pre == "true",
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
# Create (or update) the release. Codeberg 500s on a POST/GET against a
|
||||||
|
# tag it has only just received — the release request outruns the
|
||||||
|
# indexing of the ref we pushed a moment ago — so a single attempt can
|
||||||
|
# fail even though the very same call succeeds seconds later. Retry
|
||||||
|
# with backoff, and PATCH in place if a release already exists (re-run
|
||||||
|
# safe, so re-running never disturbs a published release). A 5xx body
|
||||||
|
# still exits curl 0, so the loop, not `set -e`, controls the flow.
|
||||||
|
ID=""
|
||||||
|
for attempt in 1 2 3 4 5 6; do
|
||||||
|
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
|
||||||
|
if [ -n "$EXIST" ]; then
|
||||||
|
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d @cb-payload.json "$API/releases/$EXIST"
|
||||||
|
ID="$EXIST"; break
|
||||||
|
fi
|
||||||
|
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d @cb-payload.json "$API/releases")
|
||||||
|
echo "release POST attempt $attempt HTTP $CODE"
|
||||||
|
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
||||||
|
[ -n "$ID" ] && break
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
|
||||||
|
|
||||||
|
# Attach APK + checksum, replacing any prior asset of the same name.
|
||||||
|
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
||||||
|
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||||
|
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
|
||||||
|
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||||
|
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@/tmp/$A" \
|
||||||
|
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
||||||
|
done
|
||||||
|
echo "Published $TAG to Codeberg."
|
||||||
|
|||||||
61
.gitea/workflows/renovate.yml
Normal file
61
.gitea/workflows/renovate.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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:
|
||||||
|
# Gitea only — same guard, and the same reason, as release.yaml's `detect`:
|
||||||
|
# this file is invisible to Codeberg only while .forgejo/ is non-empty, and
|
||||||
|
# a repo-write token must never run on the contributor-facing runner.
|
||||||
|
if: github.repository_owner == 'makiolaj'
|
||||||
|
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:
|
||||||
|
# Renovate targets Codeberg (canonical) while still RUNNING on the
|
||||||
|
# Gitea runner. Moving the job to Codeberg would put a repo-write
|
||||||
|
# token on the contributor-facing runner, which is exactly what the
|
||||||
|
# .forgejo/ vs .gitea/ split exists to prevent — so the token stays
|
||||||
|
# where the other secrets live and only the API calls cross over.
|
||||||
|
#
|
||||||
|
# Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the
|
||||||
|
# pinned image ships a distinct forgejo platform module.
|
||||||
|
RENOVATE_PLATFORM: forgejo
|
||||||
|
RENOVATE_ENDPOINT: https://codeberg.org/api/v1
|
||||||
|
# Codeberg bot-account token (Gitea secret). Needs repo read/write +
|
||||||
|
# PR scope on jlmakiola/agendula.
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
# Scope to this repo only — no org-wide autodiscovery.
|
||||||
|
RENOVATE_AUTODISCOVER: 'false'
|
||||||
|
RENOVATE_REPOSITORIES: '["jlmakiola/agendula"]'
|
||||||
|
# Commits/PRs authored as the bot, not a real maintainer. This address
|
||||||
|
# must be a verified email on the Codeberg bot account, otherwise the
|
||||||
|
# commits show up unattributed there.
|
||||||
|
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
|
||||||
|
# Read-only github.com PAT (no scopes needed). Nearly every dependency
|
||||||
|
# is *released* on GitHub, and without this, changelog/release-note
|
||||||
|
# lookups hit the 60/h anonymous rate limit and PRs arrive with an
|
||||||
|
# empty "Release Notes" section.
|
||||||
|
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
|
||||||
|
LOG_LEVEL: info
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -53,5 +53,19 @@ Thumbs.db
|
|||||||
# F-Droid local artifacts (the pipeline generates them in CI)
|
# F-Droid local artifacts (the pipeline generates them in CI)
|
||||||
/fdroid/
|
/fdroid/
|
||||||
|
|
||||||
|
# Release-pipeline scratch files. release.yaml writes these into the workspace
|
||||||
|
# while cutting a release; a self-hosted runner reuses that workspace, so they
|
||||||
|
# must never end up committed (release-notes.md did, through 0.3.2).
|
||||||
|
/release-notes.md
|
||||||
|
/payload.json
|
||||||
|
/existing.json
|
||||||
|
/response.json
|
||||||
|
/cb-payload.json
|
||||||
|
/cb-response.json
|
||||||
|
|
||||||
# KSP
|
# KSP
|
||||||
.ksp/
|
.ksp/
|
||||||
|
|
||||||
|
# Local agent notes: machine-specific build setup and on-device rules, not
|
||||||
|
# anything the project itself depends on.
|
||||||
|
/CLAUDE.md
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "floret-kit"]
|
||||||
|
path = floret-kit
|
||||||
|
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||||
69
CHANGELOG.md
69
CHANGELOG.md
@@ -1,11 +1,76 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project are documented here. The format follows
|
All notable changes to this project are documented here. The format follows
|
||||||
[Keep a Changelog](https://keepachangelog.com/); the latest released git tag is
|
[Keep a Changelog](https://keepachangelog.com/); the `versionName` committed in
|
||||||
the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
|
`app/build.gradle.kts` is the source of truth for a release (see
|
||||||
|
`docs/RELEASING.md`), and the `vX.Y.Z` tag is minted by the pipeline.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-08-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Agendula now speaks **German** and **Brazilian Portuguese**, the first
|
||||||
|
community translations. Pick one under **Settings → App language**, or in
|
||||||
|
Android's own per-app language settings; leave it on *System default* and
|
||||||
|
Agendula follows your phone. Anything not yet translated falls back to
|
||||||
|
English.
|
||||||
|
- Agendula can now be translated. Pick or request a language on Weblate and
|
||||||
|
translate in the browser — the link sits at the top of the language picker in
|
||||||
|
**Settings → App language**. Partial translations are fine; anything
|
||||||
|
untranslated falls back to English.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Agendula's home is now **Codeberg** (`jlmakiola/agendula`) — that's where the
|
||||||
|
source, issues, pull requests and releases live. The Source and License links
|
||||||
|
in Settings, the issue-reporting link and the F-Droid metadata all point there
|
||||||
|
now. The self-hosted Gitea instance stays as build infrastructure.
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-07-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Releases reach the Codeberg download channel again. 0.3.1 published to
|
||||||
|
F-Droid but never appeared on Codeberg, so if you install from there — or
|
||||||
|
through Obtainium — this is the release that finally carries 0.3.0's
|
||||||
|
launch-crash fix. The app itself is unchanged from 0.3.1.
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-07-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
|
||||||
|
release build stripped a constructor that the background-work scheduler needs
|
||||||
|
to open its database, and that happens before the app draws anything.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-07-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Reminders: Agendula now delivers your due reminders itself. A one-time setup
|
||||||
|
step explains this and asks for notification access, and a master switch in
|
||||||
|
Settings turns the whole thing off again.
|
||||||
|
- A Settings screen, from the gear on the overview: appearance and theme, which
|
||||||
|
fields the task form shows, your default list, and reminder defaults.
|
||||||
|
- The overview leads with Today — a progress ring showing how much of today
|
||||||
|
you've finished — followed by a live preview of what's coming up next.
|
||||||
|
- Search across every task, open or completed, from the top bar.
|
||||||
|
- A proper app icon.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- A tidier top bar: no app title, with search and settings pinned to the right.
|
||||||
|
|
||||||
|
## [0.2.2] - 2026-07-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Release automation now reliably mirrors each release to the Codeberg mirror
|
||||||
|
(signed APK + SHA-256 checksum). The 0.2.1 attempt failed when the release tag
|
||||||
|
had already been synced to Codeberg.
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-07-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Releases are now also published to the Codeberg mirror as a direct download:
|
||||||
|
each release carries the signed APK plus a SHA-256 checksum, for users who
|
||||||
|
don't use F-Droid.
|
||||||
|
|
||||||
## [0.2.0] - 2026-06-27
|
## [0.2.0] - 2026-06-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Contributing to Floret
|
# Contributing to Agendula
|
||||||
|
|
||||||
Thanks for your interest in Floret — a Material 3 Expressive task app that's a
|
Thanks for your interest in Agendula — a Material 3 Expressive task app that's a
|
||||||
pure front-end over the OpenTasks `TaskContract` provider, with no own database
|
pure front-end over the OpenTasks `TaskContract` provider, with no own database
|
||||||
or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
|
or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
|
||||||
(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
|
(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
|
||||||
@@ -30,11 +30,35 @@ would expose provider details to a ViewModel or the UI, it's in the wrong layer.
|
|||||||
```sh
|
```sh
|
||||||
./gradlew :app:assembleDebug # build the debug APK
|
./gradlew :app:assembleDebug # build the debug APK
|
||||||
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
|
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
|
||||||
./gradlew lintDebug # Android lint (CI runs this on every push)
|
./gradlew lintDebug # Android lint (CI runs this on every PR)
|
||||||
```
|
```
|
||||||
|
|
||||||
CI (`.gitea/workflows/ci.yaml`) runs lint → unit tests → debug build on every
|
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
|
||||||
push, so run these locally before opening a PR. Keep CI green.
|
then lint → unit tests → debug build on every pull request, so run these locally
|
||||||
|
before opening a PR. Keep CI green.
|
||||||
|
|
||||||
|
## Translations
|
||||||
|
|
||||||
|
**Never edit a `values-*/strings.xml` file in a pull request.** Translations are
|
||||||
|
owned by a self-hosted Weblate that writes to this repository directly, and a
|
||||||
|
hand-edit is overwritten on the next sync.
|
||||||
|
|
||||||
|
→ **[Translate Agendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
|
||||||
|
|
||||||
|
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
|
||||||
|
picks it up and offers it to translators. Partial translations are expected and
|
||||||
|
fine — missing keys are informational. Stale and orphaned keys are not, so run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/check_translations.py
|
||||||
|
```
|
||||||
|
|
||||||
|
before pushing. It reports those more clearly than lint's `MissingTranslation`
|
||||||
|
does, and it's what the `Translations` check runs on every PR.
|
||||||
|
|
||||||
|
A new language also needs one `<locale>` line in
|
||||||
|
`app/src/main/res/xml/locales_config.xml` — that file is the single source of
|
||||||
|
truth for both the in-app picker and the Android 13+ per-app language setting.
|
||||||
|
|
||||||
## Where to put code
|
## Where to put code
|
||||||
|
|
||||||
@@ -75,12 +99,14 @@ push, so run these locally before opening a PR. Keep CI green.
|
|||||||
- If your change shifts the architecture or completes a milestone, update
|
- If your change shifts the architecture or completes a milestone, update
|
||||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
|
||||||
in the same PR.
|
in the same PR.
|
||||||
- Don't bump `versionName` / `versionCode` by hand — the git tag drives those at
|
- Don't bump `versionName` / `versionCode` in a regular PR — the committed
|
||||||
release time.
|
`versionName` is bumped only when **cutting a release** (that bump reaching
|
||||||
|
`main` is what triggers the release; the pipeline then mints the tag). See
|
||||||
|
[`docs/RELEASING.md`](docs/RELEASING.md).
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Floret stays true to its thesis: a front-end over **open** task backends
|
Agendula stays true to its thesis: a front-end over **open** task backends
|
||||||
(CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends
|
(CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends
|
||||||
(Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning
|
(Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning
|
||||||
a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's
|
a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's
|
||||||
|
|||||||
79
README.md
79
README.md
@@ -1,11 +1,12 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<h1>Floret</h1>
|
<h1>Agendula</h1>
|
||||||
|
|
||||||
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
|
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
|
||||||
Reads, writes, and reminds — on top of an existing tasks provider, with no own
|
Keeps your tasks on your device, or on top of a tasks provider you already use.
|
||||||
sync stack.</p>
|
Open standards, no account required.</p>
|
||||||
|
|
||||||
|
<a href="https://codeberg.org/jlmakiola/agendula/actions"><img src="https://codeberg.org/jlmakiola/agendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||||
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
||||||
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
||||||
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
||||||
@@ -13,30 +14,66 @@ sync stack.</p>
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Floret is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula).
|
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
|
||||||
Where Calendula is a pure front-end over Android's `CalendarContract`, Floret is
|
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula
|
||||||
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
|
keeps its own store, designed around RFC 5545's `VTODO` — the same tasks DAVx5
|
||||||
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
|
(and SmoothSync, DecSync, …) sync out of your CalDAV server. It can also read and
|
||||||
database, no reinvented sync.
|
write a tasks provider you already have, for anyone already syncing that way.
|
||||||
|
|
||||||
A Calendula flower head is botanically made of many small *florets* — the
|
The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for
|
||||||
individual items that make up the bloom. Floret is those items: your tasks.
|
“things to be done” — given Calendula's `-ula` ending. Calendula keeps your days;
|
||||||
|
Agendula keeps your to-dos. (A Calendula flower head is botanically a cluster of
|
||||||
|
many small *florets* — so the two apps are florets of one bloom.)
|
||||||
|
|
||||||
> **Status: data layer done, UI in progress.** The full non-visual stack over
|
## Where your tasks live — your choice
|
||||||
> the `TaskContract` provider — provider resolution, live-updating reads,
|
|
||||||
> writes, smart-list filtering, and a self-scheduled reminder engine — is built
|
| | Where | Sync | Needs |
|
||||||
> and unit-tested. The Material 3 Expressive screens are now being built on top,
|
|---|---|---|---|
|
||||||
> one at a time. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status,
|
| **On your device** *(default)* | Agendula's own database | none yet — CalDAV sync of our own is planned | nothing. No account, no permissions, no other app |
|
||||||
|
| **In a provider you already use** | OpenTasks or tasks.org | whatever syncs it for you — DAVx5 and friends | that app installed, and its read/write permission |
|
||||||
|
|
||||||
|
Agendula's own store is an ordinary app database — nothing is published to other
|
||||||
|
apps, so there is no authority to clash over and no permission to grant. It
|
||||||
|
**coexists with OpenTasks rather than replacing it**: installing one never breaks
|
||||||
|
the other, and if you already sync through a provider, that keeps working exactly
|
||||||
|
as it did.
|
||||||
|
|
||||||
|
Recurring tasks are expanded per RFC 5545, and everything the schema does not
|
||||||
|
model is round-tripped verbatim rather than dropped — so passing your tasks
|
||||||
|
through Agendula does not quietly lose fields a server sent.
|
||||||
|
|
||||||
|
Your tasks are exportable as standard iCalendar `.ics` files at any time, because
|
||||||
|
data you can't take with you isn't really yours.
|
||||||
|
|
||||||
|
> **Status: backend complete, UI catching up.** Storage, reads and
|
||||||
|
> writes, smart-list filtering, a self-scheduled reminder engine, and export are
|
||||||
|
> built and unit-tested. The Material 3 Expressive screens are being built on
|
||||||
|
> top, one at a time — the storage-mode picker and export screen are not there
|
||||||
|
> yet. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status,
|
||||||
> [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how it's built, and
|
> [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how it's built, and
|
||||||
> [`docs/PLAN.md`](docs/PLAN.md) for the A-now-B-later design rationale.
|
> [`docs/STORAGE-AND-SYNC.md`](docs/STORAGE-AND-SYNC.md) for why storage works
|
||||||
|
> the way it does.
|
||||||
|
|
||||||
## Sync sources (by design)
|
## Sync sources (by design)
|
||||||
|
|
||||||
Floret works with anything that writes to the tasks provider — **DAVx5**
|
In external-provider mode Agendula works with anything that writes to that provider —
|
||||||
(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync
|
**DAVx5** (CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any
|
||||||
adapter — because it builds on the provider, not on any one sync app. Google
|
Android sync adapter — because it builds on the provider, not on any one sync
|
||||||
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
|
app. Google Tasks / Microsoft To Do are out of scope by design (proprietary; they
|
||||||
owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane.
|
would mean owning a sync stack). Open standards — CalDAV / iCalendar / DecSync —
|
||||||
|
are the lane.
|
||||||
|
|
||||||
|
## Translations
|
||||||
|
|
||||||
|
Agendula ships in English so far, and would like not to. Translations are
|
||||||
|
managed on a self-hosted **Weblate**, and partial ones are fine — an
|
||||||
|
untranslated string simply falls back to English.
|
||||||
|
|
||||||
|
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
|
||||||
|
|
||||||
|
No coding needed: register on the Weblate server, pick (or request) a language,
|
||||||
|
and translate the strings in your browser. You can also reach this link in the
|
||||||
|
app from the top of **Settings → App language**.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -16,20 +16,21 @@ val keystoreProperties = Properties().apply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "de.jeanlucmakiola.floret"
|
namespace = "de.jeanlucmakiola.agendula"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "de.jeanlucmakiola.floret"
|
applicationId = "de.jeanlucmakiola.agendula"
|
||||||
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. 0.2.0 -> 200). The Gitea release is marked
|
||||||
versionCode = 200
|
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
||||||
versionName = "0.2.0"
|
versionCode = 400
|
||||||
|
versionName = "0.4.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -47,6 +48,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 +67,22 @@ 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 merging to main — 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 {
|
||||||
@@ -73,18 +95,44 @@ android {
|
|||||||
buildConfig = true
|
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 {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
// Community translations are expected to be partial — a missing string
|
||||||
|
// falls back to the English base at runtime — so don't fail the build on
|
||||||
|
// it. Likewise a translated <plurals> may not fill every CLDR quantity
|
||||||
|
// form its locale defines (e.g. Arabic needs "zero"); the missing form
|
||||||
|
// falls back to "other" at runtime, so MissingQuantity is informational
|
||||||
|
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||||
|
// check_translations.py guards the same invariants with clearer,
|
||||||
|
// translator-facing messages.
|
||||||
|
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||||
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests {
|
unitTests {
|
||||||
all { it.useJUnitPlatform() }
|
all { it.useJUnitPlatform() }
|
||||||
isReturnDefaultValues = true
|
isReturnDefaultValues = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MigrationTestHelper reads the exported schemas out of the test APK's
|
||||||
|
// assets, so app/schemas/ has to ship with the instrumented tests.
|
||||||
|
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -93,11 +141,27 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export each Room schema version to app/schemas/ and commit it. That JSON is
|
||||||
|
// what MigrationTestHelper reads to build an old database and migrate it, so
|
||||||
|
// without it a migration can only be tested by hand.
|
||||||
|
ksp {
|
||||||
|
arg("room.schemaLocation", "$projectDir/schemas")
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// Not a dependency we use directly — lifecycle already drags it in at 1.7.3.
|
||||||
|
// AGP's consistent resolution then pins androidTest to the app classpath, and
|
||||||
|
// room-testing's MigrationTestHelper needs 1.8+ to deserialize the exported
|
||||||
|
// schema; on 1.7.3 it dies with an AbstractMethodError. Raise it in one place.
|
||||||
|
constraints {
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
@@ -113,13 +177,29 @@ dependencies {
|
|||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
ksp(libs.hilt.compiler)
|
ksp(libs.hilt.compiler)
|
||||||
|
|
||||||
|
// RFC 5545 recurrence expansion, in-process. Pinned at 0.12.2 — 0.16.0
|
||||||
|
// removed RecurrenceSet. rfc5545-datetime comes with it and is part of its
|
||||||
|
// API surface, so it isn't declared separately.
|
||||||
|
implementation(libs.dmfs.lib.recur)
|
||||||
|
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
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(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-time")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||||
|
implementation("de.jeanlucmakiola.floret:identity")
|
||||||
|
implementation("de.jeanlucmakiola.floret:components")
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
@@ -135,6 +215,7 @@ dependencies {
|
|||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(libs.androidx.test.rules)
|
androidTestImplementation(libs.androidx.test.rules)
|
||||||
androidTestImplementation(libs.truth)
|
androidTestImplementation(libs.truth)
|
||||||
|
androidTestImplementation(libs.androidx.room.testing)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
}
|
}
|
||||||
|
|||||||
15
app/proguard-rules.pro
vendored
15
app/proguard-rules.pro
vendored
@@ -2,5 +2,20 @@
|
|||||||
-keep class dagger.hilt.** { *; }
|
-keep class dagger.hilt.** { *; }
|
||||||
-keep @dagger.hilt.android.HiltAndroidApp class *
|
-keep @dagger.hilt.android.HiltAndroidApp class *
|
||||||
|
|
||||||
|
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
|
||||||
|
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
|
||||||
|
# nothing calls it directly — Room then throws InstantiationException, reported
|
||||||
|
# as "Failed to create an instance of ...". We pull Room in transitively via
|
||||||
|
# Glance -> WorkManager, whose WorkDatabase is built by WorkManagerInitializer
|
||||||
|
# at startup, so the app died on launch in every minified build (issue #1).
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
||||||
|
|
||||||
|
# WorkManager likewise looks its workers up by name and calls this constructor
|
||||||
|
# reflectively — same pruning, but it only bites once a worker actually runs
|
||||||
|
# (Glance's widget updates), so keep it explicitly rather than wait for it.
|
||||||
|
-keep class * extends androidx.work.ListenableWorker {
|
||||||
|
<init>(android.content.Context, androidx.work.WorkerParameters);
|
||||||
|
}
|
||||||
|
|
||||||
# Compose Compiler may keep its own; defaults are fine
|
# Compose Compiler may keep its own; defaults are fine
|
||||||
-dontwarn org.jetbrains.annotations.**
|
-dontwarn org.jetbrains.annotations.**
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 1,
|
||||||
|
"identityHash": "c94852274d874fe255ee76e1e46a3003",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "accounts",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "displayName",
|
||||||
|
"columnName": "display_name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "principalUrl",
|
||||||
|
"columnName": "principal_url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "homeSetUrl",
|
||||||
|
"columnName": "home_set_url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncAt",
|
||||||
|
"columnName": "last_sync_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncError",
|
||||||
|
"columnName": "last_sync_error",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "task_lists",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accountId",
|
||||||
|
"columnName": "account_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isVisible",
|
||||||
|
"columnName": "is_visible",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isSynced",
|
||||||
|
"columnName": "is_synced",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "owner",
|
||||||
|
"columnName": "owner",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isReadOnly",
|
||||||
|
"columnName": "is_read_only",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "href",
|
||||||
|
"columnName": "href",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "ctag",
|
||||||
|
"columnName": "ctag",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "syncToken",
|
||||||
|
"columnName": "sync_token",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDirty",
|
||||||
|
"columnName": "is_dirty",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_task_lists_account_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"account_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "accounts",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"account_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "tasks",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "listId",
|
||||||
|
"columnName": "list_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "href",
|
||||||
|
"columnName": "href",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "etag",
|
||||||
|
"columnName": "etag",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "location",
|
||||||
|
"columnName": "location",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "percentComplete",
|
||||||
|
"columnName": "percent_complete",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "completedAt",
|
||||||
|
"columnName": "completed_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "priority",
|
||||||
|
"columnName": "priority",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "classification",
|
||||||
|
"columnName": "classification",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dtstart",
|
||||||
|
"columnName": "dtstart",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "due",
|
||||||
|
"columnName": "due",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "duration",
|
||||||
|
"columnName": "duration",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isAllDay",
|
||||||
|
"columnName": "is_all_day",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timezone",
|
||||||
|
"columnName": "timezone",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "rrule",
|
||||||
|
"columnName": "rrule",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "rdate",
|
||||||
|
"columnName": "rdate",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "exdate",
|
||||||
|
"columnName": "exdate",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "recurrenceId",
|
||||||
|
"columnName": "recurrence_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "masterId",
|
||||||
|
"columnName": "master_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentId",
|
||||||
|
"columnName": "parent_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "created_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastModified",
|
||||||
|
"columnName": "last_modified",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sequence",
|
||||||
|
"columnName": "sequence",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDirty",
|
||||||
|
"columnName": "is_dirty",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDeleted",
|
||||||
|
"columnName": "is_deleted",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "unknownProperties",
|
||||||
|
"columnName": "unknown_properties",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_tasks_list_id_is_deleted",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"list_id",
|
||||||
|
"is_deleted"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_parent_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"parent_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_master_id_recurrence_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"master_id",
|
||||||
|
"recurrence_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_is_dirty",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_dirty"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_list_id_uid_recurrence_id",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"list_id",
|
||||||
|
"uid",
|
||||||
|
"recurrence_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "task_lists",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"list_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"master_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"parent_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "task_alarms",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "taskId",
|
||||||
|
"columnName": "task_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "minutesBefore",
|
||||||
|
"columnName": "minutes_before",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "reference",
|
||||||
|
"columnName": "reference",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'DUE'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "message",
|
||||||
|
"columnName": "message",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_task_alarms_task_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"task_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"task_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c94852274d874fe255ee76e1e46a3003')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
app/src/androidTest/assets/tasks-v23.db
Normal file
BIN
app/src/androidTest/assets/tasks-v23.db
Normal file
Binary file not shown.
@@ -0,0 +1,290 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.legacy
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-shot import, against `assets/tasks-v23.db` — the dmfs v23 fixture
|
||||||
|
* `scripts/make_import_fixture.py` seeds. Instrumented because both halves need
|
||||||
|
* a real SQLite: the source file and Room.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class OneShotImportTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val temp = TemporaryFolder()
|
||||||
|
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
private lateinit var scope: CoroutineScope
|
||||||
|
private lateinit var prefs: DataStore<Preferences>
|
||||||
|
private lateinit var db: TasksDatabase
|
||||||
|
private lateinit var importer: OneShotImport
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
prefs = PreferenceDataStoreFactory.create(scope = scope) {
|
||||||
|
temp.newFile("import-${counter++}.preferences_pb").also(File::delete)
|
||||||
|
}
|
||||||
|
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
importer = OneShotImport(context, db, prefs)
|
||||||
|
legacyFile().delete()
|
||||||
|
archiveFile().delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
db.close()
|
||||||
|
scope.cancel()
|
||||||
|
legacyFile().delete()
|
||||||
|
archiveFile().delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun legacyFile() = context.getDatabasePath(OneShotImport.LEGACY_NAME)
|
||||||
|
private fun archiveFile() = context.getDatabasePath(OneShotImport.ARCHIVE_NAME)
|
||||||
|
|
||||||
|
/** The fixture, copied out of the test APK's assets. */
|
||||||
|
private fun fixture(target: File = temp.newFile("tasks-v23-copy.db")): File {
|
||||||
|
InstrumentationRegistry.getInstrumentation().context.assets.open(FIXTURE).use { source ->
|
||||||
|
target.outputStream().use(source::copyTo)
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun taskRows(): Map<String, TaskEntity> =
|
||||||
|
db.tasks().tasks(null, includeCompleted = true).associate { it.task.title!! to it.task }
|
||||||
|
|
||||||
|
// --- what lands -----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun importsEveryLiveTaskAndLeavesTheDeletedOneBehind() {
|
||||||
|
val counts = importer.importFrom(fixture())
|
||||||
|
|
||||||
|
assertThat(counts).isEqualTo(ImportCounts(lists = 3, tasks = 8, alarms = 2))
|
||||||
|
assertThat(taskRows().keys).containsExactly(
|
||||||
|
"Buy milk",
|
||||||
|
"Call the dentist",
|
||||||
|
"Gather receipts",
|
||||||
|
"Renew domain",
|
||||||
|
"Water the plants",
|
||||||
|
"Team offsite",
|
||||||
|
"Task in a hidden list",
|
||||||
|
"Ship the release",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun importsEveryListAsADeviceOnlyListWithItsFlags() {
|
||||||
|
importer.importFrom(fixture())
|
||||||
|
|
||||||
|
val lists = db.taskLists().lists().associateBy { it.list.name }
|
||||||
|
assertThat(lists.keys).containsExactly("Personal", "Hidden list", "Work")
|
||||||
|
assertThat(lists.values.map { it.list.accountId }).containsExactly(null, null, null)
|
||||||
|
assertThat(lists.getValue("Personal").list.isVisible).isTrue()
|
||||||
|
assertThat(lists.getValue("Hidden list").list.isVisible).isFalse()
|
||||||
|
// The list that sat under a real account: still imported, owner kept.
|
||||||
|
assertThat(lists.getValue("Work").list.owner).isEqualTo("Me")
|
||||||
|
assertThat(lists.getValue("Work").list.color).isEqualTo(0xFF2244AA.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun carriesTheTaskFieldsAcross() {
|
||||||
|
importer.importFrom(fixture())
|
||||||
|
val tasks = taskRows()
|
||||||
|
|
||||||
|
val milk = tasks.getValue("Buy milk")
|
||||||
|
assertThat(milk.due).isEqualTo(Instant.fromEpochMilliseconds(T0 + DAY))
|
||||||
|
assertThat(milk.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
assertThat(milk.createdAt).isEqualTo(Instant.fromEpochMilliseconds(T0))
|
||||||
|
|
||||||
|
val dentist = tasks.getValue("Call the dentist")
|
||||||
|
assertThat(dentist.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||||
|
assertThat(dentist.percentComplete).isEqualTo(40)
|
||||||
|
|
||||||
|
val domain = tasks.getValue("Renew domain")
|
||||||
|
assertThat(domain.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(domain.completedAt).isEqualTo(Instant.fromEpochMilliseconds(T0 - DAY))
|
||||||
|
|
||||||
|
val plants = tasks.getValue("Water the plants")
|
||||||
|
assertThat(plants.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH")
|
||||||
|
assertThat(plants.timezone).isEqualTo("Europe/Berlin")
|
||||||
|
assertThat(plants.dtstart).isEqualTo(Instant.fromEpochMilliseconds(T0))
|
||||||
|
|
||||||
|
assertThat(tasks.getValue("Team offsite").isAllDay).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- uids -----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keepsExistingUidsAndMintsOneWhereTheLegacyRowHadNone() {
|
||||||
|
importer.importFrom(fixture())
|
||||||
|
val tasks = taskRows()
|
||||||
|
|
||||||
|
assertThat(tasks.getValue("Buy milk").uid).isEqualTo("a1b2c3d4-0000-4000-8000-000000000001")
|
||||||
|
// The external-account row's uid is what lets it be re-attached later.
|
||||||
|
assertThat(tasks.getValue("Ship the release").uid)
|
||||||
|
.isEqualTo("a1b2c3d4-0000-4000-8000-000000000009")
|
||||||
|
|
||||||
|
val minted = tasks.getValue("Call the dentist").uid
|
||||||
|
assertThat(minted).isNotEmpty()
|
||||||
|
assertThat(UUID.fromString(minted).version()).isEqualTo(4)
|
||||||
|
assertThat(tasks.values.map { it.uid }.toSet()).hasSize(tasks.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the id remap ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun remapsListIdsOntoTheNewRowIds() {
|
||||||
|
importer.importFrom(fixture())
|
||||||
|
|
||||||
|
val lists = db.taskLists().lists().associateBy { it.list.name }
|
||||||
|
val byList = db.tasks().tasks(null, includeCompleted = true)
|
||||||
|
.groupBy { it.task.listId }
|
||||||
|
.mapValues { (_, rows) -> rows.size }
|
||||||
|
|
||||||
|
assertThat(byList[lists.getValue("Personal").list.id]).isEqualTo(6)
|
||||||
|
assertThat(byList[lists.getValue("Hidden list").list.id]).isEqualTo(1)
|
||||||
|
assertThat(byList[lists.getValue("Work").list.id]).isEqualTo(1)
|
||||||
|
// No task kept a dmfs row id that Room never handed out.
|
||||||
|
assertThat(byList.keys).containsExactlyElementsIn(lists.values.map { it.list.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun remapsParentIdsOntoTheNewRowIds() {
|
||||||
|
importer.importFrom(fixture())
|
||||||
|
val tasks = taskRows()
|
||||||
|
|
||||||
|
val parent = tasks.getValue("Buy milk")
|
||||||
|
val child = tasks.getValue("Gather receipts")
|
||||||
|
assertThat(child.parentId).isEqualTo(parent.id)
|
||||||
|
assertThat(db.tasks().subtasks(parent.id).map { it.task.title }).containsExactly("Gather receipts")
|
||||||
|
assertThat(tasks.values.filter { it.parentId != null }).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- alarms ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun importsAlarmsAndSkipsEveryOtherProperty() {
|
||||||
|
importer.importFrom(fixture())
|
||||||
|
val tasks = taskRows()
|
||||||
|
|
||||||
|
assertThat(db.alarms().all()).hasSize(2)
|
||||||
|
|
||||||
|
val milk = db.alarms().forTask(tasks.getValue("Buy milk").id).single()
|
||||||
|
assertThat(milk.minutesBefore).isEqualTo(30)
|
||||||
|
assertThat(milk.reference).isEqualTo(AlarmReference.DUE)
|
||||||
|
assertThat(milk.message).isNull()
|
||||||
|
|
||||||
|
val release = db.alarms().forTask(tasks.getValue("Ship the release").id).single()
|
||||||
|
assertThat(release.minutesBefore).isEqualTo(1440)
|
||||||
|
assertThat(release.reference).isEqualTo(AlarmReference.DUE)
|
||||||
|
assertThat(release.message).isEqualTo("Ship it")
|
||||||
|
|
||||||
|
// The category property on task 1 is not an alarm.
|
||||||
|
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- running it -----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun runIfNeededImportsArchivesTheSourceAndThenDoesNothing() = runBlocking {
|
||||||
|
fixture(legacyFile())
|
||||||
|
|
||||||
|
val first = importer.runIfNeeded()
|
||||||
|
|
||||||
|
assertThat(first).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
|
||||||
|
assertThat(legacyFile().exists()).isFalse()
|
||||||
|
assertThat(archiveFile().exists()).isTrue()
|
||||||
|
assertThat(importer.isDone.first()).isTrue()
|
||||||
|
|
||||||
|
val second = importer.runIfNeeded()
|
||||||
|
|
||||||
|
assertThat(second).isEqualTo(ImportResult.AlreadyDone)
|
||||||
|
assertThat(taskRows()).hasSize(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInterruptedImportResumesFromTheArchiveWithoutDoubling() = runBlocking {
|
||||||
|
// The process dying between the commit and the flag write is the one gap
|
||||||
|
// the DataStore flag cannot cover on its own. Because the rename happens
|
||||||
|
// first and the import always replaces, the next run finds the archive and
|
||||||
|
// redoes the same work rather than importing a second copy.
|
||||||
|
fixture(legacyFile())
|
||||||
|
importer.runIfNeeded()
|
||||||
|
importer.clearCompletion()
|
||||||
|
|
||||||
|
val resumed = importer.runIfNeeded()
|
||||||
|
|
||||||
|
assertThat(resumed).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
|
||||||
|
assertThat(taskRows()).hasSize(8)
|
||||||
|
assertThat(db.taskLists().lists()).hasSize(3)
|
||||||
|
assertThat(db.alarms().all()).hasSize(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun runIfNeededMarksItselfDoneWhenThereIsNoLegacyDatabase() = runBlocking {
|
||||||
|
assertThat(importer.runIfNeeded()).isEqualTo(ImportResult.NothingToImport)
|
||||||
|
assertThat(importer.isDone.first()).isTrue()
|
||||||
|
assertThat(taskRows()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reimportFromTheArchiveReplacesRatherThanMerges() = runBlocking {
|
||||||
|
fixture(legacyFile())
|
||||||
|
importer.runIfNeeded()
|
||||||
|
|
||||||
|
val again = importer.reimportFromArchive()
|
||||||
|
|
||||||
|
assertThat(again).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
|
||||||
|
assertThat(db.taskLists().lists()).hasSize(3)
|
||||||
|
assertThat(taskRows()).hasSize(8)
|
||||||
|
assertThat(db.alarms().all()).hasSize(2)
|
||||||
|
assertThat(archiveFile().exists()).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun replacingTwiceFromTheSameFileLeavesOneCopy() {
|
||||||
|
importer.importFrom(fixture())
|
||||||
|
val counts = importer.importFrom(fixture(temp.newFile("second.db")), replaceExisting = true)
|
||||||
|
|
||||||
|
assertThat(counts).isEqualTo(ImportCounts(3, 8, 2))
|
||||||
|
assertThat(taskRows()).hasSize(8)
|
||||||
|
assertThat(db.taskLists().lists()).hasSize(3)
|
||||||
|
assertThat(db.alarms().all()).hasSize(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FIXTURE = "tasks-v23.db"
|
||||||
|
const val T0 = 1_768_467_600_000L
|
||||||
|
const val DAY = 86_400_000L
|
||||||
|
var counter = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seam over Room, exercised through [de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
* .TasksDataSource] rather than the DAOs — recurrence expansion and override
|
||||||
|
* forking only exist at this level.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class RoomTasksDataSourceTest {
|
||||||
|
|
||||||
|
private lateinit var db: TasksDatabase
|
||||||
|
private lateinit var source: RoomTasksDataSource
|
||||||
|
private var listId = 0L
|
||||||
|
|
||||||
|
/** Truncated to the store's granularity: instants are columns of epoch millis. */
|
||||||
|
private val now get() = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds())
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
db = Room.inMemoryDatabaseBuilder(
|
||||||
|
ApplicationProvider.getApplicationContext(),
|
||||||
|
TasksDatabase::class.java,
|
||||||
|
).allowMainThreadQueries().build()
|
||||||
|
source = RoomTasksDataSource(db)
|
||||||
|
listId = source.createLocalList("Personal", 0xFF112233.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = db.close()
|
||||||
|
|
||||||
|
private fun form(
|
||||||
|
title: String = "task",
|
||||||
|
due: Instant? = null,
|
||||||
|
percentComplete: Int? = null,
|
||||||
|
) = TaskForm(title = title, listId = listId, due = due, percentComplete = percentComplete)
|
||||||
|
|
||||||
|
/** Turns [taskId] into a weekly series anchored at [anchor]. */
|
||||||
|
private fun makeRecurring(taskId: Long, anchor: Instant, rule: String = "FREQ=WEEKLY") {
|
||||||
|
val entity = db.tasks().entity(taskId)!!
|
||||||
|
db.tasks().update(entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createsAndReadsBackALocalList() {
|
||||||
|
val lists = source.taskLists()
|
||||||
|
|
||||||
|
assertThat(lists).hasSize(1)
|
||||||
|
assertThat(lists.single().name).isEqualTo("Personal")
|
||||||
|
// No account, so the list still has to report something the lists screen
|
||||||
|
// can group under.
|
||||||
|
assertThat(lists.single().isLocal).isTrue()
|
||||||
|
assertThat(lists.single().accountName).isEqualTo("Local")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renamesAndRecoloursAList() {
|
||||||
|
source.updateList(listId, " Errands ", 0xFF445566.toInt())
|
||||||
|
|
||||||
|
val list = source.taskLists().single()
|
||||||
|
assertThat(list.name).isEqualTo("Errands")
|
||||||
|
assertThat(list.color).isEqualTo(0xFF445566.toInt())
|
||||||
|
// Nothing to sync a device-only list to, so the edit leaves it clean.
|
||||||
|
assertThat(db.taskLists().entity(listId)!!.isDirty).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingAListTakesItsTasksWithIt() {
|
||||||
|
source.insertTask(form(title = "Buy milk"))
|
||||||
|
source.insertTask(form(title = "Call the bank"))
|
||||||
|
val other = source.createLocalList("Work", 0xFF778899.toInt())
|
||||||
|
val keeper = source.insertTask(TaskForm(title = "Ship it", listId = other))
|
||||||
|
|
||||||
|
source.deleteList(listId)
|
||||||
|
|
||||||
|
assertThat(source.taskLists().map { it.id }).containsExactly(other)
|
||||||
|
assertThat(source.tasks(TaskQuery(includeCompleted = true)).map { it.taskId })
|
||||||
|
.containsExactly(keeper)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createsAndReadsBackANonRecurringTask() {
|
||||||
|
val due = now + 1.days
|
||||||
|
val id = source.insertTask(form(title = "Buy milk", due = due))
|
||||||
|
|
||||||
|
val task = source.task(id)!!
|
||||||
|
|
||||||
|
assertThat(task.taskId).isEqualTo(id)
|
||||||
|
assertThat(task.title).isEqualTo("Buy milk")
|
||||||
|
assertThat(task.due).isEqualTo(due)
|
||||||
|
assertThat(task.isRecurring).isFalse()
|
||||||
|
// A task that does not recur has no occurrence anchor, so it keys and edits
|
||||||
|
// by task id exactly as it did against the provider.
|
||||||
|
assertThat(task.occurrenceStart).isNull()
|
||||||
|
assertThat(task.occurrenceKey).isEqualTo("$id")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mintsAUidForEveryTask() {
|
||||||
|
val id = source.insertTask(form())
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.uid).isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun expandsARecurringSeriesIntoManyOccurrences() {
|
||||||
|
val anchor = now
|
||||||
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
|
makeRecurring(id, anchor)
|
||||||
|
|
||||||
|
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
||||||
|
|
||||||
|
// The provider materialised exactly one upcoming occurrence; we expand the
|
||||||
|
// whole window, so a weekly series yields well over a hundred.
|
||||||
|
assertThat(occurrences.size).isGreaterThan(100)
|
||||||
|
assertThat(occurrences.map { it.occurrenceStart }).containsNoDuplicates()
|
||||||
|
assertThat(occurrences.map { it.occurrenceKey }).containsNoDuplicates()
|
||||||
|
assertThat(occurrences.all { it.isRecurring }).isTrue()
|
||||||
|
// Each occurrence keeps the series' length rather than the master's dates.
|
||||||
|
val first = occurrences.minBy { it.occurrenceStart!! }
|
||||||
|
assertThat(first.due!! - first.start!!).isEqualTo(1.days)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun exactlyOneOccurrenceIsTheCurrentOne() {
|
||||||
|
val id = source.insertTask(form())
|
||||||
|
makeRecurring(id, now - 30.days)
|
||||||
|
|
||||||
|
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
||||||
|
|
||||||
|
assertThat(occurrences.count { it.distanceFromCurrent == 0 }).isEqualTo(1)
|
||||||
|
assertThat(source.task(id)!!.distanceFromCurrent).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun editingOneOccurrenceForksARecurrenceIdOverride() {
|
||||||
|
val anchor = now
|
||||||
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
|
makeRecurring(id, anchor)
|
||||||
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
|
.filter { it.taskId == id }
|
||||||
|
.first { it.distanceFromCurrent == 1 }
|
||||||
|
|
||||||
|
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
|
||||||
|
|
||||||
|
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||||
|
// RFC 5545's model: the override shares its master's UID — that is what
|
||||||
|
// makes it an override rather than a separate task. The dmfs provider
|
||||||
|
// detached the occurrence into a new task with its own UID instead.
|
||||||
|
assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
|
||||||
|
assertThat(override.masterId).isEqualTo(id)
|
||||||
|
assertThat(override.recurrenceId).isEqualTo(target.occurrenceStart)
|
||||||
|
assertThat(override.rrule).isNull()
|
||||||
|
assertThat(override.title).isEqualTo("Water them twice")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
|
||||||
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
|
makeRecurring(id, now)
|
||||||
|
val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } }
|
||||||
|
val before = open()
|
||||||
|
val target = before.first { it.distanceFromCurrent == 0 }
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, target.occurrenceStart!!, completed = true)
|
||||||
|
|
||||||
|
// Writing the status onto the master would close the series: the master is
|
||||||
|
// the row the task query filters on, so every occurrence would vanish.
|
||||||
|
val after = open()
|
||||||
|
assertThat(after).hasSize(before.size - 1)
|
||||||
|
assertThat(after.map { it.occurrenceStart }).doesNotContain(target.occurrenceStart)
|
||||||
|
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
|
||||||
|
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||||
|
assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
|
||||||
|
assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(override.rrule).isNull()
|
||||||
|
// The override stands for *that* occurrence, so it carries the
|
||||||
|
// occurrence's resolved times, not the master's anchor.
|
||||||
|
assertThat(override.dtstart).isEqualTo(target.occurrenceStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningACompletedOccurrenceReusesItsOverride() {
|
||||||
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
|
makeRecurring(id, now)
|
||||||
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
|
.first { it.taskId == id && it.distanceFromCurrent == 0 }
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, target.occurrenceStart!!, completed = true)
|
||||||
|
source.setCompletedInstance(id, target.occurrenceStart, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(id)).hasSize(1)
|
||||||
|
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status)
|
||||||
|
.isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart })
|
||||||
|
.contains(target.occurrenceStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() {
|
||||||
|
val id = source.insertTask(form(title = "Buy milk", due = now + 1.days))
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, now, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||||
|
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOverrideReplacesOnlyItsOwnOccurrence() {
|
||||||
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
|
makeRecurring(id, now)
|
||||||
|
// The list holds this series alone, so no filter is needed — and none can
|
||||||
|
// be written on taskId, since the override reports its own row id.
|
||||||
|
val before = source.tasks(TaskQuery(listId = listId))
|
||||||
|
val target = before.first { it.distanceFromCurrent == 1 }
|
||||||
|
|
||||||
|
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
|
||||||
|
|
||||||
|
val after = source.tasks(TaskQuery(listId = listId))
|
||||||
|
assertThat(after).hasSize(before.size)
|
||||||
|
val edited = after.single { it.title == "Water them twice" }
|
||||||
|
assertThat(edited.occurrenceStart).isEqualTo(target.occurrenceStart)
|
||||||
|
assertThat(after.filter { it.occurrenceStart == target.occurrenceStart }).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An edited occurrence addresses its own row, not the master's. That is what
|
||||||
|
* sends the *next* edit down `updateTask` rather than forking a second time:
|
||||||
|
* an override carries no rule, so it reads back as non-recurring.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun anEditedOccurrenceReportsTheOverridesOwnId() {
|
||||||
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
|
makeRecurring(id, now)
|
||||||
|
val target = source.tasks(TaskQuery(listId = listId)).first { it.distanceFromCurrent == 1 }
|
||||||
|
|
||||||
|
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
|
||||||
|
|
||||||
|
val edited = source.tasks(TaskQuery(listId = listId)).single { it.title == "Water them twice" }
|
||||||
|
val overrideId = db.tasks().override(id, target.occurrenceStart)!!.id
|
||||||
|
assertThat(edited.taskId).isEqualTo(overrideId)
|
||||||
|
assertThat(edited.taskId).isNotEqualTo(id)
|
||||||
|
assertThat(source.task(overrideId)!!.isRecurring).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun editingASeriesDoesNotReAnchorItWhenOneOccurrenceIsEdited() {
|
||||||
|
val anchor = now
|
||||||
|
val id = source.insertTask(form())
|
||||||
|
makeRecurring(id, anchor)
|
||||||
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
|
.filter { it.taskId == id }
|
||||||
|
.first { it.distanceFromCurrent == 2 }
|
||||||
|
|
||||||
|
source.updateInstance(id, target.occurrenceStart!!, form(due = now + 99.days))
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(anchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updatingANonRecurringTaskWritesThroughToItsRow() {
|
||||||
|
val id = source.insertTask(form(title = "old"))
|
||||||
|
|
||||||
|
source.updateTask(id, form(title = "new"))
|
||||||
|
|
||||||
|
assertThat(source.task(id)!!.title).isEqualTo("new")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completionTogglesTheWholeTriple() {
|
||||||
|
val id = source.insertTask(form())
|
||||||
|
|
||||||
|
source.setCompleted(id, completed = true)
|
||||||
|
val done = db.tasks().entity(id)!!
|
||||||
|
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(done.percentComplete).isEqualTo(100)
|
||||||
|
assertThat(done.completedAt).isNotNull()
|
||||||
|
|
||||||
|
source.setCompleted(id, completed = false)
|
||||||
|
assertThat(db.tasks().entity(id)!!.completedAt).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completedTasksAreExcludedUnlessAskedFor() {
|
||||||
|
val id = source.insertTask(form())
|
||||||
|
source.setCompleted(id, completed = true)
|
||||||
|
|
||||||
|
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = false))).isEmpty()
|
||||||
|
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = true))).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
|
||||||
|
val id = source.insertTask(form(due = now + 1.days))
|
||||||
|
|
||||||
|
source.setAlarm(id, 30)
|
||||||
|
assertThat(source.alarms()[id]).isEqualTo(30)
|
||||||
|
|
||||||
|
source.setAlarm(id, 60)
|
||||||
|
assertThat(db.alarms().forTask(id)).hasSize(1)
|
||||||
|
assertThat(source.alarms()[id]).isEqualTo(60)
|
||||||
|
|
||||||
|
source.setAlarm(id, null)
|
||||||
|
assertThat(source.alarms()).doesNotContainKey(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
|
||||||
|
val id = source.insertTask(form(due = now + 1.days))
|
||||||
|
makeRecurring(id, now)
|
||||||
|
source.setAlarm(id, 30)
|
||||||
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
|
.filter { it.taskId == id }
|
||||||
|
.first { it.distanceFromCurrent == 1 }
|
||||||
|
|
||||||
|
source.updateInstance(id, target.occurrenceStart!!, form())
|
||||||
|
|
||||||
|
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||||
|
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingATaskInALocalListRemovesItOutright() {
|
||||||
|
val id = source.insertTask(form())
|
||||||
|
|
||||||
|
source.deleteTask(id)
|
||||||
|
|
||||||
|
// No account knows about it, so there is nothing to tombstone for.
|
||||||
|
assertThat(db.tasks().entity(id)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingASeriesTakesItsOverridesWithIt() {
|
||||||
|
val id = source.insertTask(form())
|
||||||
|
makeRecurring(id, now)
|
||||||
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
|
.filter { it.taskId == id }
|
||||||
|
.first { it.distanceFromCurrent == 1 }
|
||||||
|
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
||||||
|
|
||||||
|
source.deleteTask(id)
|
||||||
|
|
||||||
|
assertThat(db.tasks().allOverrides(listId)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subtasksReadBackUnderTheirParent() {
|
||||||
|
val parent = source.insertTask(form(title = "Prepare invoice"))
|
||||||
|
val child = source.insertTask(form(title = "Gather receipts").copy(parentId = parent))
|
||||||
|
|
||||||
|
assertThat(source.subtasks(parent).map { it.taskId }).containsExactly(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun exportReadsMastersNotOccurrences() {
|
||||||
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
|
makeRecurring(id, now)
|
||||||
|
|
||||||
|
val exported = source.exportTasks(listId)
|
||||||
|
|
||||||
|
// One row carrying the rule, not one row per occurrence with the rule lost.
|
||||||
|
assertThat(exported).hasSize(1)
|
||||||
|
assertThat(exported.single().rrule).isEqualTo("FREQ=WEEKLY")
|
||||||
|
assertThat(exported.single().uid).isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun insertingIntoAMissingListFails() {
|
||||||
|
val thrown = runCatching { source.insertTask(form().copy(listId = 9_999)) }.exceptionOrNull()
|
||||||
|
|
||||||
|
assertThat(thrown).isNotNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.testing.MigrationTestHelper
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The migration harness, proven against the committed schema in `app/schemas/`.
|
||||||
|
*
|
||||||
|
* There is one schema version today, so all there is to assert is that the helper
|
||||||
|
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
|
||||||
|
* export, the assets wiring and the identity hash all line up. That is the point:
|
||||||
|
* the first real migration only has to add its own case.
|
||||||
|
*
|
||||||
|
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
|
||||||
|
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
|
||||||
|
* database, and add a test here shaped like this:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* helper.createDatabase(TEST_DB, 1).use { db ->
|
||||||
|
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
|
||||||
|
* }
|
||||||
|
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
|
||||||
|
* // read the seeded rows back — validation proves the shape, not the data
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class TasksDatabaseMigrationTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val helper = MigrationTestHelper(
|
||||||
|
InstrumentationRegistry.getInstrumentation(),
|
||||||
|
TasksDatabase::class.java,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildsV1FromTheExportedSchema() {
|
||||||
|
helper.createDatabase(TEST_DB, 1).use { db ->
|
||||||
|
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Groceries', 0)")
|
||||||
|
db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (1, 1, 'uid-1', 'Buy milk')")
|
||||||
|
|
||||||
|
db.query("SELECT title FROM tasks").use { cursor ->
|
||||||
|
assertThat(cursor.moveToFirst()).isTrue()
|
||||||
|
assertThat(cursor.getString(0)).isEqualTo("Buy milk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validatesV1AgainstTheExportedSchema() {
|
||||||
|
helper.createDatabase(TEST_DB, 1).close()
|
||||||
|
|
||||||
|
// No migrations to run: v1 is opened and checked against 1.json, which is
|
||||||
|
// what proves the harness rather than the schema.
|
||||||
|
helper.runMigrationsAndValidate(TEST_DB, 1, true).use { db ->
|
||||||
|
assertThat(db.version).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TEST_DB = "migration-test.db"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.measureTime
|
||||||
|
import kotlin.time.measureTimedValue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan's shape at scale: 5,000 tasks with 20 recurring series, read the way a
|
||||||
|
* smart list reads them — one `tasks(TaskQuery(includeCompleted = true))`, which
|
||||||
|
* includes expanding every series in memory.
|
||||||
|
*
|
||||||
|
* The assertion is a deliberately loose ceiling, so it catches a real regression
|
||||||
|
* rather than CI jitter; the printed numbers are what the check is actually for.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class TasksDatabasePerformanceTest {
|
||||||
|
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
private lateinit var db: TasksDatabase
|
||||||
|
private lateinit var source: RoomTasksDataSource
|
||||||
|
private var listId = 0L
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
delete()
|
||||||
|
db = Room.databaseBuilder(context, TasksDatabase::class.java, DB)
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
source = RoomTasksDataSource(db)
|
||||||
|
listId = source.createLocalList("Everything", 0xFF112233.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
db.close()
|
||||||
|
delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readsFiveThousandTasksWithTwentySeriesInsideTheBudget() {
|
||||||
|
val seeded = measureTime { seed() }
|
||||||
|
|
||||||
|
// Discard the first read: it pays for statement compilation and page cache
|
||||||
|
// warming, which a running app has already paid.
|
||||||
|
source.tasks(TaskQuery(includeCompleted = true))
|
||||||
|
val (tasks, elapsed) = measureTimedValue {
|
||||||
|
source.tasks(TaskQuery(includeCompleted = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
println(
|
||||||
|
"[perf] $TASK_COUNT tasks / $SERIES_COUNT series -> ${tasks.size} occurrences " +
|
||||||
|
"in $elapsed (seed $seeded)",
|
||||||
|
)
|
||||||
|
// Expansion is bounded twice over: the read window is 1 year back and 2
|
||||||
|
// forward, and each series stops at ExpansionWindow.maxOccurrences (500),
|
||||||
|
// so the occurrence count cannot grow with the age of the series.
|
||||||
|
assertThat(tasks.size).isAtLeast(TASK_COUNT)
|
||||||
|
assertThat(elapsed.inWholeMilliseconds).isLessThan(CEILING_MILLIS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seed() {
|
||||||
|
val anchor = Clock.System.now() - 30.days
|
||||||
|
val ids = ArrayList<Long>(TASK_COUNT)
|
||||||
|
db.runInTransaction {
|
||||||
|
repeat(TASK_COUNT) { index ->
|
||||||
|
ids += source.insertTask(
|
||||||
|
TaskForm(title = "Task $index", listId = listId, due = anchor + index.days),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.runInTransaction {
|
||||||
|
ids.take(SERIES_COUNT).forEach { id ->
|
||||||
|
val entity = db.tasks().entity(id)!!
|
||||||
|
db.tasks().update(
|
||||||
|
entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = "FREQ=DAILY"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun delete() {
|
||||||
|
val base = context.getDatabasePath(DB)
|
||||||
|
base.delete()
|
||||||
|
listOf("-wal", "-shm").forEach { File(base.path + it).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DB = "performance-test.db"
|
||||||
|
const val TASK_COUNT = 5_000
|
||||||
|
const val SERIES_COUNT = 20
|
||||||
|
const val CEILING_MILLIS = 8_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Auto Backup restore path, on disk.
|
||||||
|
*
|
||||||
|
* Auto Backup copies database files without checkpointing, and Room runs in WAL
|
||||||
|
* mode — so `.db` alone can be a *stale* copy of a database whose recent writes
|
||||||
|
* are still in the `-wal` sidecar. `res/xml/backup_rules.xml` carries all three
|
||||||
|
* files and [DatabaseCheckpoint] truncates the log on `ON_STOP`; this asserts
|
||||||
|
* that both of those actually do what they claim, and that neither alone is an
|
||||||
|
* assumption.
|
||||||
|
*
|
||||||
|
* A file copy of a live database stands in for the backup transport — the
|
||||||
|
* transport is what Auto Backup does to these files, and it is not what is under
|
||||||
|
* test here.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class TasksDatabaseRestoreTest {
|
||||||
|
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
private lateinit var db: TasksDatabase
|
||||||
|
private lateinit var source: RoomTasksDataSource
|
||||||
|
private var listId = 0L
|
||||||
|
private var restored: TasksDatabase? = null
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
delete(LIVE)
|
||||||
|
delete(BACKUP)
|
||||||
|
db = open(LIVE)
|
||||||
|
source = RoomTasksDataSource(db)
|
||||||
|
listId = source.createLocalList("Personal", 0xFF112233.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
restored?.close()
|
||||||
|
db.close()
|
||||||
|
delete(LIVE)
|
||||||
|
delete(BACKUP)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roomRunsInWalMode() {
|
||||||
|
// Everything below is only interesting because of this.
|
||||||
|
assertThat(journalMode()).isEqualTo("wal")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBackupOfTheDbFileAloneLosesWhateverIsStillInTheWal() {
|
||||||
|
write("checkpointed")
|
||||||
|
checkpoint()
|
||||||
|
write("only in the wal")
|
||||||
|
|
||||||
|
backUp(withSidecars = false)
|
||||||
|
|
||||||
|
assertThat(restore()).containsExactly("checkpointed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBackupThatCarriesTheSidecarsKeepsTheLastWrite() {
|
||||||
|
write("checkpointed")
|
||||||
|
checkpoint()
|
||||||
|
write("only in the wal")
|
||||||
|
|
||||||
|
backUp(withSidecars = true)
|
||||||
|
|
||||||
|
assertThat(restore()).containsExactly("checkpointed", "only in the wal")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkpointingFirstMakesTheDbFileAloneEnough() {
|
||||||
|
write("checkpointed")
|
||||||
|
checkpoint()
|
||||||
|
write("last write")
|
||||||
|
|
||||||
|
// What DatabaseCheckpoint runs on ON_STOP — the fallback for a restore
|
||||||
|
// that arrives without the sidecars.
|
||||||
|
checkpoint()
|
||||||
|
backUp(withSidecars = false)
|
||||||
|
|
||||||
|
assertThat(restore()).containsExactly("checkpointed", "last write")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the moving parts -----------------------------------------------------
|
||||||
|
|
||||||
|
private fun open(name: String): TasksDatabase =
|
||||||
|
Room.databaseBuilder(context, TasksDatabase::class.java, name)
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun write(title: String) {
|
||||||
|
source.insertTask(TaskForm(title = title, listId = listId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun journalMode(): String =
|
||||||
|
db.openHelper.writableDatabase.query("PRAGMA journal_mode").use { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
cursor.getString(0).lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [DatabaseCheckpoint]'s pragma, asserting it was not blocked by a reader. */
|
||||||
|
private fun checkpoint() {
|
||||||
|
db.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").use { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
assertThat(cursor.getInt(0)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies the live database the way Auto Backup would: no checkpoint, files as they lie. */
|
||||||
|
private fun backUp(withSidecars: Boolean) {
|
||||||
|
delete(BACKUP)
|
||||||
|
val live = context.getDatabasePath(LIVE)
|
||||||
|
val backup = context.getDatabasePath(BACKUP)
|
||||||
|
live.copyTo(backup, overwrite = true)
|
||||||
|
if (!withSidecars) return
|
||||||
|
SIDECARS.forEach { suffix ->
|
||||||
|
val from = File(live.path + suffix)
|
||||||
|
if (from.exists()) from.copyTo(File(backup.path + suffix), overwrite = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the copy as a fresh install would and reports the task titles that survived. */
|
||||||
|
private fun restore(): List<String> {
|
||||||
|
restored?.close()
|
||||||
|
val database = open(BACKUP).also { restored = it }
|
||||||
|
return RoomTasksDataSource(database).tasks(TaskQuery(includeCompleted = true)).map { it.title }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun delete(name: String) {
|
||||||
|
val base = context.getDatabasePath(name)
|
||||||
|
base.delete()
|
||||||
|
SIDECARS.forEach { File(base.path + it).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val LIVE = "restore-live.db"
|
||||||
|
const val BACKUP = "restore-backup.db"
|
||||||
|
val SIDECARS = listOf("-wal", "-shm")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The schema, exercised through the DAOs. Instrumented rather than JVM because
|
||||||
|
* the app's unit tests are plain JUnit 5 with no Robolectric, and Room needs a
|
||||||
|
* real SQLite.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class TasksDatabaseTest {
|
||||||
|
|
||||||
|
private lateinit var db: TasksDatabase
|
||||||
|
private lateinit var lists: TaskListDao
|
||||||
|
private lateinit var tasks: TaskDao
|
||||||
|
private lateinit var alarms: TaskAlarmDao
|
||||||
|
private lateinit var accounts: AccountDao
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
db = Room.inMemoryDatabaseBuilder(
|
||||||
|
ApplicationProvider.getApplicationContext(),
|
||||||
|
TasksDatabase::class.java,
|
||||||
|
).allowMainThreadQueries().build()
|
||||||
|
lists = db.taskLists()
|
||||||
|
tasks = db.tasks()
|
||||||
|
alarms = db.alarms()
|
||||||
|
accounts = db.accounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = db.close()
|
||||||
|
|
||||||
|
private fun newList(name: String = "Groceries", accountId: Long? = null): Long =
|
||||||
|
lists.insert(TaskListEntity(name = name, color = 0xFF00FF00.toInt(), accountId = accountId))
|
||||||
|
|
||||||
|
private fun newTask(
|
||||||
|
listId: Long,
|
||||||
|
uid: String = "uid-${counter++}",
|
||||||
|
title: String? = "Buy milk",
|
||||||
|
status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||||
|
parentId: Long? = null,
|
||||||
|
masterId: Long? = null,
|
||||||
|
recurrenceId: Instant? = null,
|
||||||
|
): Long = tasks.insert(
|
||||||
|
TaskEntity(
|
||||||
|
listId = listId,
|
||||||
|
uid = uid,
|
||||||
|
title = title,
|
||||||
|
status = status,
|
||||||
|
parentId = parentId,
|
||||||
|
masterId = masterId,
|
||||||
|
recurrenceId = recurrenceId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writesAndReadsAListWithItsTasks() {
|
||||||
|
val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
|
||||||
|
val listId = newList(accountId = accountId)
|
||||||
|
val due = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
val taskId = tasks.insert(
|
||||||
|
TaskEntity(
|
||||||
|
listId = listId,
|
||||||
|
uid = "uid-1",
|
||||||
|
title = "Buy milk",
|
||||||
|
description = "2%",
|
||||||
|
due = due,
|
||||||
|
priority = 3,
|
||||||
|
status = TaskStatus.IN_PROCESS,
|
||||||
|
percentComplete = 40,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val list = lists.lists().single()
|
||||||
|
assertThat(list.list.id).isEqualTo(listId)
|
||||||
|
assertThat(list.list.name).isEqualTo("Groceries")
|
||||||
|
assertThat(list.accountDisplayName).isEqualTo("Fastmail")
|
||||||
|
|
||||||
|
val row = tasks.task(taskId)!!
|
||||||
|
assertThat(row.task.title).isEqualTo("Buy milk")
|
||||||
|
assertThat(row.task.due).isEqualTo(due)
|
||||||
|
// Stored raw: an off-bucket PRIORITY must come back as it went in.
|
||||||
|
assertThat(row.task.priority).isEqualTo(3)
|
||||||
|
assertThat(row.task.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||||
|
assertThat(row.task.percentComplete).isEqualTo(40)
|
||||||
|
assertThat(row.listName).isEqualTo("Groceries")
|
||||||
|
assertThat(row.accountDisplayName).isEqualTo("Fastmail")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readsTasksOfOneListAndHidesClosedOnesUnlessAsked() {
|
||||||
|
val a = newList("A")
|
||||||
|
val b = newList("B")
|
||||||
|
newTask(a, title = "open")
|
||||||
|
newTask(a, title = "done", status = TaskStatus.COMPLETED)
|
||||||
|
newTask(a, title = "cancelled", status = TaskStatus.CANCELLED)
|
||||||
|
newTask(b, title = "elsewhere")
|
||||||
|
|
||||||
|
assertThat(tasks.tasks(a, includeCompleted = false).map { it.task.title })
|
||||||
|
.containsExactly("open")
|
||||||
|
assertThat(tasks.tasks(a, includeCompleted = true)).hasSize(3)
|
||||||
|
assertThat(tasks.tasks(null, includeCompleted = true)).hasSize(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readsSubtasksByParent() {
|
||||||
|
val listId = newList()
|
||||||
|
val parent = newTask(listId, title = "parent")
|
||||||
|
newTask(listId, title = "child", parentId = parent)
|
||||||
|
|
||||||
|
assertThat(tasks.subtasks(parent).map { it.task.title }).containsExactly("child")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hidesTombstonesFromReadsAndExports() {
|
||||||
|
val listId = newList()
|
||||||
|
val taskId = newTask(listId)
|
||||||
|
tasks.markDeleted(taskId, Instant.fromEpochMilliseconds(1))
|
||||||
|
|
||||||
|
assertThat(tasks.tasks(listId, includeCompleted = true)).isEmpty()
|
||||||
|
assertThat(tasks.task(taskId)).isNull()
|
||||||
|
assertThat(tasks.exportTasks(listId)).isEmpty()
|
||||||
|
assertThat(tasks.entity(taskId)).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keepsOverridesOutOfTheMasterReads() {
|
||||||
|
val listId = newList()
|
||||||
|
val master = newTask(listId, uid = "series")
|
||||||
|
val override = newTask(
|
||||||
|
listId,
|
||||||
|
uid = "series",
|
||||||
|
masterId = master,
|
||||||
|
recurrenceId = Instant.fromEpochMilliseconds(5_000),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id })
|
||||||
|
.containsExactly(master)
|
||||||
|
assertThat(tasks.overrides(master).map { it.id }).containsExactly(override)
|
||||||
|
assertThat(tasks.allOverrides(listId).map { it.id }).containsExactly(override)
|
||||||
|
assertThat(tasks.override(master, Instant.fromEpochMilliseconds(5_000))?.id)
|
||||||
|
.isEqualTo(override)
|
||||||
|
assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- cascades -------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingAListDeletesItsTasks() {
|
||||||
|
val listId = newList()
|
||||||
|
val taskId = newTask(listId)
|
||||||
|
|
||||||
|
lists.delete(listId)
|
||||||
|
|
||||||
|
assertThat(tasks.entity(taskId)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingASeriesDeletesItsOverrides() {
|
||||||
|
val listId = newList()
|
||||||
|
val master = newTask(listId, uid = "series")
|
||||||
|
val override = newTask(
|
||||||
|
listId,
|
||||||
|
uid = "series",
|
||||||
|
masterId = master,
|
||||||
|
recurrenceId = Instant.fromEpochMilliseconds(5_000),
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.delete(master)
|
||||||
|
|
||||||
|
assertThat(tasks.entity(override)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingAParentPromotesItsSubtasks() {
|
||||||
|
val listId = newList()
|
||||||
|
val parent = newTask(listId, title = "parent")
|
||||||
|
val child = newTask(listId, title = "child", parentId = parent)
|
||||||
|
|
||||||
|
tasks.delete(parent)
|
||||||
|
|
||||||
|
val promoted = tasks.entity(child)
|
||||||
|
assertThat(promoted).isNotNull()
|
||||||
|
assertThat(promoted!!.parentId).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingATaskDeletesItsAlarms() {
|
||||||
|
val listId = newList()
|
||||||
|
val taskId = newTask(listId)
|
||||||
|
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
|
||||||
|
assertThat(alarms.all()).hasSize(1)
|
||||||
|
|
||||||
|
tasks.delete(taskId)
|
||||||
|
|
||||||
|
assertThat(alarms.all()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingAnAccountDetachesItsListsInsteadOfDeletingThem() {
|
||||||
|
val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
|
||||||
|
val listId = newList(accountId = accountId)
|
||||||
|
|
||||||
|
accounts.delete(accountId)
|
||||||
|
|
||||||
|
assertThat(lists.entity(listId)!!.accountId).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun replacingAnAlarmLeavesOnlyTheNewOne() {
|
||||||
|
val listId = newList()
|
||||||
|
val taskId = newTask(listId)
|
||||||
|
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
|
||||||
|
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 30))
|
||||||
|
|
||||||
|
assertThat(alarms.forTask(taskId).map { it.minutesBefore }).containsExactly(30)
|
||||||
|
assertThat(alarms.forTask(taskId).single().reference).isEqualTo(AlarmReference.DUE)
|
||||||
|
|
||||||
|
alarms.replaceForTask(taskId, null)
|
||||||
|
assertThat(alarms.forTask(taskId)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the unique index -----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOverrideMayShareItsMastersUid() {
|
||||||
|
val listId = newList()
|
||||||
|
val master = newTask(listId, uid = "series")
|
||||||
|
newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(1))
|
||||||
|
newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(2))
|
||||||
|
|
||||||
|
assertThat(tasks.overrides(master)).hasSize(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsTwoOverridesOfTheSameOccurrence() {
|
||||||
|
val listId = newList()
|
||||||
|
val master = newTask(listId, uid = "series")
|
||||||
|
val at = Instant.fromEpochMilliseconds(1)
|
||||||
|
newTask(listId, uid = "series", masterId = master, recurrenceId = at)
|
||||||
|
|
||||||
|
val failure = runCatching {
|
||||||
|
newTask(listId, uid = "series", masterId = master, recurrenceId = at)
|
||||||
|
}.exceptionOrNull()
|
||||||
|
|
||||||
|
assertThat(failure).isNotNull()
|
||||||
|
assertThat(failure!!.message).contains("UNIQUE")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theSameUidMayExistInAnotherList() {
|
||||||
|
val a = newList("A")
|
||||||
|
val b = newList("B")
|
||||||
|
newTask(a, uid = "shared")
|
||||||
|
newTask(b, uid = "shared")
|
||||||
|
|
||||||
|
assertThat(tasks.byUid(a, "shared")).isNotNull()
|
||||||
|
assertThat(tasks.byUid(b, "shared")).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
var counter = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,15 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<!-- Tasks provider access. Both permission sets are declared; the active one
|
<!-- External tasks-provider access, for StorageMode.EXTERNAL only. Both
|
||||||
|
permission sets are declared, since the manifest is static; the active one
|
||||||
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
|
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
|
||||||
runtime by the permission flow. Both are dangerous-level. -->
|
runtime by the permission flow, and only once the user has actually
|
||||||
|
selected External mode. Both are dangerous-level.
|
||||||
|
|
||||||
|
StorageMode.OWN needs nothing here: it is a Room database in our own data
|
||||||
|
directory. Agendula publishes no ContentProvider and declares no
|
||||||
|
permissions of its own. -->
|
||||||
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
|
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
|
||||||
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
|
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
|
||||||
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
||||||
@@ -30,15 +36,16 @@
|
|||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".FloretApp"
|
android:name=".AgendulaApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Floret"
|
android:theme="@style/Theme.Agendula"
|
||||||
tools:targetApi="35">
|
tools:targetApi="35">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -51,6 +58,14 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Standalone crash-report surface; MainActivity routes here on a
|
||||||
|
startup crash-loop. Not exported, kept out of recents. -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.crash.CrashReportActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:launchMode="singleTask" />
|
||||||
|
|
||||||
<!-- Reminder alarm fires here (internal PendingIntent → not exported). -->
|
<!-- Reminder alarm fires here (internal PendingIntent → not exported). -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.reminders.DueReminderReceiver"
|
android:name=".data.reminders.DueReminderReceiver"
|
||||||
@@ -65,8 +80,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Re-sync reminders when the provider changes (external DAVx5 sync).
|
<!-- Re-sync reminders when an external provider changes — DAVx5 pulling
|
||||||
Targets both known authorities; the host must be static. -->
|
tasks while Agendula is backgrounded. External mode only: in OWN mode
|
||||||
|
nothing outside the app can change our data, and Room's
|
||||||
|
InvalidationTracker covers our own writes. An intent-filter host must
|
||||||
|
be a literal, so both external authorities are listed. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.reminders.ProviderChangeReceiver"
|
android:name=".data.reminders.ProviderChangeReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -76,6 +94,17 @@
|
|||||||
<data android:scheme="content" android:host="org.dmfs.tasks" />
|
<data android:scheme="content" android:host="org.dmfs.tasks" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Persists the per-app language on API < 33, where the platform
|
||||||
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
|
<service
|
||||||
|
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="false">
|
||||||
|
<meta-data
|
||||||
|
android:name="autoStoreLocales"
|
||||||
|
android:value="true" />
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
80
app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt
Normal file
80
app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package de.jeanlucmakiola.agendula
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application entry point. Registered as android:name=".AgendulaApp". Besides
|
||||||
|
* Hilt init, it kicks off a reminder re-sync on launch (independent of any UI),
|
||||||
|
* so alarms reflect tasks synced while the app was closed.
|
||||||
|
*/
|
||||||
|
@HiltAndroidApp
|
||||||
|
class AgendulaApp : Application() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
// Install first thing so startup crashes are captured too (privacy-
|
||||||
|
// respecting, on-device; the user submits the report by hand).
|
||||||
|
CrashReporter.install(
|
||||||
|
this,
|
||||||
|
CrashConfig(
|
||||||
|
appLabel = getString(R.string.app_name),
|
||||||
|
newIssueUrl = getString(R.string.report_issue_url),
|
||||||
|
chooseIssueUrl = getString(R.string.report_issue_url),
|
||||||
|
issueTitle = getString(R.string.crash_report_issue_title),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
||||||
|
val scheduler = entryPoint.reminderScheduler()
|
||||||
|
// Mirror the stored storage mode into ProviderResolver and import a
|
||||||
|
// v0.3.x install's tasks, both before anything reads a store.
|
||||||
|
val startupGate = entryPoint.startupGate()
|
||||||
|
val scope = entryPoint.applicationScope()
|
||||||
|
// An alarm is armed off whichever store was active when it was scheduled,
|
||||||
|
// so a switch has to rebuild the set. Armed only once startup's own
|
||||||
|
// null -> stored transition is past, which the launch sync below covers.
|
||||||
|
val started = AtomicBoolean(false)
|
||||||
|
entryPoint.providerResolver().onModeChanged {
|
||||||
|
if (started.get()) scope.launch { runCatching { scheduler.sync() } }
|
||||||
|
}
|
||||||
|
startupGate.start()
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint())
|
||||||
|
scope.launch {
|
||||||
|
// Wait for the stored mode and the import to land first. Rescheduling
|
||||||
|
// alarms against whichever store autoMode happens to pick would arm
|
||||||
|
// them off the wrong one — or off an empty one, mid-import.
|
||||||
|
runCatching {
|
||||||
|
startupGate.awaitReady()
|
||||||
|
started.set(true)
|
||||||
|
scheduler.sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface AppEntryPoint {
|
||||||
|
fun reminderScheduler(): ReminderScheduler
|
||||||
|
fun startupGate(): StartupGate
|
||||||
|
fun providerResolver(): ProviderResolver
|
||||||
|
|
||||||
|
@ApplicationScope
|
||||||
|
fun applicationScope(): CoroutineScope
|
||||||
|
fun databaseCheckpoint(): DatabaseCheckpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
114
app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt
Normal file
114
app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package de.jeanlucmakiola.agendula
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.agendula.data.demo.DemoSeeder
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.agendula.ui.RootScreen
|
||||||
|
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
||||||
|
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
|
||||||
|
* (replaceable) functional scaffold over the real data layer. Task-detail intent
|
||||||
|
* routing for reminder taps lands with the full UI.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var demoSeeder: DemoSeeder
|
||||||
|
|
||||||
|
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||||
|
// over the app on the next launch (the single-crash path). A startup
|
||||||
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
|
private var pendingCrashReport by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// If the app keeps crashing as it starts, the main UI can't be trusted
|
||||||
|
// to come up. Route to the standalone report screen instead of
|
||||||
|
// re-entering the crashing graph.
|
||||||
|
if (CrashReporter.isCrashLoop(this)) {
|
||||||
|
startActivity(Intent(this, CrashReportActivity::class.java))
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
// Surface a single captured crash as a dialog on the next launch.
|
||||||
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
|
|
||||||
|
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
|
||||||
|
// local (non-syncing) demo list once; no-op without the extra.
|
||||||
|
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
|
||||||
|
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
|
||||||
|
}
|
||||||
|
setContent {
|
||||||
|
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
|
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val darkTheme = when (ui.settings.themeMode) {
|
||||||
|
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||||
|
ThemeMode.LIGHT -> false
|
||||||
|
ThemeMode.DARK -> true
|
||||||
|
}
|
||||||
|
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
|
||||||
|
RootScreen(modifier = Modifier.fillMaxSize())
|
||||||
|
pendingCrashReport?.let { report ->
|
||||||
|
CrashReportDialog(
|
||||||
|
report = report,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(this@MainActivity, report)
|
||||||
|
CrashReporter.clearReport(this@MainActivity)
|
||||||
|
pendingCrashReport = null
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
CrashReporter.dismissPrompt(this@MainActivity)
|
||||||
|
pendingCrashReport = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// A successful start breaks any loop; reset the timing trail so a later
|
||||||
|
// ordinary crash isn't mistaken for a loop.
|
||||||
|
CrashReporter.markHealthy(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||||
|
private const val EXTRA_SEED = "agendula_seed"
|
||||||
|
|
||||||
|
/** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
|
||||||
|
fun taskIntent(context: Context, taskId: Long): Intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
putExtra(EXTRA_TASK_ID, taskId)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package de.jeanlucmakiola.floret.data.demo
|
package de.jeanlucmakiola.agendula.data.demo
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.floret.domain.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.floret.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -16,10 +16,10 @@ import kotlin.time.Instant
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Debug-only one-shot sample data. Creates a **local, device-only** list
|
* Debug-only one-shot sample data. Creates a **local, device-only** list
|
||||||
* ("Floret Demo") — so nothing syncs to a CalDAV server — and fills it with
|
* ("Agendula Demo") — so nothing syncs to a CalDAV server — and fills it with
|
||||||
* tasks spread across overdue / today / upcoming / no-date / completed so every
|
* tasks spread across overdue / today / upcoming / no-date / completed so every
|
||||||
* smart list shows content. Idempotent: skips if the demo list already exists.
|
* smart list shows content. Idempotent: skips if the demo list already exists.
|
||||||
* Triggered from [de.jeanlucmakiola.floret.MainActivity] only in debug builds.
|
* Triggered from [de.jeanlucmakiola.agendula.MainActivity] only in debug builds.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class DemoSeeder @Inject constructor(
|
class DemoSeeder @Inject constructor(
|
||||||
@@ -47,14 +47,14 @@ class DemoSeeder @Inject constructor(
|
|||||||
repository.createTask(TaskForm(title = "Gather receipts", listId = listId, parentId = invoiceId))
|
repository.createTask(TaskForm(title = "Gather receipts", listId = listId, parentId = invoiceId))
|
||||||
repository.createTask(TaskForm(title = "Book train tickets", listId = listId, due = at(te + 6 * day), priority = Priority.LOW))
|
repository.createTask(TaskForm(title = "Book train tickets", listId = listId, due = at(te + 6 * day), priority = Priority.LOW))
|
||||||
repository.createTask(TaskForm(title = "Read Compose 1.5 release notes", listId = listId))
|
repository.createTask(TaskForm(title = "Read Compose 1.5 release notes", listId = listId))
|
||||||
repository.createTask(TaskForm(title = "Sketch the Floret app icon", listId = listId))
|
repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId))
|
||||||
|
|
||||||
val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day)))
|
val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day)))
|
||||||
repository.setCompleted(done, completed = true)
|
repository.setCompleted(done, occurrenceStart = null, completed = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val DEMO_LIST = "Floret Demo"
|
const val DEMO_LIST = "Agendula Demo"
|
||||||
const val DEMO_COLOR = 0xFF7A5C6B.toInt()
|
const val DEMO_COLOR = 0xFF7A5C6B.toInt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import javax.inject.Provider
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
|
name = "agendula_prefs",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class DataBindModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object DataProvideModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
|
context.agendulaDataStore
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
|
||||||
|
Room.databaseBuilder(context, TasksDatabase::class.java, TasksDatabase.NAME)
|
||||||
|
// Room's default, stated rather than assumed: Auto Backup copies files
|
||||||
|
// without checkpointing, so a `-wal` sidecar can hold writes the
|
||||||
|
// backed-up `.db` does not. The backup rules carry all three files and
|
||||||
|
// the app checkpoints on ON_STOP.
|
||||||
|
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active store, chosen by [StorageMode].
|
||||||
|
*
|
||||||
|
* Resolved per injection point rather than bound once, because the mode is a
|
||||||
|
* user setting that [de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder]
|
||||||
|
* can change while the process lives. Both implementations are singletons, so
|
||||||
|
* this picks between two long-lived objects rather than building either.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideTasksDataSource(
|
||||||
|
resolver: ProviderResolver,
|
||||||
|
room: Provider<RoomTasksDataSource>,
|
||||||
|
external: Provider<AndroidTasksDataSource>,
|
||||||
|
): TasksDataSource = ModeRoutingTasksDataSource(resolver, room, external)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@IoDispatcher
|
||||||
|
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@ApplicationScope
|
||||||
|
fun provideApplicationScope(): CoroutineScope =
|
||||||
|
// SupervisorJob so one failing collector can't take the others down with it.
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.di
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
/** Marks the IO [kotlinx.coroutines.CoroutineDispatcher] for provider access. */
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class IoDispatcher
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the process-lifetime [kotlinx.coroutines.CoroutineScope] — for work that
|
||||||
|
* outlives any screen and has nothing to be cancelled by, such as keeping the
|
||||||
|
* selected storage mode mirrored out of DataStore. It is never cancelled, so
|
||||||
|
* don't launch anything unbounded in it.
|
||||||
|
*/
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class ApplicationScope
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.export
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Where an export ended up, for the UI to report. */
|
||||||
|
data class ExportResult(val fileCount: Int, val taskListNames: List<String>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why an export failed, as a value rather than a message: the UI ships in eleven
|
||||||
|
* locales, so the wording has to come from a string resource.
|
||||||
|
*/
|
||||||
|
enum class ExportFailure {
|
||||||
|
FOLDER_UNAVAILABLE,
|
||||||
|
FOLDER_NOT_WRITABLE,
|
||||||
|
CANNOT_CREATE_FILE,
|
||||||
|
LOST_ACCESS,
|
||||||
|
WRITE_FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The export could not be written. */
|
||||||
|
class ExportFailedException(
|
||||||
|
val failure: ExportFailure,
|
||||||
|
cause: Throwable? = null,
|
||||||
|
) : IOException(failure.name, cause)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes [ExportDocument]s to a user-chosen location through the Storage Access
|
||||||
|
* Framework.
|
||||||
|
*
|
||||||
|
* No storage permission anywhere: SAF hands us a `Uri` the user picked
|
||||||
|
* themselves, which is both the modern approach and the only one that still works
|
||||||
|
* on scoped storage. The caller owns launching `ACTION_CREATE_DOCUMENT` (for
|
||||||
|
* [writeZip]) or `ACTION_OPEN_DOCUMENT_TREE` (for [writeToTree]) and passes the
|
||||||
|
* result here.
|
||||||
|
*
|
||||||
|
* Marked in `docs/STORAGE-AND-SYNC.md` as floret-kit material — the plumbing is
|
||||||
|
* not task-domain and Calendula will want the same thing. Kept app-local for now
|
||||||
|
* on the kit's own stated principle of not extracting until a second consumer
|
||||||
|
* actually exists; the seam is here, so moving it later is a file move.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ExportWriter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes every document into [treeUri], a directory the user picked.
|
||||||
|
*
|
||||||
|
* A same-named file is truncated and rewritten in place rather than deleted
|
||||||
|
* and recreated: SAF would otherwise append " (1)" and turn the folder into
|
||||||
|
* an unusable pile of snapshots, and a delete that is not followed by a
|
||||||
|
* successful create loses the previous export outright.
|
||||||
|
*
|
||||||
|
* The directory is listed once. `DocumentFile.findFile` queries the whole
|
||||||
|
* tree per call, so looking each name up in the loop is one full
|
||||||
|
* cross-process directory scan per list.
|
||||||
|
*/
|
||||||
|
suspend fun writeToTree(treeUri: Uri, documents: List<ExportDocument>): ExportResult =
|
||||||
|
withContext(io) {
|
||||||
|
runCatching {
|
||||||
|
val tree = DocumentFile.fromTreeUri(context, treeUri)
|
||||||
|
?: throw ExportFailedException(ExportFailure.FOLDER_UNAVAILABLE)
|
||||||
|
if (!tree.canWrite()) throw ExportFailedException(ExportFailure.FOLDER_NOT_WRITABLE)
|
||||||
|
val existing = tree.listFiles().associateBy { it.name }
|
||||||
|
|
||||||
|
documents.forEach { document ->
|
||||||
|
val file = existing[document.fileName]
|
||||||
|
?: tree.createFile(MIME_ICALENDAR, document.fileName)
|
||||||
|
?: throw ExportFailedException(ExportFailure.CANNOT_CREATE_FILE)
|
||||||
|
write(file.uri, document.content)
|
||||||
|
}
|
||||||
|
}.getOrElse { throw asExportFailure(it) }
|
||||||
|
ExportResult(documents.size, documents.map { it.fileName })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes every document into a single zip at [target].
|
||||||
|
*
|
||||||
|
* The one-file form, for sharing or for a backup the user filed somewhere
|
||||||
|
* themselves — one attachment rather than one per list.
|
||||||
|
*/
|
||||||
|
suspend fun writeZip(target: Uri, documents: List<ExportDocument>): ExportResult =
|
||||||
|
withContext(io) {
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.openOutputStream(target, "wt")?.use { raw ->
|
||||||
|
ZipOutputStream(raw.buffered()).use { zip ->
|
||||||
|
documents.forEach { document ->
|
||||||
|
zip.putNextEntry(ZipEntry(document.fileName))
|
||||||
|
zip.write(document.content)
|
||||||
|
zip.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
|
||||||
|
}.getOrElse { throw asExportFailure(it) }
|
||||||
|
ExportResult(documents.size, documents.map { it.fileName })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun write(target: Uri, bytes: ByteArray) {
|
||||||
|
runCatching {
|
||||||
|
// "wt" truncates. Without it a shorter export leaves the tail of the
|
||||||
|
// previous, longer one behind and produces a corrupt file.
|
||||||
|
context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) }
|
||||||
|
?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
|
||||||
|
}.getOrElse { throw asExportFailure(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun asExportFailure(cause: Throwable): Throwable = when (cause) {
|
||||||
|
is ExportFailedException -> cause
|
||||||
|
// A SAF grant can be revoked between the picker and the write (the volume
|
||||||
|
// was unmounted, the provider's process died, the user cleared the grant).
|
||||||
|
is SecurityException -> ExportFailedException(ExportFailure.LOST_ACCESS, cause)
|
||||||
|
is IOException -> ExportFailedException(ExportFailure.WRITE_FAILED, cause)
|
||||||
|
else -> cause
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MIME_ICALENDAR = "text/calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.export
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ICalendarWriter
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the user's task lists into `.ics` documents.
|
||||||
|
*
|
||||||
|
* Export is a v1 feature rather than a nicety because of where the data now
|
||||||
|
* lives: our own provider is inside the app's private storage, so in Local mode a
|
||||||
|
* user's tasks exist in exactly one place and uninstalling deletes them. On Play,
|
||||||
|
* where most people will never have a sync engine, that is the majority case.
|
||||||
|
*
|
||||||
|
* **One document per list**, because a list is a CalDAV collection and that is the
|
||||||
|
* unit every other client understands. Bundling everything into a single file
|
||||||
|
* would flatten the lists away, and list membership is not recoverable from a
|
||||||
|
* VTODO afterwards.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class TaskExporter @Inject constructor(
|
||||||
|
private val dataSource: TasksDataSource,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialises [listIds] — every visible list when null.
|
||||||
|
*
|
||||||
|
* A list with no tasks still produces a document. An empty `.ics` is a real
|
||||||
|
* answer ("this list is empty"), whereas a missing file is indistinguishable
|
||||||
|
* from the export having gone wrong.
|
||||||
|
*/
|
||||||
|
suspend fun export(listIds: Set<Long>? = null): List<ExportDocument> = withContext(io) {
|
||||||
|
dataSource.taskLists()
|
||||||
|
.filter { listIds == null || it.id in listIds }
|
||||||
|
.map { list ->
|
||||||
|
val document = ExportList(
|
||||||
|
listId = list.id,
|
||||||
|
name = list.name,
|
||||||
|
accountName = list.accountName,
|
||||||
|
tasks = dataSource.exportTasks(list.id),
|
||||||
|
)
|
||||||
|
ExportDocument(
|
||||||
|
fileName = fileNameFor(list.name, list.id),
|
||||||
|
content = ICalendarWriter.write(document).toByteArray(Charsets.UTF_8),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file name derived from the list name, safe on every filesystem the
|
||||||
|
* user might pick through SAF (including FAT32 on an SD card).
|
||||||
|
*
|
||||||
|
* The list id is appended rather than trusted to be redundant: two lists on
|
||||||
|
* different accounts may share a name, and two exports landing on the same
|
||||||
|
* file would silently lose one of them.
|
||||||
|
*/
|
||||||
|
fun fileNameFor(listName: String, listId: Long): String {
|
||||||
|
val safe = listName
|
||||||
|
.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '-' }
|
||||||
|
.joinToString("")
|
||||||
|
.trim('-')
|
||||||
|
.take(60)
|
||||||
|
.ifBlank { "list" }
|
||||||
|
return "$safe-$listId.ics"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.prefs
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||||
|
|
||||||
|
data class Settings(
|
||||||
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
|
val dynamicColor: Boolean = true,
|
||||||
|
/** The list a new task defaults to; `null` = first available. */
|
||||||
|
val defaultListId: Long? = null,
|
||||||
|
/** Default minutes before due to remind; 0 = at due time. */
|
||||||
|
val reminderLeadMinutes: Int = 0,
|
||||||
|
/** Master switch for due reminders; off clears every scheduled alarm. */
|
||||||
|
val remindersEnabled: Boolean = true,
|
||||||
|
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */
|
||||||
|
val showAddSubtaskRow: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Add affordance for a list: `false` = the floating "New task" button (opens
|
||||||
|
* the editor); `true` = a quick-add bar pinned to the bottom of a real list.
|
||||||
|
* Smart lists always use the button (they have no single list to add into).
|
||||||
|
*/
|
||||||
|
val bottomAddBar: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
||||||
|
* overrides the global default (an empty list = no reminder); absent =
|
||||||
|
* inherit. Agendula offers a single reminder, so each override is a
|
||||||
|
* one-element (or empty) list.
|
||||||
|
*/
|
||||||
|
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||||
|
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
||||||
|
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
||||||
|
) {
|
||||||
|
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
||||||
|
fun reminderLeadFor(listId: Long): Int? =
|
||||||
|
perListReminderOverride.reminderLeadsFor(listId, listOf(reminderLeadMinutes)).firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||||
|
@Singleton
|
||||||
|
class SettingsPrefs @Inject constructor(
|
||||||
|
private val dataStore: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
val settings: Flow<Settings> = dataStore.data.map { p ->
|
||||||
|
Settings(
|
||||||
|
themeMode = p[THEME_MODE]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
|
||||||
|
?: ThemeMode.SYSTEM,
|
||||||
|
dynamicColor = p[DYNAMIC_COLOR] ?: true,
|
||||||
|
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
|
||||||
|
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
|
||||||
|
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
|
||||||
|
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
|
||||||
|
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
|
||||||
|
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
|
||||||
|
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
|
||||||
|
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
|
||||||
|
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
|
||||||
|
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
|
||||||
|
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which task store backs the app, or `null` while the user has not chosen —
|
||||||
|
* which is the normal state, since most people never open Settings.
|
||||||
|
*
|
||||||
|
* Kept out of [Settings] on purpose. Everything in there is a rendering
|
||||||
|
* preference collected by the UI; this one selects an authority in the data
|
||||||
|
* layer, is read on paths that must not wait for a whole settings object, and
|
||||||
|
* `null` genuinely means "undecided" rather than "default" — the difference
|
||||||
|
* matters, because undecided is what lets `ProviderResolver.autoMode` keep an
|
||||||
|
* upgrading Posture A user pointed at the provider that holds their data.
|
||||||
|
*/
|
||||||
|
val storageMode: Flow<StorageMode?> = dataStore.data.map { p ->
|
||||||
|
when (val stored = p[STORAGE_MODE]) {
|
||||||
|
null -> null
|
||||||
|
// 0.3.x's value for the bundled dmfs provider. That store is gone and
|
||||||
|
// its data was imported into OWN, so read it as OWN rather than
|
||||||
|
// letting it fall through to autoMode — someone who chose local
|
||||||
|
// storage explicitly would otherwise be sent to an external provider.
|
||||||
|
"LOCAL" -> StorageMode.OWN
|
||||||
|
else -> runCatching { StorageMode.valueOf(stored) }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name }
|
||||||
|
|
||||||
|
/** One-time reminder onboarding gate; false until the step has been shown. */
|
||||||
|
val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
|
||||||
|
|
||||||
|
suspend fun setReminderOnboardingDone() = dataStore.edit { it[REMINDER_ONBOARDING_DONE] = true }
|
||||||
|
|
||||||
|
suspend fun setRemindersEnabled(enabled: Boolean) = dataStore.edit { it[REMINDERS_ENABLED] = enabled }
|
||||||
|
|
||||||
|
suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show }
|
||||||
|
|
||||||
|
suspend fun setBottomAddBar(enabled: Boolean) = dataStore.edit { it[BOTTOM_ADD_BAR] = enabled }
|
||||||
|
|
||||||
|
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||||
|
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
||||||
|
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
||||||
|
current.applyReminderOverride(listId, override)
|
||||||
|
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
|
||||||
|
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||||
|
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
|
||||||
|
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
|
||||||
|
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
|
||||||
|
val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
|
||||||
|
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
|
||||||
|
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
|
||||||
|
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
|
val STORAGE_MODE = stringPreferencesKey("storage_mode")
|
||||||
|
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
|
||||||
|
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agendula's stored dialect for the per-list override map: `id:minutes` entries
|
||||||
|
* joined by `;` (the family default). Fixed at release — don't change without a
|
||||||
|
* data migration.
|
||||||
|
*/
|
||||||
|
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.data.reminders
|
package de.jeanlucmakiola.agendula.data.reminders
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
package de.jeanlucmakiola.floret.data.reminders
|
package de.jeanlucmakiola.agendula.data.reminders
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -21,6 +24,7 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
@Inject lateinit var dataSource: TasksDataSource
|
@Inject lateinit var dataSource: TasksDataSource
|
||||||
@Inject lateinit var notifier: TaskNotifier
|
@Inject lateinit var notifier: TaskNotifier
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
@@ -30,6 +34,7 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
val pending = goAsync()
|
val pending = goAsync()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
|
if (!settingsPrefs.settings.first().remindersEnabled) return@launch
|
||||||
val task = runCatching { dataSource.task(taskId) }.getOrNull()
|
val task = runCatching { dataSource.task(taskId) }.getOrNull()
|
||||||
if (task != null && !task.isClosed) notifier.postDue(task)
|
if (task != null && !task.isClosed) notifier.postDue(task)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -39,9 +44,16 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.floret.extra.TASK_ID"
|
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||||
|
|
||||||
fun intent(context: Context, taskId: Long): Intent =
|
/**
|
||||||
Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId)
|
* [triggerAt] rides in the intent *data*, not just an extra: PendingIntent
|
||||||
|
* identity ignores extras, so two occurrences of the same recurring task
|
||||||
|
* would otherwise collapse into one alarm under FLAG_UPDATE_CURRENT.
|
||||||
|
*/
|
||||||
|
fun intent(context: Context, taskId: Long, triggerAt: Long): Intent =
|
||||||
|
Intent(context, DueReminderReceiver::class.java)
|
||||||
|
.setData("agendula://reminder/$taskId/$triggerAt".toUri())
|
||||||
|
.putExtra(EXTRA_TASK_ID, taskId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-syncs reminders whenever the tasks provider changes — covers external sync
|
||||||
|
* (DAVx5 pulling new/edited tasks) while Agendula isn't in the foreground. The
|
||||||
|
* manifest filter targets both known authorities; best-effort (the in-app
|
||||||
|
* ContentObserver covers the foreground case regardless).
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ProviderChangeReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var scheduler: ReminderScheduler
|
||||||
|
@Inject lateinit var providerResolver: ProviderResolver
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
// The receiver has to stay exported to hear the provider's broadcast, and
|
||||||
|
// the sender holds no permission we could require — so validate the
|
||||||
|
// broadcast itself. Without this, any installed app can spam a full
|
||||||
|
// re-sync (an unbounded provider read) by firing a matching intent.
|
||||||
|
if (intent.action != Intent.ACTION_PROVIDER_CHANGED) return
|
||||||
|
val authority = providerResolver.resolve()?.authority ?: return
|
||||||
|
if (intent.data?.host != authority) return
|
||||||
|
// External sync can fire these in bursts; one re-sync per burst is plenty.
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
synchronized(Companion) {
|
||||||
|
if (now - lastSyncAt < MIN_SYNC_INTERVAL_MS) return
|
||||||
|
lastSyncAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
val pending = goAsync()
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
scheduler.sync()
|
||||||
|
} finally {
|
||||||
|
pending.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MIN_SYNC_INTERVAL_MS = 10_000L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var lastSyncAt = -MIN_SYNC_INTERVAL_MS
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.reminders
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
|
||||||
|
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
|
||||||
|
* and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run
|
||||||
|
* on app start, on boot, on a store switch, and on an external provider change;
|
||||||
|
* it diffs against [ScheduledReminderStore] so only changed alarms move.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderScheduler @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val dataSource: TasksDataSource,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
private val store: ScheduledReminderStore,
|
||||||
|
private val providerResolver: ProviderResolver,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) {
|
||||||
|
private val syncLock = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff the armed alarms against the store and move only what changed.
|
||||||
|
*
|
||||||
|
* Serialised: the diff is a read-modify-write over [ScheduledReminderStore],
|
||||||
|
* and callers overlap (a store switch fires this while the launch sync may
|
||||||
|
* still be running). Two interleaved runs would each write their own set as
|
||||||
|
* the whole truth, leaving the other's alarms armed but unrecorded — never
|
||||||
|
* cancelled, and firing against the wrong store's task ids.
|
||||||
|
*/
|
||||||
|
suspend fun sync() = withContext(io) { syncLock.withLock { syncLocked() } }
|
||||||
|
|
||||||
|
private suspend fun syncLocked() {
|
||||||
|
val settings = settingsPrefs.settings.first()
|
||||||
|
// Gate on whether the store is readable, not on whether a provider
|
||||||
|
// resolves: our own store deliberately resolves to no provider, so the
|
||||||
|
// latter clears every reminder in the default mode.
|
||||||
|
if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
|
||||||
|
clearAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val horizon = now + WINDOW_MS
|
||||||
|
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
||||||
|
.getOrElse { return }
|
||||||
|
|
||||||
|
// One reminder per *occurrence*: a recurring series yields a row per
|
||||||
|
// occurrence, all sharing a taskId, so this is a Set rather than a
|
||||||
|
// taskId-keyed Map — keying by task would collapse a daily recurring task
|
||||||
|
// down to one arbitrary reminder.
|
||||||
|
// Per-task leads. One query for all of them.
|
||||||
|
val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() }
|
||||||
|
|
||||||
|
val desired = tasks
|
||||||
|
.filter { !it.isClosed && it.due != null }
|
||||||
|
.mapNotNull { task ->
|
||||||
|
// A reminder set on the task itself wins; otherwise the task's list
|
||||||
|
// may override the global lead, or opt out entirely (override =
|
||||||
|
// null), in which case it gets no reminder at all.
|
||||||
|
val reminder = perTask[task.taskId]
|
||||||
|
val lead = reminder?.minutesBefore
|
||||||
|
?: settings.reminderLeadFor(task.listId)
|
||||||
|
?: return@mapNotNull null
|
||||||
|
// A stored reminder says what it counts back from. Ours are always
|
||||||
|
// before due, but an imported dmfs alarm or another client's can be
|
||||||
|
// before *start* — firing those off the due date is silently wrong
|
||||||
|
// for every task whose start and due differ.
|
||||||
|
val anchor = if (reminder?.fromStart == true) task.start ?: task.due!! else task.due!!
|
||||||
|
ScheduledReminder(
|
||||||
|
taskId = task.taskId,
|
||||||
|
triggerAt = anchor.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The lower bound trails `now` so a reminder missed while the device was
|
||||||
|
// off still fires once on boot instead of being silently dropped —
|
||||||
|
// setExactAndAllowWhileIdle delivers a past trigger immediately. Anything
|
||||||
|
// already armed stays armed (the diff below), so it can't re-fire.
|
||||||
|
.filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
val previous = store.all()
|
||||||
|
(previous - desired).forEach { cancel(it) }
|
||||||
|
(desired - previous).forEach { schedule(it) }
|
||||||
|
store.replace(desired)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
|
||||||
|
|
||||||
|
private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? {
|
||||||
|
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
return PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
reminder.requestCode,
|
||||||
|
DueReminderReceiver.intent(context, reminder.taskId, reminder.triggerAt),
|
||||||
|
flags,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun schedule(reminder: ScheduledReminder) {
|
||||||
|
val triggerAt = reminder.triggerAt
|
||||||
|
val pi = pendingIntent(reminder, create = true) ?: return
|
||||||
|
val am = alarmManager()
|
||||||
|
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||||
|
if (canExact) {
|
||||||
|
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
|
||||||
|
} else {
|
||||||
|
am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancel(reminder: ScheduledReminder) {
|
||||||
|
pendingIntent(reminder, create = false)?.let {
|
||||||
|
alarmManager().cancel(it)
|
||||||
|
it.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun clearAll() {
|
||||||
|
store.all().forEach { cancel(it) }
|
||||||
|
store.replace(emptySet())
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
|
||||||
|
/** How long after its trigger a missed reminder is still worth firing. */
|
||||||
|
const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.reminders
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One armed alarm. A recurring task has many occurrences sharing a [taskId], so
|
||||||
|
* the trigger time is part of the identity — keying by task alone would collapse
|
||||||
|
* a daily task down to a single reminder.
|
||||||
|
*/
|
||||||
|
data class ScheduledReminder(val taskId: Long, val triggerAt: Long) {
|
||||||
|
/**
|
||||||
|
* Request code for this alarm's PendingIntent. Derived from both fields so
|
||||||
|
* sibling occurrences don't share (and overwrite) one alarm slot.
|
||||||
|
*/
|
||||||
|
val requestCode: Int get() = (taskId * 31 + triggerAt).hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers which task reminders are currently armed, so [ReminderScheduler] can
|
||||||
|
* diff against a fresh computation and touch only the alarms that changed.
|
||||||
|
* Persisted in DataStore as a set of `taskId|trigger` strings.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ScheduledReminderStore @Inject constructor(
|
||||||
|
private val dataStore: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
suspend fun all(): Set<ScheduledReminder> =
|
||||||
|
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
||||||
|
val parts = entry.split('|')
|
||||||
|
val id = parts.getOrNull(0)?.toLongOrNull()
|
||||||
|
val at = parts.getOrNull(1)?.toLongOrNull()
|
||||||
|
if (id != null && at != null) ScheduledReminder(id, at) else null
|
||||||
|
}.toSet()
|
||||||
|
|
||||||
|
suspend fun replace(scheduled: Set<ScheduledReminder>) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val KEY = stringSetPreferencesKey("scheduled_reminders")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.data.reminders
|
package de.jeanlucmakiola.agendula.data.reminders
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -12,9 +12,9 @@ import android.os.Build
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import de.jeanlucmakiola.floret.MainActivity
|
import de.jeanlucmakiola.agendula.MainActivity
|
||||||
import de.jeanlucmakiola.floret.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.floret.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.time.Instant as JInstant
|
import java.time.Instant as JInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.ContentObserver
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only class that knows about the ContentResolver, [TasksContract] and the
|
||||||
|
* active authority. Everything else works through [TasksDataSource] / domain
|
||||||
|
* models, so swapping the provider (Posture B) never reaches above this file.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AndroidTasksDataSource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val providerResolver: ProviderResolver,
|
||||||
|
) : TasksDataSource {
|
||||||
|
|
||||||
|
private val resolver: ContentResolver get() = context.contentResolver
|
||||||
|
|
||||||
|
private fun authority(): String =
|
||||||
|
providerResolver.resolve()?.authority ?: throw ProviderUnavailableException()
|
||||||
|
|
||||||
|
private fun taskUri(authority: String, taskId: Long): Uri =
|
||||||
|
ContentUris.withAppendedId(TasksContract.tasksUri(authority), taskId)
|
||||||
|
|
||||||
|
// --- reads ----------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun taskLists(): List<TaskList> {
|
||||||
|
val uri = TasksContract.listsUri(authority())
|
||||||
|
val sort = "${Lists.ACCOUNT_NAME}, ${Lists.NAME}"
|
||||||
|
return resolver.query(uri, TaskProjections.LISTS, null, null, sort)?.use { c ->
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
buildList { while (c.moveToNext()) add(TaskMapper.taskList(reader)) }
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun tasks(query: TaskQuery): List<Task> {
|
||||||
|
val clauses = mutableListOf<String>()
|
||||||
|
val args = mutableListOf<String>()
|
||||||
|
query.listId?.let {
|
||||||
|
clauses += "${Tasks.LIST_ID} = ?"
|
||||||
|
args += it.toString()
|
||||||
|
}
|
||||||
|
if (!query.includeCompleted) clauses += "${Tasks.IS_CLOSED} = 0"
|
||||||
|
val selection = clauses.takeIf { it.isNotEmpty() }?.joinToString(" AND ")
|
||||||
|
return queryInstances(selection, args.takeIf { it.isNotEmpty() }?.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun task(taskId: Long): Task? {
|
||||||
|
val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
|
||||||
|
return rows.firstOrNull { it.distanceFromCurrent == 0 } ?: rows.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun subtasks(parentTaskId: Long): List<Task> =
|
||||||
|
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
|
||||||
|
|
||||||
|
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
|
||||||
|
val uri = TasksContract.instancesUri(authority())
|
||||||
|
// projection = null (all columns): the instances view differs across
|
||||||
|
// provider versions (e.g. tasks.org's bundled OpenTasks has no
|
||||||
|
// `is_recurring` / `distance_from_current`). The by-name mapper reads
|
||||||
|
// whatever is present and nulls the rest; the repository re-sorts, so we
|
||||||
|
// don't depend on a provider sort column either.
|
||||||
|
return resolver.query(uri, null, selection, args, null)
|
||||||
|
?.use { c ->
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||||
|
// projection = null for the same reason queryInstances uses it: the tasks
|
||||||
|
// table's shape varies across provider versions, and the by-name mapper
|
||||||
|
// reads what's there.
|
||||||
|
val uri = TasksContract.tasksUri(authority())
|
||||||
|
return resolver.query(
|
||||||
|
uri,
|
||||||
|
null,
|
||||||
|
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
|
||||||
|
// the user is concerned, so exporting it would resurrect deleted tasks
|
||||||
|
// in the backup.
|
||||||
|
"${Tasks.LIST_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
|
||||||
|
arrayOf(listId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun insertTask(form: TaskForm): Long {
|
||||||
|
val values = TaskWriteMapper.taskValues(form, ZoneId.systemDefault().id)
|
||||||
|
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
|
||||||
|
?: throw TaskWriteFailedException("insert task")
|
||||||
|
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateTask(taskId: Long, form: TaskForm) {
|
||||||
|
val values = TaskWriteMapper.taskValues(form, ZoneId.systemDefault().id)
|
||||||
|
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||||
|
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||||
|
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
||||||
|
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
||||||
|
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||||
|
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||||
|
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||||
|
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider's instance row id for one occurrence.
|
||||||
|
*
|
||||||
|
* The seam addresses occurrences by `(taskId, occurrenceStart)`; writing
|
||||||
|
* through the instances URI still needs the row id, so it is looked up here
|
||||||
|
* rather than carried around above the data layer. Selection is on `task_id`
|
||||||
|
* only — the anchor is matched in Kotlin because the column that holds it
|
||||||
|
* (`instance_original_time`) is missing on older provider schemas, where a
|
||||||
|
* WHERE clause naming it would throw instead of falling back.
|
||||||
|
*/
|
||||||
|
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? {
|
||||||
|
val uri = TasksContract.instancesUri(authority())
|
||||||
|
val selection = "${Instances.TASK_ID} = ?"
|
||||||
|
return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c ->
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) {
|
||||||
|
return@use reader.getLong(Tasks.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
|
||||||
|
val uri = TasksContract.propertiesUri(authority())
|
||||||
|
// Replace rather than update: the provider's AlarmHandler re-validates the
|
||||||
|
// whole row on every update, so a partial edit throws — and delete+insert
|
||||||
|
// means we never have to track property_id.
|
||||||
|
resolver.delete(
|
||||||
|
uri,
|
||||||
|
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
|
||||||
|
arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE),
|
||||||
|
)
|
||||||
|
if (minutesBeforeDue != null) {
|
||||||
|
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
|
||||||
|
?: throw TaskWriteFailedException("set alarm for task $taskId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun alarms(): Map<Long, TaskReminder> {
|
||||||
|
val uri = TasksContract.propertiesUri(authority())
|
||||||
|
val projection = arrayOf(
|
||||||
|
Properties.TASK_ID,
|
||||||
|
TasksContract.Alarm.MINUTES_BEFORE,
|
||||||
|
TasksContract.Alarm.REFERENCE,
|
||||||
|
)
|
||||||
|
return resolver.query(
|
||||||
|
uri,
|
||||||
|
projection,
|
||||||
|
"${Properties.MIMETYPE} = ?",
|
||||||
|
arrayOf(TasksContract.Alarm.MIMETYPE),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
buildMap {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val id = reader.getLong(Properties.TASK_ID)
|
||||||
|
val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE)
|
||||||
|
val reference = reader.getInt(TasksContract.Alarm.REFERENCE)
|
||||||
|
if (id != null && minutes != null) {
|
||||||
|
put(
|
||||||
|
id,
|
||||||
|
TaskReminder(
|
||||||
|
minutesBefore = minutes,
|
||||||
|
fromStart = reference == TasksContract.Alarm.REFERENCE_START,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||||
|
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||||
|
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||||
|
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Through the instances URI, which is what makes the provider fork an override
|
||||||
|
* rather than close the series. No instance row for the anchor means the task
|
||||||
|
* is not a series after all — the plain write is then the right one.
|
||||||
|
*/
|
||||||
|
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
|
||||||
|
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
||||||
|
?: return setCompleted(taskId, completed)
|
||||||
|
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||||
|
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||||
|
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||||
|
if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteTask(taskId: Long) {
|
||||||
|
resolver.delete(taskUri(authority(), taskId), null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createLocalList(name: String, color: Int): Long {
|
||||||
|
val uri = TasksContract.asSyncAdapter(
|
||||||
|
TasksContract.listsUri(authority()),
|
||||||
|
TasksContract.LOCAL_ACCOUNT_NAME,
|
||||||
|
TasksContract.LOCAL_ACCOUNT_TYPE,
|
||||||
|
)
|
||||||
|
val values = TaskWriteMapper.localListValues(name, color)
|
||||||
|
val result = resolver.insert(uri, values.toContentValues())
|
||||||
|
?: throw TaskWriteFailedException("create local list")
|
||||||
|
return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateList(listId: Long, name: String, color: Int) {
|
||||||
|
val values = TaskWriteMapper.listValues(name, color)
|
||||||
|
val rows = resolver.update(listSyncUri(listId), values.toContentValues(), null, null)
|
||||||
|
if (rows == 0) throw TaskWriteFailedException("update list $listId")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteList(listId: Long) {
|
||||||
|
val rows = resolver.delete(listSyncUri(listId), null, null)
|
||||||
|
if (rows == 0) throw TaskWriteFailedException("delete list $listId")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list row addressed as its own account's sync adapter — the provider only
|
||||||
|
* lets that caller write the `tasklists` table, and the account has to be the
|
||||||
|
* row's own (the params are matched against it, not merely accepted).
|
||||||
|
*/
|
||||||
|
private fun listSyncUri(listId: Long): Uri {
|
||||||
|
val authority = authority()
|
||||||
|
val uri = TasksContract.listUri(authority, listId)
|
||||||
|
val account = resolver.query(uri, arrayOf(Lists.ACCOUNT_NAME, Lists.ACCOUNT_TYPE), null, null, null)
|
||||||
|
?.use { c -> if (c.moveToFirst()) c.getString(0).orEmpty() to c.getString(1).orEmpty() else null }
|
||||||
|
?: throw TaskWriteFailedException("list $listId not found")
|
||||||
|
return TasksContract.asSyncAdapter(uri, account.first, account.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- observation ----------------------------------------------------------
|
||||||
|
|
||||||
|
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||||
|
val provider = providerResolver.resolve() ?: return AutoCloseable { }
|
||||||
|
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||||
|
override fun onChange(selfChange: Boolean) = onChange()
|
||||||
|
}
|
||||||
|
// Register both or neither: if the second call throws, the first
|
||||||
|
// registration would otherwise leak (no AutoCloseable was handed back yet).
|
||||||
|
try {
|
||||||
|
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
|
||||||
|
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
runCatching { resolver.unregisterContentObserver(observer) }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Map<String, Any?>.toContentValues(): ContentValues {
|
||||||
|
val cv = ContentValues(size)
|
||||||
|
for ((key, value) in this) when (value) {
|
||||||
|
null -> cv.putNull(key)
|
||||||
|
is Long -> cv.put(key, value)
|
||||||
|
is Int -> cv.put(key, value)
|
||||||
|
is Boolean -> cv.put(key, if (value) 1 else 0)
|
||||||
|
is String -> cv.put(key, value)
|
||||||
|
else -> cv.put(key, value.toString())
|
||||||
|
}
|
||||||
|
return cv
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
/** No tasks provider (OpenTasks / tasks.org) is installed on the device. */
|
/** No tasks provider (OpenTasks / tasks.org) is installed on the device. */
|
||||||
class ProviderUnavailableException :
|
class ProviderUnavailableException :
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
|
import javax.inject.Provider
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes every call to the store [StorageMode] selects.
|
||||||
|
*
|
||||||
|
* Per-call rather than bound once: the mode is a setting the user can change
|
||||||
|
* while the process lives, and [StorageModeHolder] pushes the new value into
|
||||||
|
* [ProviderResolver] without rebuilding the object graph. Both delegates are
|
||||||
|
* singletons, so this chooses between two existing objects.
|
||||||
|
*
|
||||||
|
* Room versus a third-party ContentProvider — nothing else.
|
||||||
|
*/
|
||||||
|
class ModeRoutingTasksDataSource(
|
||||||
|
private val resolver: ProviderResolver,
|
||||||
|
private val room: Provider<RoomTasksDataSource>,
|
||||||
|
private val external: Provider<AndroidTasksDataSource>,
|
||||||
|
) : TasksDataSource {
|
||||||
|
|
||||||
|
private fun active(): TasksDataSource =
|
||||||
|
when (resolver.mode()) {
|
||||||
|
StorageMode.OWN -> room.get()
|
||||||
|
StorageMode.EXTERNAL -> external.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun taskLists(): List<TaskList> = active().taskLists()
|
||||||
|
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
|
||||||
|
override fun task(taskId: Long): Task? = active().task(taskId)
|
||||||
|
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
|
||||||
|
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
|
||||||
|
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
|
||||||
|
|
||||||
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) =
|
||||||
|
active().updateInstance(taskId, occurrenceStart, form)
|
||||||
|
|
||||||
|
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
|
||||||
|
override fun alarms(): Map<Long, TaskReminder> = active().alarms()
|
||||||
|
override fun exportTasks(listId: Long): List<ExportTask> = active().exportTasks(listId)
|
||||||
|
override fun setCompleted(taskId: Long, completed: Boolean) = active().setCompleted(taskId, completed)
|
||||||
|
|
||||||
|
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) =
|
||||||
|
active().setCompletedInstance(taskId, occurrenceStart, completed)
|
||||||
|
|
||||||
|
override fun deleteTask(taskId: Long) = active().deleteTask(taskId)
|
||||||
|
override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color)
|
||||||
|
override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color)
|
||||||
|
override fun deleteList(listId: Long) = active().deleteList(listId)
|
||||||
|
/**
|
||||||
|
* Unlike every other method here, an observer is registered once and then
|
||||||
|
* *held* — so it cannot be routed per call, and would otherwise stay bound to
|
||||||
|
* whichever store was active when the flow started. Switching stores in
|
||||||
|
* Settings would then leave every open screen listening to the store it is no
|
||||||
|
* longer reading from.
|
||||||
|
*
|
||||||
|
* So the registration moves with the mode, and the switch itself counts as a
|
||||||
|
* change: the data underneath every live flow has just been replaced.
|
||||||
|
*/
|
||||||
|
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||||
|
val lock = Any()
|
||||||
|
var closed = false
|
||||||
|
var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull()
|
||||||
|
|
||||||
|
val modeHandle = resolver.onModeChanged {
|
||||||
|
synchronized(lock) {
|
||||||
|
if (!closed) {
|
||||||
|
handle?.let { runCatching { it.close() } }
|
||||||
|
handle = runCatching { active().registerObserver(onChange) }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange()
|
||||||
|
}
|
||||||
|
return AutoCloseable {
|
||||||
|
synchronized(lock) {
|
||||||
|
closed = true
|
||||||
|
modeHandle.close()
|
||||||
|
handle?.let { runCatching { it.close() } }
|
||||||
|
handle = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two platform facts [ProviderResolver] needs, behind an interface.
|
||||||
|
*
|
||||||
|
* Same seam the data source uses, for the same reason: which store a returning
|
||||||
|
* user lands on is decided by [ProviderResolver.autoMode], getting it wrong shows
|
||||||
|
* them an empty app, and that decision is worth testing on the JVM rather than
|
||||||
|
* only on a device. Everything Android-shaped lives here so the logic above stays
|
||||||
|
* plain Kotlin.
|
||||||
|
*/
|
||||||
|
interface ProviderEnvironment {
|
||||||
|
|
||||||
|
/** The package declaring [authority], or `null` when nothing on the device does. */
|
||||||
|
fun packageDeclaring(authority: String): String?
|
||||||
|
|
||||||
|
/** Whether this app currently holds [permission]. */
|
||||||
|
fun isGranted(permission: String): Boolean
|
||||||
|
|
||||||
|
/** [packageName]'s own app name, or null when it cannot be read. */
|
||||||
|
fun appLabel(packageName: String): String?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AndroidProviderEnvironment @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ProviderEnvironment {
|
||||||
|
|
||||||
|
override fun packageDeclaring(authority: String): String? =
|
||||||
|
context.packageManager.resolveContentProvider(authority, 0)?.packageName
|
||||||
|
|
||||||
|
override fun isGranted(permission: String): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
override fun appLabel(packageName: String): String? = runCatching {
|
||||||
|
val pm = context.packageManager
|
||||||
|
pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.retryWhen
|
||||||
|
|
||||||
|
private const val BASE_RETRY_MS = 1_000L
|
||||||
|
private const val MAX_RETRY_MS = 30_000L
|
||||||
|
|
||||||
|
/** 1s, 2s, 4s … capped at 30s, so a permanently-absent provider costs little. */
|
||||||
|
private fun retryDelayMs(attempt: Long): Long =
|
||||||
|
(BASE_RETRY_MS shl attempt.coerceAtMost(5).toInt()).coerceAtMost(MAX_RETRY_MS)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover a provider-backed flow without killing it.
|
||||||
|
*
|
||||||
|
* Provider reads fail for reasons that resolve on their own: the read permission
|
||||||
|
* isn't granted yet (first launch collects before the permission gate), or the
|
||||||
|
* provider app is mid-update. A terminal `catch` swallows the failure *and*
|
||||||
|
* cancels the upstream, so the flow never produces again — the screen stays empty
|
||||||
|
* until the process restarts, even after the user grants the permission.
|
||||||
|
*
|
||||||
|
* This emits [fallback] instead and keeps retrying with a capped backoff, so the
|
||||||
|
* collector recovers on its own once the provider becomes readable.
|
||||||
|
*/
|
||||||
|
fun <T> Flow<T>.recoveringFromProviderFailure(fallback: () -> T): Flow<T> =
|
||||||
|
retryWhen { _, attempt ->
|
||||||
|
emit(fallback())
|
||||||
|
delay(retryDelayMs(attempt))
|
||||||
|
true
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An external tasks provider Agendula can talk to. Every candidate runs the same
|
||||||
|
* dmfs `TaskProvider`, so the [TasksContract] columns apply to either.
|
||||||
|
*/
|
||||||
|
data class TaskProvider(
|
||||||
|
val authority: String,
|
||||||
|
val readPermission: String,
|
||||||
|
val writePermission: String,
|
||||||
|
val packageName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovers the *external* tasks providers (OpenTasks, tasks.org) that
|
||||||
|
* [StorageMode.EXTERNAL] can be pointed at.
|
||||||
|
*
|
||||||
|
* This used to be the A/B seam between an external provider and one Agendula
|
||||||
|
* bundled itself. That second half is gone: [StorageMode.OWN] is a Room database
|
||||||
|
* with no authority, no ContentResolver and nothing to permit, so there is
|
||||||
|
* nothing here for it to resolve. See `docs/OWN-STORE.md`.
|
||||||
|
*
|
||||||
|
* Which store is active comes from [storageMode]; how that gets decided when the
|
||||||
|
* user has not chosen is [autoMode].
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ProviderResolver @Inject constructor(
|
||||||
|
private val environment: ProviderEnvironment,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val modeListeners = CopyOnWriteArrayList<() -> Unit>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's explicit choice, or `null` while they have not made one (which is
|
||||||
|
* the normal state — most people never open Settings). Kept as a plain field
|
||||||
|
* rather than read from DataStore on demand because [resolve] is called from
|
||||||
|
* synchronous data-source code on every query, including from the main thread
|
||||||
|
* via `providerStatus()`. [StorageModeHolder] owns keeping it current.
|
||||||
|
*
|
||||||
|
* Assigning a *different* mode notifies [onModeChanged]: every live store
|
||||||
|
* observer is bound to one store and has to be moved across.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var storageMode: StorageMode? = null
|
||||||
|
set(value) {
|
||||||
|
val changed = field != value
|
||||||
|
field = value
|
||||||
|
if (changed) modeListeners.forEach { it() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe switches between stores. Fires on the thread that set [storageMode]
|
||||||
|
* — [StorageModeHolder]'s collector — so listeners must be cheap and must not
|
||||||
|
* block.
|
||||||
|
*/
|
||||||
|
fun onModeChanged(listener: () -> Unit): AutoCloseable {
|
||||||
|
modeListeners += listener
|
||||||
|
return AutoCloseable { modeListeners -= listener }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active store, resolving the undecided case through [autoMode]. */
|
||||||
|
fun mode(): StorageMode = storageMode ?: autoMode()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider to query, or `null` — either because [StorageMode.OWN] is
|
||||||
|
* active and there is no provider involved at all, or because
|
||||||
|
* [StorageMode.EXTERNAL] is and none is installed. Callers that need to tell
|
||||||
|
* those apart ask [mode].
|
||||||
|
*/
|
||||||
|
fun resolve(): TaskProvider? = when (mode()) {
|
||||||
|
StorageMode.OWN -> null
|
||||||
|
StorageMode.EXTERNAL -> resolveExternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to use when the user has not chosen — and the one piece of real
|
||||||
|
* judgement in this class, because getting it wrong loses people their data.
|
||||||
|
*
|
||||||
|
* Ranking our own store first unconditionally would be wrong: someone who
|
||||||
|
* has been using Agendula over OpenTasks since 0.3.x would update, land on an
|
||||||
|
* empty database, and reasonably conclude their tasks were deleted.
|
||||||
|
*
|
||||||
|
* So the tell is **whether we already hold an external provider's runtime
|
||||||
|
* permission**. That is a dangerous permission — it can only be there because
|
||||||
|
* a previous version asked and the user agreed, which is precisely the
|
||||||
|
* definition of "this person is an existing Posture A user". A fresh install
|
||||||
|
* never holds it, and gets our own store.
|
||||||
|
*
|
||||||
|
* Deliberately cheap and synchronous: a PackageManager lookup and a permission
|
||||||
|
* check, no database probe. Settings overrides it either way.
|
||||||
|
*/
|
||||||
|
fun autoMode(): StorageMode {
|
||||||
|
val external = resolveExternal()
|
||||||
|
return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.OWN
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first installed external candidate, or `null` when none is present. */
|
||||||
|
fun resolveExternal(): TaskProvider? {
|
||||||
|
for (candidate in EXTERNAL_CANDIDATES) {
|
||||||
|
val packageName = environment.packageDeclaring(candidate.authority) ?: continue
|
||||||
|
return candidate.copy(packageName = packageName)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasPermission(provider: TaskProvider): Boolean =
|
||||||
|
environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the active store can be read at all.
|
||||||
|
*
|
||||||
|
* [StorageMode.OWN] always can — it is our own database, with nothing to
|
||||||
|
* install and nothing to grant. Only [StorageMode.EXTERNAL] can be
|
||||||
|
* unreadable. Callers that gate on `resolve() != null` instead get this wrong
|
||||||
|
* the moment OWN is active, because OWN resolves to no provider by design.
|
||||||
|
*/
|
||||||
|
fun canReadStore(): Boolean = when (mode()) {
|
||||||
|
StorageMode.OWN -> true
|
||||||
|
StorageMode.EXTERNAL -> resolveExternal()?.let(::hasPermission) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
|
||||||
|
* `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*`
|
||||||
|
* (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`.
|
||||||
|
* OpenTasks is listed first as the canonical authority; on a device with
|
||||||
|
* only one installed, order is moot.
|
||||||
|
*/
|
||||||
|
val EXTERNAL_CANDIDATES: List<TaskProvider> = listOf(
|
||||||
|
TaskProvider(
|
||||||
|
authority = "org.dmfs.tasks",
|
||||||
|
readPermission = "org.dmfs.permission.READ_TASKS",
|
||||||
|
writePermission = "org.dmfs.permission.WRITE_TASKS",
|
||||||
|
),
|
||||||
|
TaskProvider(
|
||||||
|
authority = "org.tasks.opentasks",
|
||||||
|
readPermission = "org.tasks.permission.READ_TASKS",
|
||||||
|
writePermission = "org.tasks.permission.WRITE_TASKS",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.legacy.OneShotImport
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The work that has to finish before anything reads a task store: the stored
|
||||||
|
* [StorageMode] has to reach [ProviderResolver], and a v0.3.x install's tasks
|
||||||
|
* have to be imported out of the dmfs provider's file into Room.
|
||||||
|
*
|
||||||
|
* Both are startup races with the same shape. Reading before the mode lands
|
||||||
|
* answers from `autoMode()` instead of the user's choice; reading before the
|
||||||
|
* import lands shows an upgrading user an empty app, which is the single worst
|
||||||
|
* thing this migration could do.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class StartupGate @Inject constructor(
|
||||||
|
private val storageModeHolder: StorageModeHolder,
|
||||||
|
private val oneShotImport: OneShotImport,
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val ready = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
/** Call once, from `Application.onCreate`. */
|
||||||
|
fun start() {
|
||||||
|
storageModeHolder.start()
|
||||||
|
scope.launch {
|
||||||
|
// Opens the gate even on failure: a store that cannot be imported is
|
||||||
|
// still better shown empty than not shown at all, and the source file
|
||||||
|
// is left where it was either way.
|
||||||
|
runCatching {
|
||||||
|
storageModeHolder.awaitReady()
|
||||||
|
oneShotImport.runIfNeeded()
|
||||||
|
}
|
||||||
|
ready.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun awaitReady() = ready.await()
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which task store backs the app — the user's choice, per `docs/OWN-STORE.md`.
|
||||||
|
*
|
||||||
|
* Only two values, though `docs/STORAGE-AND-SYNC.md` describes three modes.
|
||||||
|
* **Synced is not a third store**: it is [OWN] with an account attached to a
|
||||||
|
* list, which is derived state rather than something the user picks. Attaching
|
||||||
|
* one is a plain `UPDATE task_lists SET account_id = ?` — not the full data
|
||||||
|
* migration it was under the dmfs provider, whose `ACCOUNT_TYPE` was write-once.
|
||||||
|
*/
|
||||||
|
enum class StorageMode {
|
||||||
|
/**
|
||||||
|
* Agendula's own Room database. The default, and always available: there is
|
||||||
|
* no authority, no ContentResolver and no permission to grant.
|
||||||
|
*/
|
||||||
|
OWN,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tasks provider app already on the device (OpenTasks, tasks.org), synced by
|
||||||
|
* whatever that provider's engine is — DAVx5 and friends. Still fully
|
||||||
|
* supported, now a choice rather than the only way. Requires that provider's
|
||||||
|
* runtime read/write permissions.
|
||||||
|
*/
|
||||||
|
EXTERNAL,
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the stored [StorageMode] into [ProviderResolver].
|
||||||
|
*
|
||||||
|
* The resolver is consulted synchronously from every data-source call and from
|
||||||
|
* `providerStatus()` on the main thread, so it cannot read DataStore itself.
|
||||||
|
* This is the one component that bridges the two: it collects the preference for
|
||||||
|
* the life of the process and pushes each value across.
|
||||||
|
*
|
||||||
|
* [awaitReady] exists for the startup race. Until the first DataStore emission
|
||||||
|
* arrives the resolver's mode is `null` and `ProviderResolver.autoMode` answers
|
||||||
|
* instead — fine as a steady state, wrong for a user who explicitly chose the
|
||||||
|
* other mode. Anything that touches the provider before the UI is up (the launch
|
||||||
|
* reminder re-sync, notably) should wait rather than risk reading the wrong
|
||||||
|
* store and rescheduling every alarm off it.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class StorageModeHolder @Inject constructor(
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
private val resolver: ProviderResolver,
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val firstValue = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
/** Starts mirroring. Idempotent in effect; call once, from `Application.onCreate`. */
|
||||||
|
fun start() {
|
||||||
|
scope.launch {
|
||||||
|
prefs.storageMode.collect { mode ->
|
||||||
|
resolver.storageMode = mode
|
||||||
|
firstValue.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suspends until the stored mode has been applied at least once. */
|
||||||
|
suspend fun awaitReady() = firstValue.await()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
|
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
||||||
|
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */
|
||||||
|
object TaskMapper {
|
||||||
|
|
||||||
|
fun task(r: ColumnReader): Task {
|
||||||
|
fun instant(name: String): Instant? =
|
||||||
|
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
|
||||||
|
|
||||||
|
val rowId = r.getLong(Tasks.ID) ?: 0L
|
||||||
|
// Derived from the rule columns rather than the `is_recurring` column
|
||||||
|
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
|
||||||
|
// absent on tasks.org's bundled provider (DB 22), where reading it
|
||||||
|
// would silently report every recurring task as one-off — and route
|
||||||
|
// its edits onto the series anchor.
|
||||||
|
val recurring = r.getString(Tasks.RRULE) != null ||
|
||||||
|
r.getString(Tasks.RDATE) != null ||
|
||||||
|
r.getBoolean(Instances.IS_RECURRING)
|
||||||
|
return Task(
|
||||||
|
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
|
||||||
|
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
|
||||||
|
title = r.getString(Tasks.TITLE).orEmpty(),
|
||||||
|
description = r.getString(Tasks.DESCRIPTION),
|
||||||
|
location = r.getString(Tasks.LOCATION),
|
||||||
|
url = r.getString(Tasks.URL),
|
||||||
|
priority = priorityFromICal(r.getInt(Tasks.PRIORITY)),
|
||||||
|
status = statusFromInt(r.getInt(Tasks.STATUS)),
|
||||||
|
percentComplete = r.getInt(Tasks.PERCENT_COMPLETE),
|
||||||
|
start = instant(Instances.INSTANCE_START),
|
||||||
|
due = instant(Instances.INSTANCE_DUE),
|
||||||
|
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
|
||||||
|
timeZone = r.getString(Tasks.TZ),
|
||||||
|
completedAt = instant(Tasks.COMPLETED),
|
||||||
|
listColor = r.getInt(Tasks.LIST_COLOR) ?: 0,
|
||||||
|
taskColor = r.getInt(Tasks.TASK_COLOR),
|
||||||
|
listName = r.getString(Tasks.LIST_NAME),
|
||||||
|
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
||||||
|
parentId = r.getLong(Tasks.PARENT_ID),
|
||||||
|
isRecurring = recurring,
|
||||||
|
occurrenceStart = if (recurring) occurrenceAnchor(r) else null,
|
||||||
|
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
||||||
|
created = instant(Tasks.CREATED),
|
||||||
|
lastModified = instant(Tasks.LAST_MODIFIED),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The occurrence's `RECURRENCE-ID` anchor.
|
||||||
|
*
|
||||||
|
* `instance_original_time` is the provider's own name for it and is set on
|
||||||
|
* every occurrence of a recurring task, so it is read first. It is absent on
|
||||||
|
* older provider schemas, where the fallbacks reconstruct the same value: a
|
||||||
|
* DTSTART-anchored series instantiates each occurrence at its start, and a
|
||||||
|
* series carrying only DUE anchors on the due date instead.
|
||||||
|
*/
|
||||||
|
fun occurrenceAnchor(r: ColumnReader): Instant? =
|
||||||
|
(
|
||||||
|
r.getLong(Instances.INSTANCE_ORIGINAL_TIME)
|
||||||
|
?: r.getLong(Instances.INSTANCE_START)
|
||||||
|
?: r.getLong(Instances.INSTANCE_DUE)
|
||||||
|
)?.let { Instant.fromEpochMilliseconds(it) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a row of the **`tasks` table** — a master task, not an occurrence.
|
||||||
|
*
|
||||||
|
* Export reads there rather than from `instances` on purpose: in the instances
|
||||||
|
* view a recurring task appears once per occurrence with its times already
|
||||||
|
* resolved and no rule attached, so exporting from it would write the same
|
||||||
|
* task many times over and drop the RRULE that produced them. Here each task
|
||||||
|
* appears exactly once, carrying the rule itself.
|
||||||
|
*/
|
||||||
|
fun exportTask(r: ColumnReader): ExportTask {
|
||||||
|
fun instant(name: String): Instant? =
|
||||||
|
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
|
||||||
|
|
||||||
|
return ExportTask(
|
||||||
|
taskId = r.getLong(Tasks.ID) ?: 0L,
|
||||||
|
uid = r.getString(Tasks.UID),
|
||||||
|
title = r.getString(Tasks.TITLE).orEmpty(),
|
||||||
|
description = r.getString(Tasks.DESCRIPTION),
|
||||||
|
location = r.getString(Tasks.LOCATION),
|
||||||
|
url = r.getString(Tasks.URL),
|
||||||
|
priority = priorityFromICal(r.getInt(Tasks.PRIORITY)),
|
||||||
|
status = statusFromInt(r.getInt(Tasks.STATUS)),
|
||||||
|
percentComplete = r.getInt(Tasks.PERCENT_COMPLETE),
|
||||||
|
// The task's own columns, not the instance view's resolved ones.
|
||||||
|
start = instant(Tasks.DTSTART),
|
||||||
|
due = instant(Tasks.DUE),
|
||||||
|
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
|
||||||
|
completedAt = instant(Tasks.COMPLETED),
|
||||||
|
created = instant(Tasks.CREATED),
|
||||||
|
lastModified = instant(Tasks.LAST_MODIFIED),
|
||||||
|
rrule = r.getString(Tasks.RRULE),
|
||||||
|
rdate = r.getString(Tasks.RDATE),
|
||||||
|
parentId = r.getLong(Tasks.PARENT_ID)?.takeIf { it > 0 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun taskList(r: ColumnReader): TaskList = TaskList(
|
||||||
|
id = r.getLong(Lists.ID) ?: 0L,
|
||||||
|
name = r.getString(Lists.NAME).orEmpty(),
|
||||||
|
color = r.getInt(Lists.COLOR) ?: 0,
|
||||||
|
accountName = r.getString(Lists.ACCOUNT_NAME).orEmpty(),
|
||||||
|
accountType = r.getString(Lists.ACCOUNT_TYPE).orEmpty(),
|
||||||
|
isSynced = r.getBoolean(Lists.SYNC_ENABLED),
|
||||||
|
isVisible = r.getBoolean(Lists.VISIBLE),
|
||||||
|
owner = r.getString(Lists.OWNER),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
|
||||||
|
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
||||||
|
object TaskProjections {
|
||||||
|
|
||||||
|
val LISTS: Array<String> = arrayOf(
|
||||||
|
Lists.ID,
|
||||||
|
Lists.NAME,
|
||||||
|
Lists.COLOR,
|
||||||
|
Lists.VISIBLE,
|
||||||
|
Lists.SYNC_ENABLED,
|
||||||
|
Lists.OWNER,
|
||||||
|
Lists.ACCOUNT_NAME,
|
||||||
|
Lists.ACCOUNT_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
// No `instances` projection on purpose: that read passes `projection = null`
|
||||||
|
// (all columns), because the view's shape differs across provider versions —
|
||||||
|
// tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list
|
||||||
|
// here would drift out of sync with the by-name mapper and quietly drop
|
||||||
|
// columns it depends on. See AndroidTasksDataSource.queryInstances.
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import de.jeanlucmakiola.agendula.domain.toICal
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
|
||||||
|
private fun Instant.forAllDay(allDay: Boolean): Instant =
|
||||||
|
if (!allDay) this
|
||||||
|
else Instant.fromEpochMilliseconds(
|
||||||
|
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
||||||
|
* so it unit-tests on the JVM; [AndroidTasksDataSource] converts the map to
|
||||||
|
* ContentValues. `null` values are written as SQL NULL (clears the column).
|
||||||
|
*/
|
||||||
|
object TaskWriteMapper {
|
||||||
|
|
||||||
|
fun taskValues(form: TaskForm, tzId: String): Map<String, Any?> = buildMap {
|
||||||
|
put(Tasks.TITLE, form.title.trim())
|
||||||
|
put(Tasks.LIST_ID, form.listId)
|
||||||
|
put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null })
|
||||||
|
put(Tasks.PRIORITY, form.priority.toICal())
|
||||||
|
// Keep completion in sync with progress whenever the form carries a
|
||||||
|
// percent (the Progress field was used). The provider already auto-
|
||||||
|
// completes at 100% but won't reopen below it — left to itself it strands
|
||||||
|
// a task "done at 75%" — so we set status both ways. COMPLETED is only
|
||||||
|
// cleared on reopen; at 100% we leave it for the provider to fill/keep so
|
||||||
|
// re-saving a finished task doesn't churn its completion timestamp. When
|
||||||
|
// no percent is present the standalone complete toggle stays authoritative.
|
||||||
|
val percent = form.percentComplete?.coerceIn(0, 100)
|
||||||
|
put(Tasks.PERCENT_COMPLETE, percent)
|
||||||
|
when {
|
||||||
|
percent == null -> Unit
|
||||||
|
percent >= 100 -> put(Tasks.STATUS, TasksContract.STATUS_COMPLETED)
|
||||||
|
percent > 0 -> {
|
||||||
|
put(Tasks.STATUS, TasksContract.STATUS_IN_PROCESS)
|
||||||
|
put(Tasks.COMPLETED, null)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
put(Tasks.STATUS, TasksContract.STATUS_NEEDS_ACTION)
|
||||||
|
put(Tasks.COMPLETED, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
|
||||||
|
// All-day tasks are date-only in iCalendar. The provider reads them back
|
||||||
|
// through DateTime.toAllDay(), which drops the time-of-day and resolves the
|
||||||
|
// remaining date against UTC — so a local-midnight instant lands on the
|
||||||
|
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
|
||||||
|
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
|
||||||
|
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
|
||||||
|
// DUE and DURATION are mutually exclusive. The provider's Validating
|
||||||
|
// processor evaluates the *merged* row (supplied values over the stored
|
||||||
|
// ones), so writing DUE onto a task that already carries a DURATION throws
|
||||||
|
// "Only one of DUE or DURATION must be supplied." Clear it alongside.
|
||||||
|
put(Tasks.DURATION, null)
|
||||||
|
put(Tasks.PARENT_ID, form.parentId)
|
||||||
|
// The provider treats a null tz as local time; set it explicitly for
|
||||||
|
// timed tasks so the stored instant is unambiguous across zones.
|
||||||
|
val timed = !form.isAllDay && (form.start != null || form.due != null)
|
||||||
|
put(Tasks.TZ, if (timed) tzId else null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values for an update through the *instances* URI (a recurring occurrence).
|
||||||
|
* The provider clones the row into an override and strips list/recurrence
|
||||||
|
* fields as it goes, so LIST_ID and PARENT_ID are dropped here rather than
|
||||||
|
* written and silently ignored — moving one occurrence between lists or
|
||||||
|
* parents isn't a thing the override model expresses.
|
||||||
|
*/
|
||||||
|
fun instanceValues(form: TaskForm, tzId: String): Map<String, Any?> =
|
||||||
|
taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID
|
||||||
|
|
||||||
|
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
|
||||||
|
if (completed) {
|
||||||
|
mapOf(
|
||||||
|
Tasks.STATUS to TasksContract.STATUS_COMPLETED,
|
||||||
|
Tasks.PERCENT_COMPLETE to 100,
|
||||||
|
Tasks.COMPLETED to nowMillis,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
mapOf(
|
||||||
|
Tasks.STATUS to TasksContract.STATUS_NEEDS_ACTION,
|
||||||
|
Tasks.PERCENT_COMPLETE to null,
|
||||||
|
Tasks.COMPLETED to null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reminder for [taskId], as an Alarm property row. The provider's validator
|
||||||
|
* requires MINUTES_BEFORE, REFERENCE (non-negative) and ALARM_TYPE on every
|
||||||
|
* write, so all three are always present.
|
||||||
|
*/
|
||||||
|
fun alarmValues(taskId: Long, minutesBeforeDue: Int): Map<String, Any?> = mapOf(
|
||||||
|
Properties.TASK_ID to taskId,
|
||||||
|
Properties.MIMETYPE to Alarm.MIMETYPE,
|
||||||
|
Alarm.MINUTES_BEFORE to minutesBeforeDue,
|
||||||
|
Alarm.REFERENCE to Alarm.REFERENCE_DUE,
|
||||||
|
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The user-owned columns of a list — what an edit is allowed to change. */
|
||||||
|
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
||||||
|
Lists.NAME to name.trim(),
|
||||||
|
Lists.COLOR to color,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun localListValues(name: String, color: Int): Map<String, Any?> = listValues(name, color) + mapOf(
|
||||||
|
Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
|
||||||
|
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
|
||||||
|
Lists.VISIBLE to 1,
|
||||||
|
Lists.SYNC_ENABLED to 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package de.jeanlucmakiola.floret.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The subset of the OpenTasks `TaskContract` that Floret uses, vendored as
|
* The subset of the OpenTasks `TaskContract` that Agendula uses, vendored as
|
||||||
* Kotlin constants. Derived from `org.dmfs.tasks.contract.TaskContract`
|
* Kotlin constants. Derived from `org.dmfs.tasks.contract.TaskContract`
|
||||||
* (Apache-2.0, dmfs GmbH):
|
* (Apache-2.0, dmfs GmbH):
|
||||||
* https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java
|
* https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java
|
||||||
@@ -25,7 +25,7 @@ object TasksContract {
|
|||||||
const val PARAM_ACCOUNT_TYPE = "account_type"
|
const val PARAM_ACCOUNT_TYPE = "account_type"
|
||||||
const val LOAD_PROPERTIES = "load_properties"
|
const val LOAD_PROPERTIES = "load_properties"
|
||||||
|
|
||||||
/** Account used for local, device-only task lists Floret owns. */
|
/** Account used for local, device-only task lists Agendula owns. */
|
||||||
const val LOCAL_ACCOUNT_NAME = "Local"
|
const val LOCAL_ACCOUNT_NAME = "Local"
|
||||||
const val LOCAL_ACCOUNT_TYPE = "org.dmfs.account.LOCAL"
|
const val LOCAL_ACCOUNT_TYPE = "org.dmfs.account.LOCAL"
|
||||||
|
|
||||||
@@ -66,6 +66,9 @@ object TasksContract {
|
|||||||
const val IS_ALLDAY = "is_allday"
|
const val IS_ALLDAY = "is_allday"
|
||||||
const val TZ = "tz"
|
const val TZ = "tz"
|
||||||
const val RRULE = "rrule"
|
const val RRULE = "rrule"
|
||||||
|
const val RDATE = "rdate"
|
||||||
|
/** Set on an override row — the master occurrence this one replaces. */
|
||||||
|
const val ORIGINAL_INSTANCE_ID = "original_instance_id"
|
||||||
const val PARENT_ID = "parent_id"
|
const val PARENT_ID = "parent_id"
|
||||||
const val SORTING = "sorting"
|
const val SORTING = "sorting"
|
||||||
const val CREATED = "created"
|
const val CREATED = "created"
|
||||||
@@ -85,7 +88,7 @@ object TasksContract {
|
|||||||
* The `instances` view — one row per task occurrence, with recurrence
|
* The `instances` view — one row per task occurrence, with recurrence
|
||||||
* resolved and absolute [INSTANCE_START]/[INSTANCE_DUE] timestamps. Inherits
|
* resolved and absolute [INSTANCE_START]/[INSTANCE_DUE] timestamps. Inherits
|
||||||
* every [Tasks] column (title, status, …) plus the instance-only columns
|
* every [Tasks] column (title, status, …) plus the instance-only columns
|
||||||
* below. This is Floret's read source.
|
* below. This is Agendula's read source.
|
||||||
*/
|
*/
|
||||||
object Instances {
|
object Instances {
|
||||||
const val PATH = "instances"
|
const val PATH = "instances"
|
||||||
@@ -96,8 +99,59 @@ object TasksContract {
|
|||||||
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
|
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
|
||||||
const val DISTANCE_FROM_CURRENT = "distance_from_current"
|
const val DISTANCE_FROM_CURRENT = "distance_from_current"
|
||||||
const val IS_RECURRING = "is_recurring"
|
const val IS_RECURRING = "is_recurring"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
|
||||||
|
* instantiated at, before any override moved it. Set on every occurrence
|
||||||
|
* of a recurring task, which is what makes it the occurrence's identity.
|
||||||
|
*/
|
||||||
|
const val INSTANCE_ORIGINAL_TIME = "instance_original_time"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */
|
||||||
|
object Properties {
|
||||||
|
const val PATH = "properties"
|
||||||
|
const val PROPERTY_ID = "property_id"
|
||||||
|
const val TASK_ID = "task_id"
|
||||||
|
const val MIMETYPE = "mimetype"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An alarm property row — a per-task reminder lead.
|
||||||
|
*
|
||||||
|
* Storage and sync format *only*: the provider fires nothing (its alarm
|
||||||
|
* scheduling is commented out and the internal `alarms` table is never
|
||||||
|
* populated), so [de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler]
|
||||||
|
* still arms the real AlarmManager alarm. Writing it here is what makes the
|
||||||
|
* lead survive a sync and show up in other OpenTasks clients.
|
||||||
|
*
|
||||||
|
* The columns are the generic `dataN` slots; the meanings below are the
|
||||||
|
* Alarm property's contract for them.
|
||||||
|
*/
|
||||||
|
object Alarm {
|
||||||
|
const val MIMETYPE = "vnd.android.cursor.item/alarm"
|
||||||
|
|
||||||
|
/** `data0` — minutes from the reference date; positive means *before* it. */
|
||||||
|
const val MINUTES_BEFORE = "data0"
|
||||||
|
|
||||||
|
/** `data1` — which date to count from. */
|
||||||
|
const val REFERENCE = "data1"
|
||||||
|
|
||||||
|
/** `data2` — optional message shown with the alarm. */
|
||||||
|
const val MESSAGE = "data2"
|
||||||
|
|
||||||
|
/** `data3` — alarm kind. Must be present, and non-zero to count as an alarm. */
|
||||||
|
const val ALARM_TYPE = "data3"
|
||||||
|
|
||||||
|
const val REFERENCE_DUE = 1
|
||||||
|
const val REFERENCE_START = 2
|
||||||
|
|
||||||
|
/** 0 (NOTHING) is excluded from the provider's `has_alarms` count — use MESSAGE. */
|
||||||
|
const val TYPE_MESSAGE = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}")
|
||||||
|
|
||||||
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
||||||
const val STATUS_NEEDS_ACTION = 0
|
const val STATUS_NEEDS_ACTION = 0
|
||||||
const val STATUS_IN_PROCESS = 1
|
const val STATUS_IN_PROCESS = 1
|
||||||
@@ -109,9 +163,20 @@ object TasksContract {
|
|||||||
|
|
||||||
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
|
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
|
||||||
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
|
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
|
||||||
|
fun listUri(authority: String, listId: Long): Uri =
|
||||||
|
Uri.parse("content://$authority/${Lists.PATH}/$listId")
|
||||||
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
|
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
|
||||||
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
|
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single occurrence. Updating through this URI is how a *recurring* task is
|
||||||
|
* edited: the provider clones the row into an override task
|
||||||
|
* (`original_instance_id` set, recurrence fields stripped) instead of moving
|
||||||
|
* the series anchor, which is what writing to `tasks/<id>` would do.
|
||||||
|
*/
|
||||||
|
fun instanceUri(authority: String, instanceId: Long): Uri =
|
||||||
|
Uri.parse("content://$authority/${Instances.PATH}/$instanceId")
|
||||||
|
|
||||||
/** Append the sync-adapter params required to write local-account rows. */
|
/** Append the sync-adapter params required to write local-account rows. */
|
||||||
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
|
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
|
||||||
uri.buildUpon()
|
uri.buildUpon()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stored reminder: how long before, and what it counts back from.
|
||||||
|
*
|
||||||
|
* [fromStart] matters because both stores can hold a `START`-referenced alarm —
|
||||||
|
* the dmfs import preserves one, and an external provider's other clients write
|
||||||
|
* them — while Agendula's own UI only ever sets a before-due lead. Collapsing it
|
||||||
|
* to a number here is what silently fired those reminders off the wrong anchor.
|
||||||
|
*/
|
||||||
|
data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false)
|
||||||
|
|
||||||
|
/** What to fetch from the provider. Smart-list date logic is applied above this. */
|
||||||
|
data class TaskQuery(
|
||||||
|
val listId: Long? = null,
|
||||||
|
val includeCompleted: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-shaped, synchronous seam over the tasks ContentResolver. Returns parsed
|
||||||
|
* lists so [TasksRepositoryImpl] can be tested with a fake on the JVM. All Cursor
|
||||||
|
* and ContentObserver handling lives in [AndroidTasksDataSource].
|
||||||
|
*/
|
||||||
|
interface TasksDataSource {
|
||||||
|
fun taskLists(): List<TaskList>
|
||||||
|
fun tasks(query: TaskQuery): List<Task>
|
||||||
|
fun task(taskId: Long): Task?
|
||||||
|
fun subtasks(parentTaskId: Long): List<Task>
|
||||||
|
|
||||||
|
fun insertTask(form: TaskForm): Long
|
||||||
|
fun updateTask(taskId: Long, form: TaskForm)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single occurrence of a recurring task, addressed by the task row and
|
||||||
|
* the occurrence's `RECURRENCE-ID` anchor ([Task.occurrenceStart]). The store
|
||||||
|
* forks an override rather than moving the series anchor — which is what
|
||||||
|
* [updateTask] would do, since a recurring task's start/due are the
|
||||||
|
* occurrence's resolved times.
|
||||||
|
*
|
||||||
|
* Addressing by `(taskId, occurrenceStart)` rather than by a materialised
|
||||||
|
* instance row id keeps this seam independent of any one store's row
|
||||||
|
* numbering; External mode maps it back to an instance row itself.
|
||||||
|
*/
|
||||||
|
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm)
|
||||||
|
/**
|
||||||
|
* Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm
|
||||||
|
* property row. The provider never fires it — [de.jeanlucmakiola.agendula
|
||||||
|
* .data.reminders.ReminderScheduler] does — but persisting it here is what
|
||||||
|
* syncs the lead and shares it with other OpenTasks clients.
|
||||||
|
*/
|
||||||
|
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
|
||||||
|
|
||||||
|
/** Every task's reminder, by task id. One query, for the scheduler. */
|
||||||
|
fun alarms(): Map<Long, TaskReminder>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
|
||||||
|
* not occurrences — see [TaskMapper.exportTask] for why that distinction
|
||||||
|
* matters. Excludes rows the provider has flagged deleted-but-unsynced.
|
||||||
|
*/
|
||||||
|
fun exportTasks(listId: Long): List<de.jeanlucmakiola.agendula.domain.export.ExportTask>
|
||||||
|
|
||||||
|
fun setCompleted(taskId: Long, completed: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete (or reopen) **one occurrence** of a recurring task, addressed the
|
||||||
|
* same way [updateInstance] is. Ticking a series through [setCompleted] would
|
||||||
|
* close the master row, which takes every past and future occurrence out of
|
||||||
|
* every list at once.
|
||||||
|
*
|
||||||
|
* Implementations fall back to [setCompleted] when the row turns out not to
|
||||||
|
* be a series master — an override, or a plain task the caller happened to
|
||||||
|
* hand an anchor for — so the routing above cannot get this wrong.
|
||||||
|
*/
|
||||||
|
fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean)
|
||||||
|
fun deleteTask(taskId: Long)
|
||||||
|
fun createLocalList(name: String, color: Int): Long
|
||||||
|
|
||||||
|
/** Rename and recolour [listId]. */
|
||||||
|
fun updateList(listId: Long, name: String, color: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete [listId] **and the tasks in it** — `tasks.list_id` cascades on the
|
||||||
|
* Room path, and the provider does the same on the External one.
|
||||||
|
*
|
||||||
|
* Only ever called for a local, device-only list: a collection that belongs
|
||||||
|
* to an account is the server's to remove, and neither store expresses a
|
||||||
|
* collection tombstone yet. The UI gates on [TaskList.isLocal]; this seam
|
||||||
|
* does not re-check it.
|
||||||
|
*/
|
||||||
|
fun deleteList(listId: Long)
|
||||||
|
|
||||||
|
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
|
||||||
|
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
|
||||||
|
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single entry point the UI layer uses. Flows re-emit automatically when the
|
||||||
|
* provider changes (our writes *and* external sync like DAVx5), via the
|
||||||
|
* data source's ContentObserver.
|
||||||
|
*/
|
||||||
|
interface TasksRepository {
|
||||||
|
fun taskLists(): Flow<List<TaskList>>
|
||||||
|
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The direct children of [parentId], re-emitting live like the other flows.
|
||||||
|
* Lets a smart-list view pull a parent's full subtask set on demand — those
|
||||||
|
* children are usually filtered out of the list's own (date-based) query.
|
||||||
|
*/
|
||||||
|
fun subtasks(parentId: Long): Flow<List<Task>>
|
||||||
|
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
||||||
|
|
||||||
|
suspend fun createTask(form: TaskForm): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite [taskId] with [form]. When [expectedLastModified] is non-null, the
|
||||||
|
* task's current `last_modified` is re-checked first and a
|
||||||
|
* [TaskConflictException] is thrown if it differs — i.e. something changed it
|
||||||
|
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
|
||||||
|
*/
|
||||||
|
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
||||||
|
/**
|
||||||
|
* Complete or reopen a task. Pass the occurrence's [Task.occurrenceStart] so a
|
||||||
|
* recurring series forks a `RECURRENCE-ID` override for that one occurrence
|
||||||
|
* instead of closing the whole series; `null` completes the row itself.
|
||||||
|
*/
|
||||||
|
suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean)
|
||||||
|
suspend fun deleteTask(taskId: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-task reminder lead in minutes before due, or `null` if the task has
|
||||||
|
* none (in which case the list's / global setting applies). Read when the edit
|
||||||
|
* form loads so saving can't silently drop it.
|
||||||
|
*/
|
||||||
|
suspend fun reminderFor(taskId: Long): Int?
|
||||||
|
suspend fun createLocalList(name: String, color: Int): Long
|
||||||
|
suspend fun updateList(listId: Long, name: String, color: Int)
|
||||||
|
|
||||||
|
/** Deletes the list **and its tasks**. Local lists only — see [TasksDataSource.deleteList]. */
|
||||||
|
suspend fun deleteList(listId: Long)
|
||||||
|
|
||||||
|
/** Synchronous snapshot for the permission/onboarding gate. */
|
||||||
|
fun providerStatus(): ProviderStatus
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package de.jeanlucmakiola.floret.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.floret.domain.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.floret.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.floret.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFiltering
|
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.floret.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.floret.domain.TaskSorting
|
import de.jeanlucmakiola.agendula.domain.TaskSorting
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
@@ -28,6 +28,7 @@ import kotlin.time.Instant
|
|||||||
class TasksRepositoryImpl @Inject constructor(
|
class TasksRepositoryImpl @Inject constructor(
|
||||||
private val dataSource: TasksDataSource,
|
private val dataSource: TasksDataSource,
|
||||||
private val providerResolver: ProviderResolver,
|
private val providerResolver: ProviderResolver,
|
||||||
|
private val startupGate: StartupGate,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : TasksRepository {
|
) : TasksRepository {
|
||||||
|
|
||||||
@@ -35,6 +36,12 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
||||||
|
|
||||||
|
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
|
||||||
|
dataSource.subtasks(parentId)
|
||||||
|
.filter { it.taskId != parentId }
|
||||||
|
.sortedWith(TaskSorting.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
|
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
|
||||||
dataSource.task(taskId)?.let { task ->
|
dataSource.task(taskId)?.let { task ->
|
||||||
TaskDetail(
|
TaskDetail(
|
||||||
@@ -74,22 +81,50 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createTask(form: TaskForm): Long =
|
override suspend fun createTask(form: TaskForm): Long =
|
||||||
withContext(io) { dataSource.insertTask(form) }
|
withContext(io) {
|
||||||
|
val id = dataSource.insertTask(form)
|
||||||
|
form.reminderMinutesBeforeDue?.let { dataSource.setAlarm(id, it) }
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reminderFor(taskId: Long): Int? =
|
||||||
|
withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() }
|
||||||
|
|
||||||
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
|
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
// Conflict-safe overwrite: re-read just before writing and bail if the
|
// Re-read just before writing: it settles the conflict check *and* tells
|
||||||
// provider's last_modified moved since the form captured it (external
|
// us which URI to write through.
|
||||||
// sync / another app). A null baseline means "force / overwrite anyway".
|
val current = dataSource.task(taskId)
|
||||||
|
// Conflict-safe overwrite: bail if the provider's last_modified moved
|
||||||
|
// since the form captured it (external sync / another app). A null
|
||||||
|
// baseline means "force / overwrite anyway".
|
||||||
if (expectedLastModified != null) {
|
if (expectedLastModified != null) {
|
||||||
val current = dataSource.task(taskId)?.lastModified
|
val seen = current?.lastModified
|
||||||
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
|
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
|
||||||
}
|
}
|
||||||
|
// Write the reminder first: forking a recurring occurrence copies the
|
||||||
|
// task's properties onto the new override row, so setting the alarm
|
||||||
|
// beforehand is what carries it across.
|
||||||
|
dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue)
|
||||||
|
// A recurring task's start/due are one occurrence's resolved times, so
|
||||||
|
// writing them back to the task row would re-anchor the whole series.
|
||||||
|
// Going through the occurrence forks an override instead.
|
||||||
|
val occurrence = current?.takeIf { it.isRecurring }?.occurrenceStart
|
||||||
|
if (occurrence != null) {
|
||||||
|
dataSource.updateInstance(taskId, occurrence, form)
|
||||||
|
} else {
|
||||||
dataSource.updateTask(taskId, form)
|
dataSource.updateTask(taskId, form)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
|
override suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) =
|
||||||
withContext(io) { dataSource.setCompleted(taskId, completed) }
|
withContext(io) {
|
||||||
|
if (occurrenceStart != null) {
|
||||||
|
dataSource.setCompletedInstance(taskId, occurrenceStart, completed)
|
||||||
|
} else {
|
||||||
|
dataSource.setCompleted(taskId, completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun deleteTask(taskId: Long) =
|
override suspend fun deleteTask(taskId: Long) =
|
||||||
withContext(io) { dataSource.deleteTask(taskId) }
|
withContext(io) { dataSource.deleteTask(taskId) }
|
||||||
@@ -97,18 +132,32 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun createLocalList(name: String, color: Int): Long =
|
override suspend fun createLocalList(name: String, color: Int): Long =
|
||||||
withContext(io) { dataSource.createLocalList(name, color) }
|
withContext(io) { dataSource.createLocalList(name, color) }
|
||||||
|
|
||||||
|
override suspend fun updateList(listId: Long, name: String, color: Int) =
|
||||||
|
withContext(io) { dataSource.updateList(listId, name, color) }
|
||||||
|
|
||||||
|
override suspend fun deleteList(listId: Long) =
|
||||||
|
withContext(io) { dataSource.deleteList(listId) }
|
||||||
|
|
||||||
override fun providerStatus(): ProviderStatus {
|
override fun providerStatus(): ProviderStatus {
|
||||||
|
// Our own store is always ready: it ships with the app, needs no provider
|
||||||
|
// and no grant. The permission gate only ever applied to External mode —
|
||||||
|
// now that is visibly true rather than a special case inside it.
|
||||||
|
if (providerResolver.mode() == StorageMode.OWN) return ProviderStatus.READY
|
||||||
val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER
|
val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER
|
||||||
return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
|
return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
|
||||||
else ProviderStatus.NEEDS_PERMISSION
|
else ProviderStatus.NEEDS_PERMISSION
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emits an initial load, then re-loads on every provider change. The observer
|
* Emits an initial load, then re-loads on every store change. The observer
|
||||||
* callback (main thread) only pokes a conflated channel; the actual blocking
|
* callback (main thread) only pokes a conflated channel; the actual blocking
|
||||||
* query runs on [io].
|
* query runs on [io].
|
||||||
*/
|
*/
|
||||||
private fun <T> observing(load: () -> T): Flow<T> = callbackFlow {
|
private fun <T> observing(load: () -> T): Flow<T> = callbackFlow {
|
||||||
|
// Nothing reads a store before the stored mode has landed and a v0.3.x
|
||||||
|
// install has been imported — otherwise the first emission comes from the
|
||||||
|
// wrong store, or from an empty one.
|
||||||
|
startupGate.awaitReady()
|
||||||
val ticks = Channel<Unit>(Channel.CONFLATED)
|
val ticks = Channel<Unit>(Channel.CONFLATED)
|
||||||
val handle = dataSource.registerObserver { ticks.trySend(Unit) }
|
val handle = dataSource.registerObserver { ticks.trySend(Unit) }
|
||||||
ticks.trySend(Unit) // prime the initial emission
|
ticks.trySend(Unit) // prime the initial emission
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.legacy
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.CursorColumnReader
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
|
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||||
|
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** How much one import moved. */
|
||||||
|
data class ImportCounts(val lists: Int, val tasks: Int, val alarms: Int)
|
||||||
|
|
||||||
|
/** The outcome of [OneShotImport.runIfNeeded] or [OneShotImport.reimportFromArchive]. */
|
||||||
|
sealed interface ImportResult {
|
||||||
|
/** The DataStore flag was already set; nothing was read. */
|
||||||
|
data object AlreadyDone : ImportResult
|
||||||
|
|
||||||
|
/** No legacy database on disk — a fresh install, or one already archived. */
|
||||||
|
data object NothingToImport : ImportResult
|
||||||
|
|
||||||
|
data class Imported(val counts: ImportCounts) : ImportResult
|
||||||
|
|
||||||
|
/** Nothing landed: the transaction rolled back and the source is untouched. */
|
||||||
|
data class Failed(val cause: Throwable) : ImportResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a v0.3.x install's tasks out of the bundled dmfs provider's SQLite file
|
||||||
|
* and into Room, once (`docs/OWN-STORE.md`, "Migrating existing users").
|
||||||
|
*
|
||||||
|
* The file is opened read-only and directly — no provider, no ContentResolver —
|
||||||
|
* so this keeps working after `:provider` is deleted. Everything lands in one
|
||||||
|
* Room transaction with verified counts, so a failure leaves Room exactly as it
|
||||||
|
* was and the source file exactly where it was.
|
||||||
|
*
|
||||||
|
* dmfs accounts are not carried over: every list is imported as a device-only
|
||||||
|
* list (`account_id IS NULL`), including one that sat under a real account —
|
||||||
|
* only reachable if the user had pointed DAVx5 at our authority. Their task
|
||||||
|
* `uid`s are preserved, which is what lets those rows be re-attached to an
|
||||||
|
* account once sync lands.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class OneShotImport @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val database: TasksDatabase,
|
||||||
|
private val dataStore: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Whether the import has run. Set before the rename, so both guards hold. */
|
||||||
|
val isDone: Flow<Boolean> = dataStore.data.map { it[IMPORT_DONE] ?: false }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steps 1–8 of the plan: archive `databases/tasks.db`, import it, record
|
||||||
|
* completion. Safe to call on every launch.
|
||||||
|
*
|
||||||
|
* The archive happens *before* the import, and the import always replaces, so
|
||||||
|
* that every point this can be killed at re-enters correctly:
|
||||||
|
*
|
||||||
|
* - killed after the rename, before the import — the next run finds the
|
||||||
|
* archive, imports it, and nothing is lost;
|
||||||
|
* - killed after the import commits, before the flag is written — the next
|
||||||
|
* run truncates and re-imports the same archive, so the result is the same
|
||||||
|
* rather than doubled.
|
||||||
|
*
|
||||||
|
* Renaming last would leave that second window open: the flag would be unset
|
||||||
|
* and `tasks.db` still in place, and the next launch would import it a second
|
||||||
|
* time on top of the first. That is the window the plan's "guarded by a
|
||||||
|
* DataStore flag *and* by the rename" is meant to close, and only this order
|
||||||
|
* actually closes it.
|
||||||
|
*/
|
||||||
|
suspend fun runIfNeeded(): ImportResult = withContext(Dispatchers.IO) {
|
||||||
|
if (isDone.first()) return@withContext ImportResult.AlreadyDone
|
||||||
|
val source = archivedSource() ?: run {
|
||||||
|
markDone()
|
||||||
|
return@withContext ImportResult.NothingToImport
|
||||||
|
}
|
||||||
|
val counts = runCatching { importFrom(source, replaceExisting = true) }
|
||||||
|
.getOrElse { return@withContext ImportResult.Failed(it) }
|
||||||
|
markDone()
|
||||||
|
ImportResult.Imported(counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rollback path: re-run against the archived `tasks.db.imported`,
|
||||||
|
* truncating the Room tables first so a second attempt replaces rather than
|
||||||
|
* merges. Reached by a targeted fix release, not by the app on its own.
|
||||||
|
*/
|
||||||
|
suspend fun reimportFromArchive(): ImportResult = withContext(Dispatchers.IO) {
|
||||||
|
val source = archivedSource() ?: return@withContext ImportResult.NothingToImport
|
||||||
|
val counts = runCatching { importFrom(source, replaceExisting = true) }
|
||||||
|
.getOrElse { return@withContext ImportResult.Failed(it) }
|
||||||
|
markDone()
|
||||||
|
ImportResult.Imported(counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The legacy database as `tasks.db.imported`, archiving it first if it is
|
||||||
|
* still under its live name. `null` when there is nothing to import.
|
||||||
|
*/
|
||||||
|
private fun archivedSource(): File? {
|
||||||
|
val archive = context.getDatabasePath(ARCHIVE_NAME)
|
||||||
|
if (archive.exists()) return archive
|
||||||
|
val live = context.getDatabasePath(LEGACY_NAME)
|
||||||
|
if (!live.exists()) return null
|
||||||
|
return if (archive(live)) archive else live
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the completion flag so [runIfNeeded] will import again. */
|
||||||
|
suspend fun clearCompletion() {
|
||||||
|
dataStore.edit { it.remove(IMPORT_DONE) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steps 2–6 against an arbitrary dmfs database: read it read-only, then write
|
||||||
|
* everything in one Room transaction whose counts are verified before it
|
||||||
|
* commits. Blocking — call it off the main thread.
|
||||||
|
*/
|
||||||
|
fun importFrom(source: File, replaceExisting: Boolean = false): ImportCounts {
|
||||||
|
val snapshot = SQLiteDatabase.openDatabase(source.path, null, SQLiteDatabase.OPEN_READONLY)
|
||||||
|
.use(::read)
|
||||||
|
return database.runInTransaction<ImportCounts> {
|
||||||
|
if (replaceExisting) truncate()
|
||||||
|
val baseline = tableCounts()
|
||||||
|
val written = write(snapshot)
|
||||||
|
verify(written, baseline)
|
||||||
|
written
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reading the dmfs file ------------------------------------------------
|
||||||
|
|
||||||
|
private fun read(db: SQLiteDatabase): LegacySnapshot {
|
||||||
|
val lists = mutableListOf<LegacyList>()
|
||||||
|
db.rawQuery("SELECT * FROM Lists ORDER BY _id", null).use { cursor ->
|
||||||
|
val r = CursorColumnReader(cursor)
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val id = r.getLong("_id") ?: continue
|
||||||
|
lists += LegacyList(
|
||||||
|
id = id,
|
||||||
|
entity = TaskListEntity(
|
||||||
|
name = r.getString("list_name").orEmpty(),
|
||||||
|
color = r.getInt("list_color") ?: 0,
|
||||||
|
accountId = null,
|
||||||
|
isVisible = r.getBoolean("visible"),
|
||||||
|
isSynced = r.getBoolean("sync_enabled"),
|
||||||
|
owner = r.getString("list_owner"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val rows = mutableListOf<LegacyTaskRow>()
|
||||||
|
db.rawQuery("SELECT * FROM Tasks WHERE _deleted IS NULL OR _deleted = 0 ORDER BY _id", null)
|
||||||
|
.use { cursor ->
|
||||||
|
val r = CursorColumnReader(cursor)
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val id = r.getLong("_id") ?: continue
|
||||||
|
rows += LegacyTaskRow(
|
||||||
|
id = id,
|
||||||
|
listId = r.getLong("list_id") ?: continue,
|
||||||
|
parentId = r.getLong("parent_id"),
|
||||||
|
masterId = r.getLong("original_instance_id"),
|
||||||
|
recurrenceId = r.instant("original_instance_time"),
|
||||||
|
entity = TaskEntity(
|
||||||
|
listId = 0,
|
||||||
|
uid = r.getString("_uid") ?: UUID.randomUUID().toString(),
|
||||||
|
title = r.getString("title"),
|
||||||
|
description = r.getString("description"),
|
||||||
|
location = r.getString("location"),
|
||||||
|
url = r.getString("url"),
|
||||||
|
color = r.getInt("task_color"),
|
||||||
|
status = statusFromInt(r.getInt("status")),
|
||||||
|
percentComplete = r.getInt("percent_complete"),
|
||||||
|
completedAt = r.instant("completed"),
|
||||||
|
priority = r.getInt("priority") ?: PRIORITY_NONE,
|
||||||
|
classification = r.getInt("class"),
|
||||||
|
dtstart = r.instant("dtstart"),
|
||||||
|
due = r.instant("due"),
|
||||||
|
duration = r.getString("duration"),
|
||||||
|
isAllDay = r.getBoolean("is_allday"),
|
||||||
|
timezone = r.getString("tz"),
|
||||||
|
rrule = r.getString("rrule"),
|
||||||
|
rdate = r.getString("rdate"),
|
||||||
|
exdate = r.getString("exdate"),
|
||||||
|
createdAt = r.instant("created"),
|
||||||
|
lastModified = r.instant("last_modified"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val alarms = mutableListOf<LegacyAlarm>()
|
||||||
|
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null)
|
||||||
|
.use { cursor ->
|
||||||
|
val r = CursorColumnReader(cursor)
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
|
||||||
|
val taskId = r.getLong("task_id") ?: continue
|
||||||
|
val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
|
||||||
|
alarms += LegacyAlarm(
|
||||||
|
taskId = taskId,
|
||||||
|
minutesBefore = minutes,
|
||||||
|
reference = if (r.getString("data1")?.trim() == REFERENCE_START) {
|
||||||
|
AlarmReference.START
|
||||||
|
} else {
|
||||||
|
AlarmReference.DUE
|
||||||
|
},
|
||||||
|
message = r.getString("data2"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LegacySnapshot(lists, rows, alarms)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writing into Room ----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dmfs `list_id`, `parent_id` and `original_instance_id` are old row ids, and
|
||||||
|
* Room mints its own on insert, so every one of them is remapped through the
|
||||||
|
* ids the inserts hand back. Tasks are inserted with their links cleared and
|
||||||
|
* a second pass sets them, because a parent may be a higher `_id` than its
|
||||||
|
* child.
|
||||||
|
*/
|
||||||
|
private fun write(snapshot: LegacySnapshot): ImportCounts {
|
||||||
|
val listDao = database.taskLists()
|
||||||
|
val taskDao = database.tasks()
|
||||||
|
val alarmDao = database.alarms()
|
||||||
|
|
||||||
|
val listIds = snapshot.lists.associate { it.id to listDao.insert(it.entity) }
|
||||||
|
|
||||||
|
// A task whose list is missing is already invisible in dmfs — its tasks
|
||||||
|
// view inner-joins Lists — so dropping it loses nothing the user could see.
|
||||||
|
val importable = snapshot.tasks.filter { it.listId in listIds }
|
||||||
|
val importableIds = importable.mapTo(mutableSetOf()) { it.id }
|
||||||
|
val taskIds = mutableMapOf<Long, Long>()
|
||||||
|
val inserted = mutableListOf<Pair<LegacyTaskRow, TaskEntity>>()
|
||||||
|
val seen = mutableSetOf<Triple<Long, String, Instant?>>()
|
||||||
|
|
||||||
|
for (row in importable) {
|
||||||
|
val listId = listIds.getValue(row.listId)
|
||||||
|
val overrides = row.masterId != null && row.masterId in importableIds
|
||||||
|
val recurrenceId = row.recurrenceId.takeIf { overrides }
|
||||||
|
// A duplicate (list, uid, recurrence) would abort the whole import on
|
||||||
|
// the unique index; a fresh uid costs the row nothing it still has.
|
||||||
|
val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
|
||||||
|
?: UUID.randomUUID().toString()
|
||||||
|
val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId)
|
||||||
|
val newId = taskDao.insert(entity)
|
||||||
|
taskIds[row.id] = newId
|
||||||
|
inserted += row to entity.copy(id = newId)
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((row, entity) in inserted) {
|
||||||
|
val parentId = row.parentId?.let(taskIds::get)
|
||||||
|
val masterId = row.masterId?.let(taskIds::get)
|
||||||
|
if (parentId == null && masterId == null) continue
|
||||||
|
taskDao.update(entity.copy(parentId = parentId, masterId = masterId))
|
||||||
|
}
|
||||||
|
|
||||||
|
var alarmCount = 0
|
||||||
|
for (alarm in snapshot.alarms) {
|
||||||
|
val taskId = taskIds[alarm.taskId] ?: continue
|
||||||
|
alarmDao.insert(
|
||||||
|
TaskAlarmEntity(
|
||||||
|
taskId = taskId,
|
||||||
|
minutesBefore = alarm.minutesBefore,
|
||||||
|
reference = alarm.reference,
|
||||||
|
message = alarm.message,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
alarmCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImportCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verify(written: ImportCounts, before: ImportCounts) {
|
||||||
|
val after = tableCounts()
|
||||||
|
check(after.lists - before.lists == written.lists) {
|
||||||
|
"list count mismatch: ${after.lists - before.lists} != ${written.lists}"
|
||||||
|
}
|
||||||
|
check(after.tasks - before.tasks == written.tasks) {
|
||||||
|
"task count mismatch: ${after.tasks - before.tasks} != ${written.tasks}"
|
||||||
|
}
|
||||||
|
check(after.alarms - before.alarms == written.alarms) {
|
||||||
|
"alarm count mismatch: ${after.alarms - before.alarms} != ${written.alarms}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dropping the lists takes their tasks and alarms with them, by cascade. */
|
||||||
|
private fun truncate() {
|
||||||
|
val listDao = database.taskLists()
|
||||||
|
listDao.lists().forEach { listDao.delete(it.list.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tableCounts() = ImportCounts(
|
||||||
|
lists = count("task_lists"),
|
||||||
|
tasks = count("tasks"),
|
||||||
|
alarms = count("task_alarms"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun count(table: String): Int =
|
||||||
|
database.query("SELECT COUNT(*) FROM $table", null).use {
|
||||||
|
if (it.moveToFirst()) it.getInt(0) else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the source file ------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames the dmfs file, sidecars included, to `tasks.db.imported`. Never
|
||||||
|
* deletes it: for one release it is the only way back if the import turns out
|
||||||
|
* to be wrong on someone's device.
|
||||||
|
*/
|
||||||
|
private fun archive(source: File): Boolean {
|
||||||
|
val target = File(source.parentFile, ARCHIVE_NAME)
|
||||||
|
if (!source.renameTo(target)) return false
|
||||||
|
for (suffix in SIDECARS) {
|
||||||
|
val sidecar = File(source.path + suffix)
|
||||||
|
if (sidecar.exists()) sidecar.renameTo(File(target.path + suffix))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun markDone() {
|
||||||
|
dataStore.edit { it[IMPORT_DONE] = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CursorColumnReader.instant(name: String): Instant? =
|
||||||
|
getLong(name)?.let(Instant::fromEpochMilliseconds)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val LEGACY_NAME = "tasks.db"
|
||||||
|
const val ARCHIVE_NAME = "tasks.db.imported"
|
||||||
|
|
||||||
|
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
|
||||||
|
private const val REFERENCE_START = "2"
|
||||||
|
private val SIDECARS = listOf("-journal", "-wal", "-shm")
|
||||||
|
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LegacySnapshot(
|
||||||
|
val lists: List<LegacyList>,
|
||||||
|
val tasks: List<LegacyTaskRow>,
|
||||||
|
val alarms: List<LegacyAlarm>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class LegacyList(val id: Long, val entity: TaskListEntity)
|
||||||
|
|
||||||
|
private class LegacyTaskRow(
|
||||||
|
val id: Long,
|
||||||
|
val listId: Long,
|
||||||
|
val parentId: Long?,
|
||||||
|
val masterId: Long?,
|
||||||
|
val recurrenceId: Instant?,
|
||||||
|
val entity: TaskEntity,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class LegacyAlarm(
|
||||||
|
val taskId: Long,
|
||||||
|
val minutesBefore: Int,
|
||||||
|
val reference: AlarmReference,
|
||||||
|
val message: String?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Reads and writes over `accounts`. Unused until sync lands. */
|
||||||
|
@Dao
|
||||||
|
interface AccountDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM accounts ORDER BY display_name")
|
||||||
|
fun all(): List<AccountEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM accounts WHERE id = :accountId")
|
||||||
|
fun account(accountId: Long): AccountEntity?
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
fun insert(account: AccountEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
fun update(account: AccountEntity)
|
||||||
|
|
||||||
|
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
|
||||||
|
fun recordSync(accountId: Long, at: Instant?, error: String?)
|
||||||
|
|
||||||
|
@Query("DELETE FROM accounts WHERE id = :accountId")
|
||||||
|
fun delete(accountId: Long): Int
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||||
|
import de.jeanlucmakiola.agendula.domain.toInt
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage encodings for the entity types SQLite has no column type for. Time is
|
||||||
|
* epoch millis; [TaskStatus] goes through the `domain` mappers so that numbering
|
||||||
|
* keeps its single home.
|
||||||
|
*
|
||||||
|
* `PRIORITY` deliberately has no converter — it is stored as the raw iCalendar
|
||||||
|
* integer, because [de.jeanlucmakiola.agendula.domain.Priority] is a lossy
|
||||||
|
* bucketing and a converter would apply it before the value reaches disk.
|
||||||
|
*/
|
||||||
|
object Converters {
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun instantToMillis(value: Instant?): Long? = value?.toEpochMilliseconds()
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun instantFromMillis(value: Long?): Instant? = value?.let(Instant::fromEpochMilliseconds)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun statusToInt(value: TaskStatus): Int = value.toInt()
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun statusFrom(value: Int): TaskStatus = statusFromInt(value)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun alarmReferenceToString(value: AlarmReference): String = value.name
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun alarmReferenceFrom(value: String): AlarmReference =
|
||||||
|
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds the write-ahead log back into the database file when the app goes to the
|
||||||
|
* background.
|
||||||
|
*
|
||||||
|
* Room runs in WAL mode, and Auto Backup copies files without checkpointing — so
|
||||||
|
* a `-wal` sidecar can hold writes the backed-up `.db` does not. The backup rules
|
||||||
|
* carry all three files, which already makes a restore consistent; this narrows
|
||||||
|
* the window further by ensuring the `.db` alone is usually current, which is what
|
||||||
|
* a restore onto a device that drops the sidecars falls back to.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class DatabaseCheckpoint @Inject constructor(
|
||||||
|
private val database: TasksDatabase,
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
) : DefaultLifecycleObserver {
|
||||||
|
|
||||||
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
database.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A CalDAV account. Empty until sync lands (`docs/SYNC.md` phase 2), but the FK
|
||||||
|
* from [TaskListEntity] exists from v1 so turning sync on never needs a
|
||||||
|
* migration. The app password is never stored here — Keystore only.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "accounts")
|
||||||
|
data class AccountEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
@ColumnInfo(name = "id") val id: Long = 0,
|
||||||
|
@ColumnInfo(name = "display_name") val displayName: String,
|
||||||
|
@ColumnInfo(name = "principal_url") val principalUrl: String? = null,
|
||||||
|
@ColumnInfo(name = "home_set_url") val homeSetUrl: String? = null,
|
||||||
|
@ColumnInfo(name = "username") val username: String? = null,
|
||||||
|
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null,
|
||||||
|
@ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A task list. [accountId] is nullable: `NULL` is a device-only list, and
|
||||||
|
* attaching one to an account later is a plain `UPDATE` rather than a data
|
||||||
|
* migration.
|
||||||
|
*
|
||||||
|
* Deleting an account detaches its lists (`SET NULL`) instead of deleting them,
|
||||||
|
* for the same reason [TaskEntity.parentId] does — removing an account is not
|
||||||
|
* an instruction to destroy the tasks it held.
|
||||||
|
*/
|
||||||
|
@Entity(
|
||||||
|
tableName = "task_lists",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = AccountEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["account_id"],
|
||||||
|
onDelete = ForeignKey.SET_NULL,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index(value = ["account_id"])],
|
||||||
|
)
|
||||||
|
data class TaskListEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
@ColumnInfo(name = "id") val id: Long = 0,
|
||||||
|
@ColumnInfo(name = "name") val name: String,
|
||||||
|
/** ARGB. */
|
||||||
|
@ColumnInfo(name = "color") val color: Int,
|
||||||
|
@ColumnInfo(name = "account_id") val accountId: Long? = null,
|
||||||
|
@ColumnInfo(name = "is_visible", defaultValue = "1") val isVisible: Boolean = true,
|
||||||
|
@ColumnInfo(name = "is_synced", defaultValue = "1") val isSynced: Boolean = true,
|
||||||
|
/** CalDAV owner display name. */
|
||||||
|
@ColumnInfo(name = "owner") val owner: String? = null,
|
||||||
|
@ColumnInfo(name = "is_read_only", defaultValue = "0") val isReadOnly: Boolean = false,
|
||||||
|
/** User ordering. */
|
||||||
|
@ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0,
|
||||||
|
/** Collection URL, relative to the account root. */
|
||||||
|
@ColumnInfo(name = "href") val href: String? = null,
|
||||||
|
@ColumnInfo(name = "ctag") val ctag: String? = null,
|
||||||
|
/** RFC 6578 sync token, per collection. */
|
||||||
|
@ColumnInfo(name = "sync_token") val syncToken: String? = null,
|
||||||
|
@ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A task. Series masters *and* `RECURRENCE-ID` overrides live in this table; an
|
||||||
|
* override is a row with [recurrenceId] set and [masterId] pointing at its
|
||||||
|
* master, sharing the master's [uid].
|
||||||
|
*
|
||||||
|
* [masterId] and [parentId] are different things: [parentId] is task hierarchy
|
||||||
|
* (`RELATED-TO;RELTYPE=PARENT`), [masterId] is recurrence. A row can carry both.
|
||||||
|
*/
|
||||||
|
@Entity(
|
||||||
|
tableName = "tasks",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = TaskListEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["list_id"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
// Deleting a series takes its overrides with it — they would otherwise be
|
||||||
|
// unreachable rows that still sync.
|
||||||
|
ForeignKey(
|
||||||
|
entity = TaskEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["master_id"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
// Deleting a parent promotes its subtasks to top level rather than
|
||||||
|
// destroying work the user did not ask to lose.
|
||||||
|
ForeignKey(
|
||||||
|
entity = TaskEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["parent_id"],
|
||||||
|
onDelete = ForeignKey.SET_NULL,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [
|
||||||
|
Index(value = ["list_id", "is_deleted"]),
|
||||||
|
Index(value = ["parent_id"]),
|
||||||
|
Index(value = ["master_id", "recurrence_id"]),
|
||||||
|
Index(value = ["is_dirty"]),
|
||||||
|
// An override shares its master's UID, so (list_id, uid) alone would
|
||||||
|
// reject the very rows recurrence depends on. With recurrence_id NULL on
|
||||||
|
// the master and set on each override this reads as: one master and at
|
||||||
|
// most one override per occurrence, per UID, per list. Note SQLite treats
|
||||||
|
// NULLs as distinct in a unique index, so the master half is a statement
|
||||||
|
// of intent, not an enforced constraint.
|
||||||
|
Index(value = ["list_id", "uid", "recurrence_id"], unique = true),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data class TaskEntity(
|
||||||
|
// identity
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
@ColumnInfo(name = "id") val id: Long = 0,
|
||||||
|
@ColumnInfo(name = "list_id") val listId: Long,
|
||||||
|
/** RFC 4122 UUID, minted at creation in every mode, synced or not. */
|
||||||
|
@ColumnInfo(name = "uid") val uid: String,
|
||||||
|
@ColumnInfo(name = "href") val href: String? = null,
|
||||||
|
@ColumnInfo(name = "etag") val etag: String? = null,
|
||||||
|
|
||||||
|
// content
|
||||||
|
@ColumnInfo(name = "title") val title: String? = null,
|
||||||
|
@ColumnInfo(name = "description") val description: String? = null,
|
||||||
|
@ColumnInfo(name = "location") val location: String? = null,
|
||||||
|
@ColumnInfo(name = "url") val url: String? = null,
|
||||||
|
/** ARGB override for the list colour. */
|
||||||
|
@ColumnInfo(name = "color") val color: Int? = null,
|
||||||
|
|
||||||
|
// state
|
||||||
|
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||||
|
@ColumnInfo(name = "percent_complete") val percentComplete: Int? = null,
|
||||||
|
@ColumnInfo(name = "completed_at") val completedAt: Instant? = null,
|
||||||
|
/**
|
||||||
|
* Raw iCalendar `PRIORITY`: 0 none, 1 highest, 9 lowest. Stored unbucketed —
|
||||||
|
* [de.jeanlucmakiola.agendula.domain.Priority] folds 1–4 into HIGH, so
|
||||||
|
* converting on the way *in* would rewrite a server's `PRIORITY:3` as `1` and
|
||||||
|
* lose it on the next round-trip. The bucketing belongs to the mapper, which
|
||||||
|
* is where the UI needs it.
|
||||||
|
*/
|
||||||
|
@ColumnInfo(name = "priority", defaultValue = "0") val priority: Int = PRIORITY_NONE,
|
||||||
|
/** RFC 5545 `CLASS`: 0 public, 1 private, 2 confidential. */
|
||||||
|
@ColumnInfo(name = "classification") val classification: Int? = null,
|
||||||
|
|
||||||
|
// time
|
||||||
|
@ColumnInfo(name = "dtstart") val dtstart: Instant? = null,
|
||||||
|
@ColumnInfo(name = "due") val due: Instant? = null,
|
||||||
|
/** RFC 5545 `DURATION`, verbatim. Mutually exclusive with [due]. */
|
||||||
|
@ColumnInfo(name = "duration") val duration: String? = null,
|
||||||
|
@ColumnInfo(name = "is_all_day", defaultValue = "0") val isAllDay: Boolean = false,
|
||||||
|
@ColumnInfo(name = "timezone") val timezone: String? = null,
|
||||||
|
|
||||||
|
// recurrence
|
||||||
|
@ColumnInfo(name = "rrule") val rrule: String? = null,
|
||||||
|
@ColumnInfo(name = "rdate") val rdate: String? = null,
|
||||||
|
@ColumnInfo(name = "exdate") val exdate: String? = null,
|
||||||
|
/** This row's `RECURRENCE-ID` anchor; `NULL` on a master. */
|
||||||
|
@ColumnInfo(name = "recurrence_id") val recurrenceId: Instant? = null,
|
||||||
|
/** The series this row overrides; `NULL` on a master. */
|
||||||
|
@ColumnInfo(name = "master_id") val masterId: Long? = null,
|
||||||
|
|
||||||
|
// hierarchy
|
||||||
|
@ColumnInfo(name = "parent_id") val parentId: Long? = null,
|
||||||
|
@ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0,
|
||||||
|
|
||||||
|
// audit
|
||||||
|
@ColumnInfo(name = "created_at") val createdAt: Instant? = null,
|
||||||
|
@ColumnInfo(name = "last_modified") val lastModified: Instant? = null,
|
||||||
|
@ColumnInfo(name = "sequence", defaultValue = "0") val sequence: Int = 0,
|
||||||
|
|
||||||
|
// sync
|
||||||
|
@ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false,
|
||||||
|
/** Tombstone: deleted locally, still owed to a server. */
|
||||||
|
@ColumnInfo(name = "is_deleted", defaultValue = "0") val isDeleted: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Raw unfolded iCalendar lines of every property we do not model, re-emitted
|
||||||
|
* verbatim on write so a round-trip cannot silently lose a field.
|
||||||
|
*/
|
||||||
|
@ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What [TaskAlarmEntity.minutesBefore] counts back from. */
|
||||||
|
enum class AlarmReference { DUE, START }
|
||||||
|
|
||||||
|
/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */
|
||||||
|
@Entity(
|
||||||
|
tableName = "task_alarms",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = TaskEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["task_id"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index(value = ["task_id"])],
|
||||||
|
)
|
||||||
|
data class TaskAlarmEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
@ColumnInfo(name = "id") val id: Long = 0,
|
||||||
|
@ColumnInfo(name = "task_id") val taskId: Long,
|
||||||
|
@ColumnInfo(name = "minutes_before") val minutesBefore: Int,
|
||||||
|
@ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE,
|
||||||
|
@ColumnInfo(name = "message") val message: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Embedded
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list plus its account's display name, which the domain
|
||||||
|
* [de.jeanlucmakiola.agendula.domain.TaskList] carries and groups by.
|
||||||
|
* `null` means a device-only list.
|
||||||
|
*/
|
||||||
|
data class TaskListRow(
|
||||||
|
@Embedded val list: TaskListEntity,
|
||||||
|
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A task plus the three columns of its list the domain
|
||||||
|
* [de.jeanlucmakiola.agendula.domain.Task] carries, so reading a screenful is
|
||||||
|
* one query rather than one per list.
|
||||||
|
*/
|
||||||
|
data class TaskRow(
|
||||||
|
@Embedded val task: TaskEntity,
|
||||||
|
@ColumnInfo(name = "list_name") val listName: String,
|
||||||
|
@ColumnInfo(name = "list_color") val listColor: Int,
|
||||||
|
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.LocalAccount
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
|
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
||||||
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Account type reported for a list attached to one of ours. */
|
||||||
|
const val CALDAV_ACCOUNT_TYPE = "caldav"
|
||||||
|
|
||||||
|
/** Maps Room rows to domain models. Pure + testable, like [de.jeanlucmakiola.agendula.data.tasks.TaskMapper]. */
|
||||||
|
object RoomTaskMapper {
|
||||||
|
|
||||||
|
fun taskList(row: TaskListRow): TaskList = TaskList(
|
||||||
|
id = row.list.id,
|
||||||
|
name = row.list.name,
|
||||||
|
color = row.list.color,
|
||||||
|
// TaskList.accountName is non-null and the lists screen groups by it, so a
|
||||||
|
// list with no account still has to report something to group under.
|
||||||
|
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
||||||
|
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
|
||||||
|
isSynced = row.list.isSynced,
|
||||||
|
isVisible = row.list.isVisible,
|
||||||
|
owner = row.list.owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One occurrence of [row]. [occurrenceStart] is the occurrence's
|
||||||
|
* `RECURRENCE-ID` anchor and `null` for a task that does not recur;
|
||||||
|
* [start] / [due] are that occurrence's resolved times.
|
||||||
|
*/
|
||||||
|
fun task(
|
||||||
|
row: TaskRow,
|
||||||
|
occurrenceStart: Instant? = null,
|
||||||
|
start: Instant? = row.task.dtstart,
|
||||||
|
due: Instant? = row.task.due,
|
||||||
|
distanceFromCurrent: Int? = null,
|
||||||
|
): Task = Task(
|
||||||
|
taskId = row.task.id,
|
||||||
|
listId = row.task.listId,
|
||||||
|
title = row.task.title.orEmpty(),
|
||||||
|
description = row.task.description,
|
||||||
|
location = row.task.location,
|
||||||
|
url = row.task.url,
|
||||||
|
priority = priorityFromICal(row.task.priority),
|
||||||
|
status = row.task.status,
|
||||||
|
percentComplete = row.task.percentComplete,
|
||||||
|
start = start,
|
||||||
|
due = due,
|
||||||
|
isAllDay = row.task.isAllDay,
|
||||||
|
timeZone = row.task.timezone,
|
||||||
|
completedAt = row.task.completedAt,
|
||||||
|
listColor = row.listColor,
|
||||||
|
taskColor = row.task.color,
|
||||||
|
listName = row.listName,
|
||||||
|
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
||||||
|
parentId = row.task.parentId,
|
||||||
|
isRecurring = row.task.isRecurring,
|
||||||
|
occurrenceStart = occurrenceStart,
|
||||||
|
distanceFromCurrent = distanceFromCurrent,
|
||||||
|
created = row.task.createdAt,
|
||||||
|
lastModified = row.task.lastModified,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun exportTask(task: TaskEntity): ExportTask = ExportTask(
|
||||||
|
taskId = task.id,
|
||||||
|
uid = task.uid,
|
||||||
|
title = task.title.orEmpty(),
|
||||||
|
description = task.description,
|
||||||
|
location = task.location,
|
||||||
|
url = task.url,
|
||||||
|
priority = priorityFromICal(task.priority),
|
||||||
|
status = task.status,
|
||||||
|
percentComplete = task.percentComplete,
|
||||||
|
start = task.dtstart,
|
||||||
|
due = task.due,
|
||||||
|
isAllDay = task.isAllDay,
|
||||||
|
completedAt = task.completedAt,
|
||||||
|
created = task.createdAt,
|
||||||
|
lastModified = task.lastModified,
|
||||||
|
rrule = task.rrule,
|
||||||
|
rdate = task.rdate,
|
||||||
|
parentId = task.parentId?.takeIf { it > 0 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row carries a recurrence rule if it has an `RRULE` or an `RDATE`. */
|
||||||
|
val TaskEntity.isRecurring: Boolean
|
||||||
|
get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only
|
||||||
|
* a due date, and RFC 5545 then anchors the recurrence on it — matching how the
|
||||||
|
* dmfs provider instantiated the same series.
|
||||||
|
*/
|
||||||
|
val TaskEntity.recurrenceAnchor: Instant?
|
||||||
|
get() = dtstart ?: due
|
||||||
|
|
||||||
|
/** The rule set of this series, or `null` when it does not recur. */
|
||||||
|
fun TaskEntity.recurrenceSpec(): RecurrenceSpec? {
|
||||||
|
if (!isRecurring) return null
|
||||||
|
val anchor = recurrenceAnchor ?: return null
|
||||||
|
return RecurrenceSpec(
|
||||||
|
rrule = rrule,
|
||||||
|
rdate = rdate,
|
||||||
|
exdate = exdate,
|
||||||
|
anchor = anchor,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
timeZone = timezone,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.InvalidationTracker
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
|
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||||
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** How far either side of now a series is expanded. */
|
||||||
|
private val WINDOW_BACK = 365.days
|
||||||
|
private val WINDOW_FORWARD = 730.days
|
||||||
|
|
||||||
|
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [TasksDataSource] over Agendula's own Room store.
|
||||||
|
*
|
||||||
|
* The one structural difference from [de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
* .AndroidTasksDataSource]: there is no materialised instances table, so a
|
||||||
|
* recurring series is expanded here, at read time, by [RecurrenceExpander].
|
||||||
|
* Nothing above this cares — the repository already filters and sorts in Kotlin.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class RoomTasksDataSource @Inject constructor(
|
||||||
|
private val database: TasksDatabase,
|
||||||
|
) : TasksDataSource {
|
||||||
|
|
||||||
|
private val clock: Clock = Clock.System
|
||||||
|
|
||||||
|
private val tasks get() = database.tasks()
|
||||||
|
private val lists get() = database.taskLists()
|
||||||
|
private val alarms get() = database.alarms()
|
||||||
|
|
||||||
|
// --- reads ----------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun taskLists(): List<TaskList> = lists.lists().map(RoomTaskMapper::taskList)
|
||||||
|
|
||||||
|
override fun tasks(query: TaskQuery): List<Task> {
|
||||||
|
val now = clock.now()
|
||||||
|
val overrides = tasks.allOverrides(query.listId).groupBy { it.masterId }
|
||||||
|
return tasks.tasks(query.listId, query.includeCompleted)
|
||||||
|
.flatMap { occurrencesOf(it, overrides[it.task.id].orEmpty(), now) }
|
||||||
|
.filter { query.includeCompleted || !it.isClosed }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun task(taskId: Long): Task? {
|
||||||
|
val row = tasks.task(taskId) ?: return null
|
||||||
|
// An override row is one occurrence in its own right; it names the
|
||||||
|
// occurrence it replaces rather than expanding to a series.
|
||||||
|
row.task.recurrenceId?.let { return RoomTaskMapper.task(row, occurrenceStart = it) }
|
||||||
|
val now = clock.now()
|
||||||
|
val occurrences = occurrencesOf(row, tasks.overrides(taskId), now)
|
||||||
|
return occurrences.firstOrNull { it.distanceFromCurrent == 0 } ?: occurrences.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun subtasks(parentTaskId: Long): List<Task> {
|
||||||
|
val now = clock.now()
|
||||||
|
return tasks.subtasks(parentTaskId)
|
||||||
|
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exportTasks(listId: Long): List<ExportTask> =
|
||||||
|
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
||||||
|
|
||||||
|
override fun alarms(): Map<Long, TaskReminder> =
|
||||||
|
alarms.all().associate {
|
||||||
|
it.taskId to TaskReminder(
|
||||||
|
minutesBefore = it.minutesBefore,
|
||||||
|
fromStart = it.reference == AlarmReference.START,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every occurrence of [row] inside the expansion window, with any
|
||||||
|
* `RECURRENCE-ID` override substituted for the occurrence it replaces.
|
||||||
|
*
|
||||||
|
* A non-recurring task is its own single occurrence and carries a null
|
||||||
|
* [Task.occurrenceStart], so it keys and edits by task id exactly as before.
|
||||||
|
*/
|
||||||
|
private fun occurrencesOf(row: TaskRow, overrides: List<TaskEntity>, now: Instant): List<Task> {
|
||||||
|
val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row))
|
||||||
|
val window = ExpansionWindow(
|
||||||
|
from = now - WINDOW_BACK,
|
||||||
|
until = now + WINDOW_FORWARD,
|
||||||
|
pivot = now,
|
||||||
|
)
|
||||||
|
val anchors = RecurrenceExpander.expand(spec, window)
|
||||||
|
if (anchors.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
|
||||||
|
val byAnchor = overrides.associateBy { it.recurrenceId }
|
||||||
|
|
||||||
|
return anchors.mapIndexedNotNull { index, anchor ->
|
||||||
|
val override = byAnchor[anchor]
|
||||||
|
if (override != null) {
|
||||||
|
RoomTaskMapper.task(
|
||||||
|
row = row.copy(task = override),
|
||||||
|
occurrenceStart = anchor,
|
||||||
|
start = override.dtstart,
|
||||||
|
due = override.due,
|
||||||
|
distanceFromCurrent = distances[index],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val (start, due) = occurrenceTimes(row.task, anchor)
|
||||||
|
RoomTaskMapper.task(
|
||||||
|
row = row,
|
||||||
|
occurrenceStart = anchor,
|
||||||
|
start = start,
|
||||||
|
due = due,
|
||||||
|
distanceFromCurrent = distances[index],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One occurrence's resolved start and due. A timed series keeps each
|
||||||
|
* occurrence's duration; a due-anchored one has no start to offset from, so
|
||||||
|
* the anchor *is* the due date.
|
||||||
|
*/
|
||||||
|
private fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair<Instant?, Instant?> {
|
||||||
|
if (master.dtstart == null) return null to anchor
|
||||||
|
val length = master.due?.let { it - master.dtstart }
|
||||||
|
return anchor to length?.let { anchor + it }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun insertTask(form: TaskForm): Long {
|
||||||
|
if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}")
|
||||||
|
val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone())
|
||||||
|
return tasks.insert(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateTask(taskId: Long, form: TaskForm) {
|
||||||
|
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
|
||||||
|
tasks.update(TaskFormWriter.apply(current, form, clock.now(), zone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes one occurrence as a `RECURRENCE-ID` override — RFC 5545's model, and
|
||||||
|
* what every other CalDAV client expects to receive. The dmfs provider
|
||||||
|
* detached the occurrence into a brand-new task with its own UID instead,
|
||||||
|
* which is the model least compatible with sync; the override shares its
|
||||||
|
* master's UID, which is exactly what makes it an override.
|
||||||
|
*/
|
||||||
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||||
|
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
|
||||||
|
val now = clock.now()
|
||||||
|
val existing = tasks.override(taskId, occurrenceStart)
|
||||||
|
if (existing != null) {
|
||||||
|
tasks.update(TaskFormWriter.apply(existing, form, now, zone()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||||
|
val fork = TaskFormWriter.apply(
|
||||||
|
newOverride(master, taskId, occurrenceStart, start, due),
|
||||||
|
form,
|
||||||
|
now,
|
||||||
|
zone(),
|
||||||
|
)
|
||||||
|
// The list and parent come from the master: moving one occurrence between
|
||||||
|
// lists or parents is not something the override model expresses.
|
||||||
|
val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId))
|
||||||
|
alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
|
||||||
|
alarms.replaceForTask(
|
||||||
|
taskId,
|
||||||
|
minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||||
|
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId")
|
||||||
|
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticking one occurrence forks a `RECURRENCE-ID` override carrying the
|
||||||
|
* completion — the same model [updateInstance] writes. Writing the status onto
|
||||||
|
* the master instead would close the series: the master is what
|
||||||
|
* [TaskDao.tasks] filters on, so every occurrence, past and future, would
|
||||||
|
* leave every list at once.
|
||||||
|
*/
|
||||||
|
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
|
||||||
|
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete instance $taskId")
|
||||||
|
// Not a series master — an override, or a plain task the caller handed an
|
||||||
|
// anchor for. Either way this row *is* the occurrence.
|
||||||
|
if (master.recurrenceSpec() == null) return setCompleted(taskId, completed)
|
||||||
|
|
||||||
|
val now = clock.now()
|
||||||
|
tasks.override(taskId, occurrenceStart)?.let {
|
||||||
|
tasks.update(TaskFormWriter.completed(it, completed, now))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||||
|
val fork = TaskFormWriter.completed(newOverride(master, taskId, occurrenceStart, start, due), completed, now)
|
||||||
|
val id = tasks.insert(fork)
|
||||||
|
alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A blank override row for one occurrence of [master]: same UID (that is what
|
||||||
|
* makes it an override rather than a separate task), the series fields
|
||||||
|
* stripped, and no `href`/`etag` because the server has never seen it.
|
||||||
|
*/
|
||||||
|
private fun newOverride(
|
||||||
|
master: TaskEntity,
|
||||||
|
masterId: Long,
|
||||||
|
occurrenceStart: Instant,
|
||||||
|
start: Instant?,
|
||||||
|
due: Instant?,
|
||||||
|
): TaskEntity = master.copy(
|
||||||
|
id = 0,
|
||||||
|
masterId = masterId,
|
||||||
|
recurrenceId = occurrenceStart,
|
||||||
|
dtstart = start,
|
||||||
|
due = due,
|
||||||
|
rrule = null,
|
||||||
|
rdate = null,
|
||||||
|
exdate = null,
|
||||||
|
href = null,
|
||||||
|
etag = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard delete for a row no server knows about, tombstone for one that is
|
||||||
|
* still owed to a collection. `master_id` cascades, so deleting a series
|
||||||
|
* takes its overrides with it.
|
||||||
|
*/
|
||||||
|
override fun deleteTask(taskId: Long) {
|
||||||
|
val current = tasks.entity(taskId) ?: return
|
||||||
|
val listAccount = lists.entity(current.listId)?.accountId
|
||||||
|
if (listAccount == null) tasks.delete(taskId) else tasks.markDeleted(taskId, clock.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createLocalList(name: String, color: Int): Long =
|
||||||
|
lists.insert(TaskListEntity(name = name.trim(), color = color))
|
||||||
|
|
||||||
|
override fun updateList(listId: Long, name: String, color: Int) {
|
||||||
|
val current = lists.entity(listId) ?: throw TaskWriteFailedException("update list $listId")
|
||||||
|
// Only an account-backed collection owes a server a PROPPATCH; a
|
||||||
|
// device-only list has nothing to be dirty for.
|
||||||
|
lists.update(
|
||||||
|
current.copy(
|
||||||
|
name = name.trim(),
|
||||||
|
color = color,
|
||||||
|
isDirty = current.accountId != null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `tasks.list_id` is `ON DELETE CASCADE`, so the list's tasks go with it. */
|
||||||
|
override fun deleteList(listId: Long) = lists.delete(listId)
|
||||||
|
|
||||||
|
// --- observation ----------------------------------------------------------
|
||||||
|
|
||||||
|
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||||
|
val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) {
|
||||||
|
override fun onInvalidated(tables: Set<String>) = onChange()
|
||||||
|
}
|
||||||
|
database.invalidationTracker.addObserver(observer)
|
||||||
|
return AutoCloseable { database.invalidationTracker.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zone(): String = ZoneId.systemDefault().id
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
|
||||||
|
/** Reads and writes over `task_alarms`. */
|
||||||
|
@Dao
|
||||||
|
interface TaskAlarmDao {
|
||||||
|
|
||||||
|
/** Every reminder in the store, for one scheduler pass. */
|
||||||
|
@Query("SELECT * FROM task_alarms")
|
||||||
|
fun all(): List<TaskAlarmEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId")
|
||||||
|
fun forTask(taskId: Long): List<TaskAlarmEntity>
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
fun insert(alarm: TaskAlarmEntity): Long
|
||||||
|
|
||||||
|
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
|
||||||
|
fun deleteForTask(taskId: Long): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the task's only reminder, or clear it with `null`. The row that lands is
|
||||||
|
* always a new one — the id is cleared so an alarm lifted off another task
|
||||||
|
* (forking an occurrence copies the master's) inserts instead of colliding.
|
||||||
|
*/
|
||||||
|
@Transaction
|
||||||
|
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) {
|
||||||
|
deleteForTask(taskId)
|
||||||
|
alarm?.let { insert(it.copy(id = 0, taskId = taskId)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and writes over `tasks`.
|
||||||
|
*
|
||||||
|
* Reads split masters from overrides on purpose: [tasks] returns the rows a
|
||||||
|
* recurrence expander expands (a non-recurring task is its own single
|
||||||
|
* occurrence), and [allOverrides] / [overrides] return the
|
||||||
|
* `RECURRENCE-ID` rows that replace individual occurrences. Nothing here
|
||||||
|
* expands anything — that is phase 2's job, in Kotlin.
|
||||||
|
*/
|
||||||
|
@Dao
|
||||||
|
interface TaskDao {
|
||||||
|
|
||||||
|
// --- reads ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master (and non-recurring) rows, optionally narrowed to one list. Closed
|
||||||
|
* tasks — `COMPLETED` and `CANCELLED` — are excluded unless
|
||||||
|
* [includeCompleted]; tombstones always are.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT t.*, l.name AS list_name, l.color AS list_color,
|
||||||
|
a.display_name AS account_display_name
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_lists l ON l.id = t.list_id
|
||||||
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
|
WHERE t.is_deleted = 0
|
||||||
|
AND t.master_id IS NULL
|
||||||
|
AND (:listId IS NULL OR t.list_id = :listId)
|
||||||
|
AND (:includeCompleted = 1 OR t.status NOT IN (2, 3))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun tasks(listId: Long?, includeCompleted: Boolean): List<TaskRow>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT t.*, l.name AS list_name, l.color AS list_color,
|
||||||
|
a.display_name AS account_display_name
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_lists l ON l.id = t.list_id
|
||||||
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
|
WHERE t.id = :taskId AND t.is_deleted = 0
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun task(taskId: Long): TaskRow?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT t.*, l.name AS list_name, l.color AS list_color,
|
||||||
|
a.display_name AS account_display_name
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_lists l ON l.id = t.list_id
|
||||||
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
|
WHERE t.parent_id = :parentTaskId AND t.is_deleted = 0 AND t.master_id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun subtasks(parentTaskId: Long): List<TaskRow>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM tasks WHERE id = :taskId")
|
||||||
|
fun entity(taskId: Long): TaskEntity?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every override, optionally narrowed to one list — read alongside [tasks] so
|
||||||
|
* expansion can replace the occurrences they override in one pass rather than
|
||||||
|
* querying per series.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM tasks
|
||||||
|
WHERE master_id IS NOT NULL AND is_deleted = 0
|
||||||
|
AND (:listId IS NULL OR list_id = :listId)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun allOverrides(listId: Long?): List<TaskEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0")
|
||||||
|
fun overrides(masterId: Long): List<TaskEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT * FROM tasks WHERE master_id = :masterId AND recurrence_id IS :recurrenceId AND is_deleted = 0"
|
||||||
|
)
|
||||||
|
fun override(masterId: Long, recurrenceId: Instant?): TaskEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM tasks WHERE list_id = :listId AND uid = :uid AND recurrence_id IS :recurrenceId")
|
||||||
|
fun byUid(listId: Long, uid: String, recurrenceId: Instant? = null): TaskEntity?
|
||||||
|
|
||||||
|
/** Masters only, tombstones excluded — what an `.ics` export writes. */
|
||||||
|
@Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL")
|
||||||
|
fun exportTasks(listId: Long): List<TaskEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM tasks WHERE is_dirty = 1")
|
||||||
|
fun dirty(): List<TaskEntity>
|
||||||
|
|
||||||
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
fun insert(task: TaskEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
fun update(task: TaskEntity): Int
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE tasks SET status = :status, percent_complete = :percentComplete,
|
||||||
|
completed_at = :completedAt, last_modified = :lastModified, is_dirty = :dirty
|
||||||
|
WHERE id = :taskId
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun setCompletion(
|
||||||
|
taskId: Long,
|
||||||
|
status: TaskStatus,
|
||||||
|
percentComplete: Int?,
|
||||||
|
completedAt: Instant?,
|
||||||
|
lastModified: Instant?,
|
||||||
|
dirty: Boolean,
|
||||||
|
): Int
|
||||||
|
|
||||||
|
/** Hard delete. Used when the row was never on a server. */
|
||||||
|
@Query("DELETE FROM tasks WHERE id = :taskId")
|
||||||
|
fun delete(taskId: Long): Int
|
||||||
|
|
||||||
|
/** Tombstone, for a row a server still knows about. */
|
||||||
|
@Query("UPDATE tasks SET is_deleted = 1, is_dirty = 1, last_modified = :at WHERE id = :taskId")
|
||||||
|
fun markDeleted(taskId: Long, at: Instant?): Int
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import de.jeanlucmakiola.agendula.domain.toICal
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
|
||||||
|
internal fun Instant.forAllDay(allDay: Boolean): Instant =
|
||||||
|
if (!allDay) this
|
||||||
|
else Instant.fromEpochMilliseconds(
|
||||||
|
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a [TaskForm] to a [TaskEntity]. Pure, so the semantics below are
|
||||||
|
* testable on the JVM without a database.
|
||||||
|
*
|
||||||
|
* This is the Room counterpart of
|
||||||
|
* [de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper], which stays for
|
||||||
|
* External mode. It is a separate object rather than a shared one because most
|
||||||
|
* of what that mapper does is work around the provider — clearing `DURATION`
|
||||||
|
* because the provider validates a merged row, writing `STATUS` both ways
|
||||||
|
* because the provider auto-completes at 100% but will not reopen below it. Here
|
||||||
|
* those rules are ours to state directly.
|
||||||
|
*/
|
||||||
|
object TaskFormWriter {
|
||||||
|
|
||||||
|
/** A brand-new task. [uid] is minted by the caller and never null. */
|
||||||
|
fun newTask(form: TaskForm, uid: String, now: Instant, tzId: String): TaskEntity =
|
||||||
|
apply(
|
||||||
|
TaskEntity(listId = form.listId, uid = uid, createdAt = now),
|
||||||
|
form,
|
||||||
|
now,
|
||||||
|
tzId,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** [current] with [form] applied. Identity, recurrence and sync columns are left alone. */
|
||||||
|
fun apply(current: TaskEntity, form: TaskForm, now: Instant, tzId: String): TaskEntity {
|
||||||
|
val percent = form.percentComplete?.coerceIn(0, 100)
|
||||||
|
val timed = !form.isAllDay && (form.start != null || form.due != null)
|
||||||
|
return current.copy(
|
||||||
|
listId = form.listId,
|
||||||
|
title = form.title.trim(),
|
||||||
|
description = form.description?.trim()?.ifBlank { null },
|
||||||
|
priority = form.priority.toICal(),
|
||||||
|
percentComplete = percent,
|
||||||
|
status = statusFor(percent, current.status),
|
||||||
|
completedAt = completedAtFor(percent, current, now),
|
||||||
|
dtstart = form.start?.forAllDay(form.isAllDay),
|
||||||
|
due = form.due?.forAllDay(form.isAllDay),
|
||||||
|
// DUE and DURATION are mutually exclusive (RFC 5545 §3.6.2).
|
||||||
|
duration = null,
|
||||||
|
isAllDay = form.isAllDay,
|
||||||
|
timezone = if (timed) tzId else null,
|
||||||
|
parentId = form.parentId?.takeIf { it > 0 },
|
||||||
|
lastModified = now,
|
||||||
|
isDirty = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The completion triple, for the standalone complete toggle. */
|
||||||
|
fun completed(current: TaskEntity, completed: Boolean, now: Instant): TaskEntity = current.copy(
|
||||||
|
status = if (completed) TaskStatus.COMPLETED else TaskStatus.NEEDS_ACTION,
|
||||||
|
percentComplete = if (completed) 100 else null,
|
||||||
|
completedAt = if (completed) now else null,
|
||||||
|
lastModified = now,
|
||||||
|
isDirty = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A form carrying no percent leaves status alone — the standalone toggle stays
|
||||||
|
* authoritative. Otherwise progress and status move together in both
|
||||||
|
* directions, which is the asymmetry the provider never had: it auto-completed
|
||||||
|
* at 100% but would not reopen below it, stranding a task "done at 75%".
|
||||||
|
*/
|
||||||
|
private fun statusFor(percent: Int?, current: TaskStatus): TaskStatus = when {
|
||||||
|
percent == null -> current
|
||||||
|
percent >= 100 -> TaskStatus.COMPLETED
|
||||||
|
percent > 0 -> TaskStatus.IN_PROCESS
|
||||||
|
else -> TaskStatus.NEEDS_ACTION
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun completedAtFor(percent: Int?, current: TaskEntity, now: Instant): Instant? = when {
|
||||||
|
percent == null -> current.completedAt
|
||||||
|
percent >= 100 -> current.completedAt ?: now
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
|
|
||||||
|
/** Reads and writes over `task_lists`. Synchronous, like the seam above it. */
|
||||||
|
@Dao
|
||||||
|
interface TaskListDao {
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT l.*, a.display_name AS account_display_name
|
||||||
|
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
|
ORDER BY a.display_name, l.sort_order, l.name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun lists(): List<TaskListRow>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT l.*, a.display_name AS account_display_name
|
||||||
|
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
|
WHERE l.id = :listId
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun list(listId: Long): TaskListRow?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM task_lists WHERE id = :listId")
|
||||||
|
fun entity(listId: Long): TaskListEntity?
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM task_lists WHERE id = :listId")
|
||||||
|
fun exists(listId: Long): Int
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
fun insert(list: TaskListEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
fun update(list: TaskListEntity)
|
||||||
|
|
||||||
|
@Query("UPDATE task_lists SET is_visible = :visible WHERE id = :listId")
|
||||||
|
fun setVisible(listId: Long, visible: Boolean)
|
||||||
|
|
||||||
|
/** Attach a list to an account, or detach it with `null`. */
|
||||||
|
@Query("UPDATE task_lists SET account_id = :accountId WHERE id = :listId")
|
||||||
|
fun setAccount(listId: Long, accountId: Long?)
|
||||||
|
|
||||||
|
@Query("DELETE FROM task_lists WHERE id = :listId")
|
||||||
|
fun delete(listId: Long)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agendula's own task store (`docs/OWN-STORE.md`). Four tables, designed from
|
||||||
|
* what the app actually reads and writes plus RFC 5545's `VTODO`.
|
||||||
|
*
|
||||||
|
* Schemas are exported to `app/schemas/` and committed, so a future version can
|
||||||
|
* be migration-tested against this one.
|
||||||
|
*/
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
AccountEntity::class,
|
||||||
|
TaskListEntity::class,
|
||||||
|
TaskEntity::class,
|
||||||
|
TaskAlarmEntity::class,
|
||||||
|
],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = true,
|
||||||
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
abstract class TasksDatabase : RoomDatabase() {
|
||||||
|
abstract fun taskLists(): TaskListDao
|
||||||
|
abstract fun tasks(): TaskDao
|
||||||
|
abstract fun alarms(): TaskAlarmDao
|
||||||
|
abstract fun accounts(): AccountDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NAME = "agendula-tasks.db"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All-day tasks are date-only in iCalendar. OpenTasks reads them back through
|
||||||
|
* `DateTime.toAllDay()`, which discards the time-of-day and resolves the
|
||||||
|
* remaining date against UTC — so the storage convention is **UTC midnight of
|
||||||
|
* the intended calendar date, with a null timezone**. Timed tasks, by contrast,
|
||||||
|
* are ordinary instants rendered in the device's zone.
|
||||||
|
*
|
||||||
|
* These two conventions disagree about which day a given instant is, which is
|
||||||
|
* why every all-day value needs an explicit conversion rather than a raw
|
||||||
|
* `Instant` passed straight through.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** UTC midnight of [date] — the storage form for an all-day value. */
|
||||||
|
fun allDayInstantOf(date: java.time.LocalDate): Instant =
|
||||||
|
Instant.fromEpochMilliseconds(date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar date this instant denotes: read in UTC for [allDay] values,
|
||||||
|
* in [zone] for timed ones.
|
||||||
|
*/
|
||||||
|
fun Instant.calendarDate(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalDate =
|
||||||
|
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
|
||||||
|
.atZone(if (allDay) ZoneOffset.UTC else zone)
|
||||||
|
.toLocalDate()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an instant across the two conventions when the all-day switch flips, so
|
||||||
|
* the day the user is looking at stays put. Without this, toggling all-day off
|
||||||
|
* turns a UTC-midnight value into "02:00" in Berlin (or the previous day, 19:00,
|
||||||
|
* in New York) — reading to the user as "the time reset itself".
|
||||||
|
*/
|
||||||
|
fun Instant.rebasedForAllDay(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): Instant =
|
||||||
|
if (allDay) allDayInstantOf(calendarDate(allDay = false, zone = zone))
|
||||||
|
else Instant.fromEpochMilliseconds(
|
||||||
|
calendarDate(allDay = true).atStartOfDay(zone).toInstant().toEpochMilli(),
|
||||||
|
)
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.floret.domain
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** A task list (the `tasklists` table). Lists group under their account. */
|
/** A task list (the `tasklists` table). Lists group under their account. */
|
||||||
@@ -14,8 +13,9 @@ data class TaskList(
|
|||||||
val isVisible: Boolean,
|
val isVisible: Boolean,
|
||||||
val owner: String?,
|
val owner: String?,
|
||||||
) {
|
) {
|
||||||
/** A device-only list Floret (or another app) created locally, not synced. */
|
/** A device-only list Agendula (or another app) created locally, not synced. */
|
||||||
val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE
|
val isLocal: Boolean
|
||||||
|
get() = accountType == LocalAccount.TYPE || accountType == LocalAccount.DMFS_TYPE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
||||||
@@ -24,11 +24,11 @@ enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
|||||||
enum class Priority { NONE, LOW, MEDIUM, HIGH }
|
enum class Priority { NONE, LOW, MEDIUM, HIGH }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A task occurrence as read from the `instances` view. [id] is the instance row
|
* One occurrence of a task. [taskId] is the underlying task row and the stable
|
||||||
* id; [taskId] is the underlying `tasks._id` and the stable target for edits.
|
* target for edits and navigation; [occurrenceStart] distinguishes occurrences of
|
||||||
|
* the same series.
|
||||||
*/
|
*/
|
||||||
data class Task(
|
data class Task(
|
||||||
val id: Long,
|
|
||||||
val taskId: Long,
|
val taskId: Long,
|
||||||
val listId: Long,
|
val listId: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -48,7 +48,22 @@ data class Task(
|
|||||||
val listName: String?,
|
val listName: String?,
|
||||||
val accountName: String?,
|
val accountName: String?,
|
||||||
val parentId: Long?,
|
val parentId: Long?,
|
||||||
|
/**
|
||||||
|
* This row carries a recurrence rule, so it is one occurrence of a series and
|
||||||
|
* [start]/[due] are that occurrence's resolved times — *not* the master's
|
||||||
|
* anchor. Edits go through
|
||||||
|
* [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance], which
|
||||||
|
* forks a `RECURRENCE-ID` override instead of re-anchoring the series.
|
||||||
|
*/
|
||||||
val isRecurring: Boolean,
|
val isRecurring: Boolean,
|
||||||
|
/**
|
||||||
|
* This occurrence's `RECURRENCE-ID` anchor — what identifies it within its
|
||||||
|
* series — or `null` when the task does not recur. Together with [taskId] it
|
||||||
|
* is a stable, collision-free identity for an occurrence, which is what list
|
||||||
|
* keys and [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance]
|
||||||
|
* address it by.
|
||||||
|
*/
|
||||||
|
val occurrenceStart: Instant? = null,
|
||||||
val distanceFromCurrent: Int?,
|
val distanceFromCurrent: Int?,
|
||||||
val created: Instant?,
|
val created: Instant?,
|
||||||
val lastModified: Instant?,
|
val lastModified: Instant?,
|
||||||
@@ -65,6 +80,15 @@ data class Task(
|
|||||||
val isSubtask: Boolean get() = parentId != null && parentId > 0
|
val isSubtask: Boolean get() = parentId != null && parentId > 0
|
||||||
/** The task's own colour if set, else the list colour. */
|
/** The task's own colour if set, else the list colour. */
|
||||||
val effectiveColor: Int get() = taskColor ?: listColor
|
val effectiveColor: Int get() = taskColor ?: listColor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable identity for a lazy-list key. Two occurrences of one series can show
|
||||||
|
* up in the same list, so [taskId] alone is not unique — and folding
|
||||||
|
* `(taskId, occurrenceStart)` into a Long could collide, which as a Compose
|
||||||
|
* key is a visible bug.
|
||||||
|
*/
|
||||||
|
val occurrenceKey: String
|
||||||
|
get() = if (occurrenceStart == null) "$taskId" else "$taskId@${occurrenceStart.toEpochMilliseconds()}"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */
|
/** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */
|
||||||
@@ -85,22 +109,22 @@ fun priorityFromICal(value: Int?): Priority = when {
|
|||||||
|
|
||||||
/** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */
|
/** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */
|
||||||
fun Priority.toICal(): Int = when (this) {
|
fun Priority.toICal(): Int = when (this) {
|
||||||
Priority.NONE -> TasksContract.PRIORITY_NONE
|
Priority.NONE -> PRIORITY_NONE
|
||||||
Priority.HIGH -> 1
|
Priority.HIGH -> 1
|
||||||
Priority.MEDIUM -> 5
|
Priority.MEDIUM -> 5
|
||||||
Priority.LOW -> 9
|
Priority.LOW -> 9
|
||||||
}
|
}
|
||||||
|
|
||||||
fun statusFromInt(value: Int?): TaskStatus = when (value) {
|
fun statusFromInt(value: Int?): TaskStatus = when (value) {
|
||||||
TasksContract.STATUS_IN_PROCESS -> TaskStatus.IN_PROCESS
|
ICalStatus.IN_PROCESS -> TaskStatus.IN_PROCESS
|
||||||
TasksContract.STATUS_COMPLETED -> TaskStatus.COMPLETED
|
ICalStatus.COMPLETED -> TaskStatus.COMPLETED
|
||||||
TasksContract.STATUS_CANCELLED -> TaskStatus.CANCELLED
|
ICalStatus.CANCELLED -> TaskStatus.CANCELLED
|
||||||
else -> TaskStatus.NEEDS_ACTION
|
else -> TaskStatus.NEEDS_ACTION
|
||||||
}
|
}
|
||||||
|
|
||||||
fun TaskStatus.toInt(): Int = when (this) {
|
fun TaskStatus.toInt(): Int = when (this) {
|
||||||
TaskStatus.NEEDS_ACTION -> TasksContract.STATUS_NEEDS_ACTION
|
TaskStatus.NEEDS_ACTION -> ICalStatus.NEEDS_ACTION
|
||||||
TaskStatus.IN_PROCESS -> TasksContract.STATUS_IN_PROCESS
|
TaskStatus.IN_PROCESS -> ICalStatus.IN_PROCESS
|
||||||
TaskStatus.COMPLETED -> TasksContract.STATUS_COMPLETED
|
TaskStatus.COMPLETED -> ICalStatus.COMPLETED
|
||||||
TaskStatus.CANCELLED -> TasksContract.STATUS_CANCELLED
|
TaskStatus.CANCELLED -> ICalStatus.CANCELLED
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iCalendar `STATUS` values for a `VTODO`, as integers.
|
||||||
|
*
|
||||||
|
* These live in `domain` rather than being read out of a provider contract: the
|
||||||
|
* numbering is Agendula's own storage encoding as much as it is dmfs's, and the
|
||||||
|
* domain layer must not depend on the data layer to map its own enums.
|
||||||
|
*/
|
||||||
|
object ICalStatus {
|
||||||
|
const val NEEDS_ACTION = 0
|
||||||
|
const val IN_PROCESS = 1
|
||||||
|
const val COMPLETED = 2
|
||||||
|
const val CANCELLED = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Priority 0 means "no priority"; 1 is highest, 9 lowest (RFC 5545 §3.8.1.9). */
|
||||||
|
const val PRIORITY_NONE = 0
|
||||||
|
|
||||||
|
/** How a device-only list identifies its (non-existent) account. */
|
||||||
|
object LocalAccount {
|
||||||
|
/** Shown as the section header above device-only lists. */
|
||||||
|
const val NAME = "Local"
|
||||||
|
|
||||||
|
/** What Agendula's own store reports for a list with no account. */
|
||||||
|
const val TYPE = "local"
|
||||||
|
|
||||||
|
/** What a dmfs-derived provider reports in External mode. */
|
||||||
|
const val DMFS_TYPE = "org.dmfs.account.LOCAL"
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.domain
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package de.jeanlucmakiola.floret.domain
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A validated create/edit form. Kept free of Android types so ViewModels and
|
* A validated create/edit form. Kept free of Android types so ViewModels and
|
||||||
* tests can build and validate it on the JVM. The data layer turns it into
|
* tests can build and validate it on the JVM. The data layer turns it into
|
||||||
* provider ContentValues ([de.jeanlucmakiola.floret.data.tasks.TaskWriteMapper]).
|
* provider ContentValues ([de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper]).
|
||||||
*/
|
*/
|
||||||
data class TaskForm(
|
data class TaskForm(
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.domain
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.domain
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
/** Default task ordering, extracted so it's unit-testable without the repository. */
|
/** Default task ordering, extracted so it's unit-testable without the repository. */
|
||||||
object TaskSorting {
|
object TaskSorting {
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.domain.export
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One task as it goes out to iCalendar — a **master** task, not an occurrence.
|
||||||
|
*
|
||||||
|
* Deliberately not [de.jeanlucmakiola.agendula.domain.Task]. That model is read
|
||||||
|
* from the `instances` view, where a recurring task appears once per occurrence
|
||||||
|
* with resolved times and no rule; exporting from it would write the same task
|
||||||
|
* fifty times and lose the RRULE that generated them. Export reads the `tasks`
|
||||||
|
* table instead, and needs two fields the UI never asks for ([uid], [rrule]).
|
||||||
|
*/
|
||||||
|
data class ExportTask(
|
||||||
|
/** `tasks._id` — the fallback identity when [uid] is absent. */
|
||||||
|
val taskId: Long,
|
||||||
|
/**
|
||||||
|
* The iCalendar UID, or `null` for a task created on this device and never
|
||||||
|
* synced. The dmfs provider only lets a *sync adapter* assign one, so in Local
|
||||||
|
* mode this is null for everything — see [ICalendarWriter.uidFor], which
|
||||||
|
* synthesises a stable substitute rather than emitting a VTODO with no UID.
|
||||||
|
*/
|
||||||
|
val uid: String?,
|
||||||
|
val title: String,
|
||||||
|
val description: String?,
|
||||||
|
val location: String?,
|
||||||
|
val url: String?,
|
||||||
|
val priority: Priority,
|
||||||
|
val status: TaskStatus,
|
||||||
|
val percentComplete: Int?,
|
||||||
|
val start: Instant?,
|
||||||
|
val due: Instant?,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val completedAt: Instant?,
|
||||||
|
val created: Instant?,
|
||||||
|
val lastModified: Instant?,
|
||||||
|
/** Raw `RRULE` value as stored, without the `RRULE:` name. Null when non-recurring. */
|
||||||
|
val rrule: String?,
|
||||||
|
/** Raw `RDATE` value as stored. Null when absent. */
|
||||||
|
val rdate: String?,
|
||||||
|
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
|
||||||
|
val parentId: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A task list and everything in it, ready to become one `.ics` document. */
|
||||||
|
data class ExportList(
|
||||||
|
val listId: Long,
|
||||||
|
val name: String,
|
||||||
|
val accountName: String,
|
||||||
|
val tasks: List<ExportTask>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A single file the export produced: [fileName] and its finished bytes. */
|
||||||
|
data class ExportDocument(
|
||||||
|
val fileName: String,
|
||||||
|
val content: ByteArray,
|
||||||
|
) {
|
||||||
|
// ByteArray gets identity equals/hashCode, which makes this data class lie.
|
||||||
|
override fun equals(other: Any?): Boolean =
|
||||||
|
this === other ||
|
||||||
|
(other is ExportDocument && fileName == other.fileName && content.contentEquals(other.content))
|
||||||
|
|
||||||
|
override fun hashCode(): Int = 31 * fileName.hashCode() + content.contentHashCode()
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.domain.export
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||||
|
import de.jeanlucmakiola.agendula.domain.toICal
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a task list as an RFC 5545 `VCALENDAR` of `VTODO` components.
|
||||||
|
*
|
||||||
|
* Pure Kotlin and deliberately free of any Android type, so the format — the part
|
||||||
|
* that decides whether an exported backup can actually be read again — is
|
||||||
|
* unit-testable on the JVM. Serialising tasks is task-domain and stays here; the
|
||||||
|
* SAF/file plumbing that carries the bytes out is not, and lives in the data
|
||||||
|
* layer (and is the piece `docs/STORAGE-AND-SYNC.md` marks as a floret-kit
|
||||||
|
* candidate).
|
||||||
|
*
|
||||||
|
* **Times are always written in UTC.** Emitting a local `TZID` would oblige us to
|
||||||
|
* also emit a matching `VTIMEZONE` component with its full transition rules, and
|
||||||
|
* a `TZID` referencing an absent definition is what actually breaks importers. UTC
|
||||||
|
* is unambiguous and universally accepted, so the exported instant is exact even
|
||||||
|
* though the original wall-clock zone is not carried. All-day values keep their
|
||||||
|
* `VALUE=DATE` form and stay date-only, which is the only representation that
|
||||||
|
* survives a timezone change intact.
|
||||||
|
*/
|
||||||
|
object ICalendarWriter {
|
||||||
|
|
||||||
|
private const val PRODUCT_ID = "-//Jean-Luc Makiola//Agendula//EN"
|
||||||
|
|
||||||
|
/** RFC 5545 caps a content line at 75 octets, excluding the CRLF. */
|
||||||
|
private const val MAX_LINE_OCTETS = 75
|
||||||
|
|
||||||
|
private val DATE = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||||
|
private val DATE_TIME_UTC = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
|
||||||
|
|
||||||
|
/** Serialises [list] to a complete `.ics` document. */
|
||||||
|
fun write(list: ExportList): String = buildString {
|
||||||
|
line("BEGIN:VCALENDAR")
|
||||||
|
line("VERSION:2.0")
|
||||||
|
line("PRODID:$PRODUCT_ID")
|
||||||
|
line("CALSCALE:GREGORIAN")
|
||||||
|
// Non-standard but near-universally understood, and the only way the list's
|
||||||
|
// name survives into a calendar app. Importers that don't know it skip it.
|
||||||
|
property("X-WR-CALNAME", list.name)
|
||||||
|
|
||||||
|
// Parents must be addressable by UID, and a subtask may appear before its
|
||||||
|
// parent in the list, so resolve every id up front.
|
||||||
|
val uidsById = list.tasks.associate { it.taskId to uidFor(it) }
|
||||||
|
list.tasks.forEach { task -> writeTask(task, uidsById) }
|
||||||
|
|
||||||
|
line("END:VCALENDAR")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UID to write for [task].
|
||||||
|
*
|
||||||
|
* Local tasks have none, because nothing assigns one: the provider never
|
||||||
|
* generates a `_uid` itself, and our write path does not set it either, so in
|
||||||
|
* Local mode every task arrives here with `uid == null`.
|
||||||
|
*
|
||||||
|
* Note this is a gap we leave open, not one the provider imposes.
|
||||||
|
* `processors/tasks/Validating.java:92-96` restricts `_uid` to sync adapters
|
||||||
|
* on *update* only; `insert` does not check it, so any caller may assign a UID
|
||||||
|
* at creation. Doing that would be strictly better than synthesising here —
|
||||||
|
* see `docs/SYNC.md`, where it is a phase-1 item, because a real UID minted at
|
||||||
|
* creation is what lets a local task later be pushed to CalDAV without
|
||||||
|
* duplicating.
|
||||||
|
*
|
||||||
|
* Until then: a VTODO without a UID is invalid and, worse, un-mergeable —
|
||||||
|
* re-importing a backup would duplicate every task instead of matching it. So
|
||||||
|
* we synthesise one from the row id, which is stable for as long as the row
|
||||||
|
* is, and tag it with our own domain so a synthesised UID is recognisable as
|
||||||
|
* such.
|
||||||
|
*/
|
||||||
|
fun uidFor(task: ExportTask): String =
|
||||||
|
task.uid?.takeIf { it.isNotBlank() } ?: "agendula-${task.taskId}@jeanlucmakiola.de"
|
||||||
|
|
||||||
|
private fun StringBuilder.writeTask(task: ExportTask, uidsById: Map<Long, String>) {
|
||||||
|
line("BEGIN:VTODO")
|
||||||
|
property("UID", uidFor(task))
|
||||||
|
// DTSTAMP is mandatory. It means "when this representation was written",
|
||||||
|
// which for an export is now — not the task's own timestamps.
|
||||||
|
property("DTSTAMP", formatUtc(Instant.fromEpochMilliseconds(System.currentTimeMillis())))
|
||||||
|
property("SUMMARY", task.title)
|
||||||
|
|
||||||
|
task.description?.takeIf { it.isNotBlank() }?.let { property("DESCRIPTION", it) }
|
||||||
|
task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) }
|
||||||
|
// URL is a URI, not TEXT: it must not be escaped like one.
|
||||||
|
task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) }
|
||||||
|
|
||||||
|
task.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
|
||||||
|
task.due?.let { dateProperty("DUE", it, task.isAllDay) }
|
||||||
|
task.created?.let { rawProperty("CREATED", formatUtc(it)) }
|
||||||
|
task.lastModified?.let { rawProperty("LAST-MODIFIED", formatUtc(it)) }
|
||||||
|
// COMPLETED is defined as UTC date-time even for an all-day task.
|
||||||
|
task.completedAt?.let { rawProperty("COMPLETED", formatUtc(it)) }
|
||||||
|
|
||||||
|
rawProperty("STATUS", task.status.toICalName())
|
||||||
|
if (task.priority != Priority.NONE) rawProperty("PRIORITY", task.priority.toICal().toString())
|
||||||
|
task.percentComplete?.coerceIn(0, 100)?.let { rawProperty("PERCENT-COMPLETE", it.toString()) }
|
||||||
|
|
||||||
|
// Passed through as stored. The provider keeps these in iCalendar form
|
||||||
|
// already, and re-deriving them would risk changing what the user's
|
||||||
|
// recurrence actually means.
|
||||||
|
task.rrule?.takeIf { it.isNotBlank() }?.let { rawProperty("RRULE", it) }
|
||||||
|
task.rdate?.takeIf { it.isNotBlank() }?.let { rawProperty("RDATE", it) }
|
||||||
|
|
||||||
|
// Only emit the link when the parent is in this same document; a
|
||||||
|
// RELATED-TO pointing outside the file would dangle on import.
|
||||||
|
task.parentId?.let { uidsById[it] }?.let {
|
||||||
|
property("RELATED-TO;RELTYPE=PARENT", it)
|
||||||
|
}
|
||||||
|
|
||||||
|
line("END:VTODO")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun StringBuilder.dateProperty(name: String, instant: Instant, allDay: Boolean) {
|
||||||
|
if (allDay) {
|
||||||
|
// Read in UTC, matching the storage convention (see AllDayTime): an
|
||||||
|
// all-day value *is* UTC midnight of the intended calendar date.
|
||||||
|
rawProperty("$name;VALUE=DATE", instant.calendarDate(allDay = true).format(DATE))
|
||||||
|
} else {
|
||||||
|
rawProperty(name, formatUtc(instant))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatUtc(instant: Instant): String =
|
||||||
|
java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds())
|
||||||
|
.atZone(ZoneOffset.UTC)
|
||||||
|
.format(DATE_TIME_UTC)
|
||||||
|
|
||||||
|
/** A property whose value is TEXT, and so must be escaped. */
|
||||||
|
private fun StringBuilder.property(name: String, value: String) =
|
||||||
|
line("$name:${escapeText(value)}")
|
||||||
|
|
||||||
|
/** A property whose value is already in its final form (dates, numbers, URIs, rules). */
|
||||||
|
private fun StringBuilder.rawProperty(name: String, value: String) = line("$name:$value")
|
||||||
|
|
||||||
|
private fun StringBuilder.line(content: String) {
|
||||||
|
append(fold(content))
|
||||||
|
append(CRLF)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes a TEXT value per RFC 5545 §3.3.11. Backslash first, or it would
|
||||||
|
* double the backslashes introduced by the later replacements.
|
||||||
|
*/
|
||||||
|
internal fun escapeText(value: String): String = value
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace(";", "\\;")
|
||||||
|
.replace(",", "\\,")
|
||||||
|
.replace("\r\n", "\\n")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\n")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds a content line to at most [MAX_LINE_OCTETS] octets, continuing with
|
||||||
|
* CRLF + a single space.
|
||||||
|
*
|
||||||
|
* Counted in **octets, not characters** — the limit is defined that way, and an
|
||||||
|
* emoji in a task title is four of them. Splits are kept on character
|
||||||
|
* boundaries so folding can never cut a UTF-8 sequence in half and corrupt the
|
||||||
|
* text; an importer unfolds by removing CRLF + leading whitespace, recovering
|
||||||
|
* the original exactly.
|
||||||
|
*/
|
||||||
|
internal fun fold(content: String): String {
|
||||||
|
if (content.utf8Size() <= MAX_LINE_OCTETS) return content
|
||||||
|
|
||||||
|
val out = StringBuilder()
|
||||||
|
var octets = 0
|
||||||
|
// First line takes the full budget; every continuation loses one octet to
|
||||||
|
// the leading space.
|
||||||
|
var budget = MAX_LINE_OCTETS
|
||||||
|
var index = 0
|
||||||
|
while (index < content.length) {
|
||||||
|
val codePoint = content.codePointAt(index)
|
||||||
|
val charCount = Character.charCount(codePoint)
|
||||||
|
val size = String(Character.toChars(codePoint)).utf8Size()
|
||||||
|
if (octets + size > budget) {
|
||||||
|
out.append(CRLF).append(' ')
|
||||||
|
octets = 0
|
||||||
|
budget = MAX_LINE_OCTETS - 1
|
||||||
|
}
|
||||||
|
out.append(content, index, index + charCount)
|
||||||
|
octets += size
|
||||||
|
index += charCount
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size
|
||||||
|
|
||||||
|
private fun TaskStatus.toICalName(): String = when (this) {
|
||||||
|
TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION"
|
||||||
|
TaskStatus.IN_PROCESS -> "IN-PROCESS"
|
||||||
|
TaskStatus.COMPLETED -> "COMPLETED"
|
||||||
|
TaskStatus.CANCELLED -> "CANCELLED"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CRLF = "\r\n"
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.domain.recurrence
|
||||||
|
|
||||||
|
import org.dmfs.rfc5545.DateTime
|
||||||
|
import org.dmfs.rfc5545.recur.RecurrenceRule
|
||||||
|
import org.dmfs.rfc5545.recurrenceset.RecurrenceList
|
||||||
|
import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter
|
||||||
|
import org.dmfs.rfc5545.recurrenceset.RecurrenceSet
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.TimeZone
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
private const val MILLIS_PER_SECOND = 1000L
|
||||||
|
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** The rule set of one task series, as stored. All strings are raw iCalendar values. */
|
||||||
|
data class RecurrenceSpec(
|
||||||
|
val rrule: String?,
|
||||||
|
val rdate: String?,
|
||||||
|
val exdate: String?,
|
||||||
|
/** The series anchor: DTSTART if present, else DUE. Never null for a recurring task. */
|
||||||
|
val anchor: Instant,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
/** IANA zone id the anchor is expressed in; null means floating/local. */
|
||||||
|
val timeZone: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window expansion is bounded to: [from] inclusive, [until] exclusive, and
|
||||||
|
* never more than [maxOccurrences] results — so an unbounded `RRULE` terminates.
|
||||||
|
*
|
||||||
|
* [pivot] is where "now" sits inside the window, and it is what the occurrence
|
||||||
|
* budget is spent around. Without it a series firing more often than about
|
||||||
|
* once a day exhausts [maxOccurrences] inside the past alone — an eight-hourly
|
||||||
|
* task would stop expanding months before today, so it would never appear in
|
||||||
|
* Today or Upcoming at all. At most a quarter of the budget goes to occurrences
|
||||||
|
* before [pivot], and the most recent of those are the ones kept.
|
||||||
|
*/
|
||||||
|
data class ExpansionWindow(
|
||||||
|
val from: Instant,
|
||||||
|
val until: Instant,
|
||||||
|
val maxOccurrences: Int = 500,
|
||||||
|
val pivot: Instant = from,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands a task series into its occurrences in memory, over `lib-recur`.
|
||||||
|
*
|
||||||
|
* There is no materialised instances table behind this: the repository already
|
||||||
|
* filters and sorts in Kotlin, so occurrences are computed at read time and the
|
||||||
|
* whole class of staleness bugs a cached table brings never exists.
|
||||||
|
*/
|
||||||
|
object RecurrenceExpander {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every occurrence of [spec] inside [window], as its `RECURRENCE-ID` anchor —
|
||||||
|
* the instant identifying that occurrence within the series. Ascending,
|
||||||
|
* deduplicated, `EXDATE` applied.
|
||||||
|
*
|
||||||
|
* The anchor itself is always part of the set (RFC 5545 §3.8.5.3: `DTSTART`
|
||||||
|
* is the first instance), so a spec with no rule and no `RDATE` expands to
|
||||||
|
* exactly its anchor. A malformed `RRULE`, `RDATE` or `EXDATE` is dropped
|
||||||
|
* rather than thrown — a task whose stored rule cannot be parsed still has
|
||||||
|
* to appear.
|
||||||
|
*
|
||||||
|
* [floatingZone] resolves a series with no [RecurrenceSpec.timeZone]; it is a
|
||||||
|
* parameter rather than a `TimeZone.getDefault()` lookup so expansion is
|
||||||
|
* deterministic under test.
|
||||||
|
*/
|
||||||
|
fun expand(
|
||||||
|
spec: RecurrenceSpec,
|
||||||
|
window: ExpansionWindow,
|
||||||
|
floatingZone: ZoneId = ZoneId.systemDefault(),
|
||||||
|
): List<Instant> {
|
||||||
|
val zone = zoneOf(spec, floatingZone)
|
||||||
|
val anchorMillis = anchorMillis(spec)
|
||||||
|
|
||||||
|
val set = RecurrenceSet()
|
||||||
|
spec.rrule.orNull()?.let { raw -> ruleOf(raw, zone)?.let { set.addInstances(RecurrenceRuleAdapter(it)) } }
|
||||||
|
spec.rdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addInstances) }
|
||||||
|
spec.exdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addExceptions) }
|
||||||
|
|
||||||
|
val iterator = set.iterator(zone, anchorMillis, window.until.toEpochMilliseconds())
|
||||||
|
iterator.fastForward(window.from.toEpochMilliseconds())
|
||||||
|
|
||||||
|
// Occurrences arrive ascending, so everything before the pivot lands first
|
||||||
|
// and `past` is final by the time the first future one appears. Past is a
|
||||||
|
// sliding window (the newest are the ones worth keeping); the rest of the
|
||||||
|
// budget then goes to the future, undiminished when there is no past.
|
||||||
|
val pastCap = window.maxOccurrences / 4
|
||||||
|
val past = ArrayDeque<Instant>()
|
||||||
|
val future = ArrayList<Instant>()
|
||||||
|
var previous = Long.MIN_VALUE
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val millis = iterator.next()
|
||||||
|
if (millis == previous) continue
|
||||||
|
previous = millis
|
||||||
|
val at = Instant.fromEpochMilliseconds(millis)
|
||||||
|
if (at < window.pivot) {
|
||||||
|
if (past.size == pastCap) past.removeFirst()
|
||||||
|
if (pastCap > 0) past.addLast(at)
|
||||||
|
} else {
|
||||||
|
future += at
|
||||||
|
if (past.size + future.size >= window.maxOccurrences) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return past + future
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the current occurrence in an ascending [occurrences] list: the
|
||||||
|
* first one at or after [now], or the last one when the whole series is in
|
||||||
|
* the past. `-1` when there are no occurrences at all.
|
||||||
|
*/
|
||||||
|
fun currentOccurrenceIndex(occurrences: List<Instant>, now: Instant): Int {
|
||||||
|
if (occurrences.isEmpty()) return -1
|
||||||
|
val next = occurrences.indexOfFirst { it >= now }
|
||||||
|
return if (next >= 0) next else occurrences.lastIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each occurrence's distance from the current one, index-aligned with
|
||||||
|
* [occurrences]. `0` is the current occurrence, negative counts back into the
|
||||||
|
* past and positive counts forward — the convention `Task.distanceFromCurrent`
|
||||||
|
* carries and the data sources pick the current occurrence by.
|
||||||
|
*
|
||||||
|
* Purely positional: unlike the dmfs provider, which drove the same number off
|
||||||
|
* each instance's closed state, this knows only times. Completion-aware
|
||||||
|
* refinement belongs where overrides carry their status.
|
||||||
|
*/
|
||||||
|
fun distancesFromCurrent(occurrences: List<Instant>, now: Instant): List<Int> {
|
||||||
|
val current = currentOccurrenceIndex(occurrences, now)
|
||||||
|
if (current < 0) return emptyList()
|
||||||
|
return occurrences.indices.map { it - current }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone {
|
||||||
|
if (spec.isAllDay) return TimeZone.getTimeZone(ZoneId.of("UTC"))
|
||||||
|
val stored = spec.timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() }
|
||||||
|
return TimeZone.getTimeZone(stored ?: floatingZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All-day series are date-anchored: pin the anchor to UTC midnight, as it is
|
||||||
|
* stored. A timed one is floored to the second, because RFC 5545 DATE-TIME
|
||||||
|
* has no sub-second field — carrying millis in makes lib-recur emit the raw
|
||||||
|
* anchor *and* its truncated self, doubling the first occurrence, and mints
|
||||||
|
* `RECURRENCE-ID`s no other client could address.
|
||||||
|
*/
|
||||||
|
private fun anchorMillis(spec: RecurrenceSpec): Long {
|
||||||
|
val millis = spec.anchor.toEpochMilliseconds()
|
||||||
|
val unit = if (spec.isAllDay) MILLIS_PER_DAY else MILLIS_PER_SECOND
|
||||||
|
return Math.floorDiv(millis, unit) * unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ruleOf(value: String, zone: TimeZone): RecurrenceRule? = runCatching {
|
||||||
|
RecurrenceRule(value).also { rule ->
|
||||||
|
// lib-recur refuses to iterate a floating UNTIL against a zoned start,
|
||||||
|
// and RFC 5545 §3.3.10 forbids that pairing — but stored rules carry it
|
||||||
|
// anyway. Re-read the UNTIL's local fields in the series zone.
|
||||||
|
val until = rule.until
|
||||||
|
if (until != null && until.isFloating) {
|
||||||
|
rule.until = DateTime(
|
||||||
|
zone,
|
||||||
|
until.year,
|
||||||
|
until.month,
|
||||||
|
until.dayOfMonth,
|
||||||
|
until.hours,
|
||||||
|
until.minutes,
|
||||||
|
until.seconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun datesOf(value: String, zone: TimeZone): RecurrenceList? =
|
||||||
|
runCatching { RecurrenceList(value, zone) }.getOrNull()
|
||||||
|
|
||||||
|
private fun String?.orNull(): String? = this?.trim()?.ifEmpty { null }
|
||||||
|
}
|
||||||
119
app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt
Normal file
119
app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||||
|
import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
|
||||||
|
import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingScreen
|
||||||
|
import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingViewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App root: gates on the tasks-provider permission, then hands off to
|
||||||
|
* [AgendulaNavHost] (lists → task list → detail / edit).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RootScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
permissionViewModel: PermissionViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val permission by permissionViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
) { permissionViewModel.refresh() }
|
||||||
|
|
||||||
|
// Re-check on every resume, not just after the in-app request: the user may
|
||||||
|
// have granted the permission (or installed a provider) in system Settings and
|
||||||
|
// come back, and otherwise the gate would hold until the process restarts.
|
||||||
|
OnResume { permissionViewModel.refresh() }
|
||||||
|
|
||||||
|
// Neither gate can show in OWN mode (that store is always READY), so the way
|
||||||
|
// out of one is always our own store. Without it a user whose provider app
|
||||||
|
// went away is held on this screen with Settings behind it.
|
||||||
|
val fallback = stringResource(R.string.onboarding_use_own_store)
|
||||||
|
|
||||||
|
when (permission.status) {
|
||||||
|
ProviderStatus.NO_PROVIDER -> Gate(
|
||||||
|
modifier = modifier,
|
||||||
|
title = stringResource(R.string.onboarding_no_provider_title),
|
||||||
|
body = stringResource(R.string.onboarding_no_provider_body),
|
||||||
|
secondaryAction = fallback,
|
||||||
|
onSecondaryAction = permissionViewModel::useOwnStore,
|
||||||
|
)
|
||||||
|
ProviderStatus.NEEDS_PERMISSION -> Gate(
|
||||||
|
modifier = modifier,
|
||||||
|
title = stringResource(R.string.onboarding_permission_title),
|
||||||
|
body = stringResource(R.string.onboarding_permission_body),
|
||||||
|
action = stringResource(R.string.onboarding_permission_button),
|
||||||
|
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
|
||||||
|
secondaryAction = fallback,
|
||||||
|
onSecondaryAction = permissionViewModel::useOwnStore,
|
||||||
|
)
|
||||||
|
ProviderStatus.READY -> ReadyGate(modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Second one-time gate after the provider grant: the reminder onboarding step.
|
||||||
|
* [ReminderOnboardingViewModel.onboardingDone] is null until DataStore's first
|
||||||
|
* emission — render nothing for that frame rather than flash the wrong screen.
|
||||||
|
* A cross-fade eases the hand-off to the app instead of snapping.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ReadyGate(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onboardingViewModel: ReminderOnboardingViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val done by onboardingViewModel.onboardingDone.collectAsStateWithLifecycle()
|
||||||
|
Crossfade(targetState = done, label = "reminderOnboardingGate") { state ->
|
||||||
|
when (state) {
|
||||||
|
true -> AgendulaNavHost(modifier = modifier)
|
||||||
|
false -> ReminderOnboardingScreen(
|
||||||
|
onFinished = onboardingViewModel::finish,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Gate(
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
action: String? = null,
|
||||||
|
onAction: () -> Unit = {},
|
||||||
|
secondaryAction: String? = null,
|
||||||
|
onSecondaryAction: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (action != null) Button(onClick = onAction) { Text(action) }
|
||||||
|
if (secondaryAction != null) TextButton(onClick = onSecondaryAction) { Text(secondaryAction) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.common
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
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.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -22,24 +23,10 @@ import androidx.compose.ui.graphics.luminance
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
|
||||||
* Soften a raw provider colour toward a pastel that fits the active theme —
|
|
||||||
* keeps the hue (lists stay recognisable), caps saturation so harsh sync
|
|
||||||
* colours stop screaming, pins brightness to read on light and dark. Copied
|
|
||||||
* from Calendula so the families' colours behave identically.
|
|
||||||
*/
|
|
||||||
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 task list: a neutral round chip holding a checklist glyph
|
* Leading avatar for a task list: a neutral round chip holding a checklist glyph
|
||||||
* tinted in the list's (pastelised) colour — the Calendula calendar-chip pattern,
|
* tinted in the list's (pastelised) colour — the Calendula calendar-chip pattern,
|
||||||
* with a task icon for Floret.
|
* with a task icon for Agendula.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ListColorChip(color: Int, modifier: Modifier = Modifier) {
|
fun ListColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colours offered when creating or editing a task list.
|
||||||
|
*
|
||||||
|
* Raw ARGB, the way a CalDAV server sends one — every surface that draws a list
|
||||||
|
* colour runs it through
|
||||||
|
* [de.jeanlucmakiola.floret.components.pastelize] first, so these are hues
|
||||||
|
* rather than final fills, chosen to stay distinguishable after that pass. A
|
||||||
|
* list can still carry any colour a server gives it; this is only the set the
|
||||||
|
* app hands out.
|
||||||
|
*/
|
||||||
|
val ListPalette: List<Int> = listOf(
|
||||||
|
0xFF7A5C6B.toInt(), // mauve — Agendula's own seed
|
||||||
|
0xFFD7484A.toInt(), // red
|
||||||
|
0xFFE8743B.toInt(), // orange
|
||||||
|
0xFFE0A32E.toInt(), // amber
|
||||||
|
0xFF7CA83E.toInt(), // olive
|
||||||
|
0xFF35A06A.toInt(), // green
|
||||||
|
0xFF19938C.toInt(), // teal
|
||||||
|
0xFF2A9BC4.toInt(), // cyan
|
||||||
|
0xFF3C74C8.toInt(), // blue
|
||||||
|
0xFF6A5CC0.toInt(), // indigo
|
||||||
|
0xFF9455B8.toInt(), // purple
|
||||||
|
0xFFC94F8E.toInt(), // pink
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What a new list gets before the user picks anything. */
|
||||||
|
val DefaultListColor: Int = ListPalette.first()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [block] on every `ON_RESUME`.
|
||||||
|
*
|
||||||
|
* For state the app cannot observe because it is granted, revoked or installed
|
||||||
|
* outside it — a runtime permission, an exact-alarm allowance, a provider app —
|
||||||
|
* which otherwise stays stale until the process restarts.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OnResume(block: () -> Unit) {
|
||||||
|
val current by rememberUpdatedState(block)
|
||||||
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
|
DisposableEffect(lifecycle) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) current()
|
||||||
|
}
|
||||||
|
lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zone helpers shared by the date/time pickers. All-day conversions live in
|
||||||
|
* [de.jeanlucmakiola.agendula.domain.AllDayTime] — these cover the timed case,
|
||||||
|
* where the device zone is the right frame of reference.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private val zone: ZoneId get() = ZoneId.systemDefault()
|
||||||
|
|
||||||
|
internal fun Instant.toLocalTime(): LocalTime =
|
||||||
|
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
|
||||||
|
|
||||||
|
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
|
||||||
|
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.common
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -21,7 +22,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.floret.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Priority has no semantic Material role (M3 only ships `error` for danger), so
|
* Priority has no semantic Material role (M3 only ships `error` for danger), so
|
||||||
@@ -44,6 +45,27 @@ fun priorityFill(priority: Priority, dark: Boolean): Color {
|
|||||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground tint (icon/text) for a priority level — the same red/amber/green as
|
||||||
|
* [priorityFill], but kept saturated enough to read as a small glyph directly on
|
||||||
|
* the surface rather than as a soft fill. Used by the task list's quiet meta line,
|
||||||
|
* where priority is a tinted flag instead of a filled pill. [Priority.NONE] is
|
||||||
|
* unspecified (callers render it neutral / omit it).
|
||||||
|
*/
|
||||||
|
fun priorityAccent(priority: Priority, dark: Boolean): Color {
|
||||||
|
val base = when (priority) {
|
||||||
|
Priority.HIGH -> 0xFFE53935.toInt() // red
|
||||||
|
Priority.MEDIUM -> 0xFFFB8C00.toInt() // orange-amber (darker than the fill for contrast)
|
||||||
|
Priority.LOW -> 0xFF43A047.toInt() // green
|
||||||
|
Priority.NONE -> return Color.Unspecified
|
||||||
|
}
|
||||||
|
val hsv = FloatArray(3)
|
||||||
|
android.graphics.Color.colorToHSV(base, hsv)
|
||||||
|
hsv[1] = if (dark) 0.55f else 0.85f
|
||||||
|
hsv[2] = if (dark) 0.85f else 0.62f // light on dark surfaces; deep on light ones
|
||||||
|
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A coloured status pill for a task's priority: a flag plus the level's [label],
|
* A coloured status pill for a task's priority: a flag plus the level's [label],
|
||||||
* filled in that level's pastel hue. Used read-only in the list and detail
|
* filled in that level's pastel hue. Used read-only in the list and detail
|
||||||
@@ -90,14 +112,14 @@ fun PriorityChip(
|
|||||||
|
|
||||||
val body: @Composable () -> Unit = {
|
val body: @Composable () -> Unit = {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (showIcon) {
|
if (showIcon) {
|
||||||
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(15.dp))
|
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(14.dp))
|
||||||
}
|
}
|
||||||
Text(label, style = MaterialTheme.typography.labelMedium, color = content)
|
Text(label, style = MaterialTheme.typography.labelSmall, color = content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val shape = RoundedCornerShape(50)
|
val shape = RoundedCornerShape(50)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
|
||||||
|
/** Common reminder lead times offered as quick picks in the reminder pickers. */
|
||||||
|
val REMINDER_PRESETS = listOf(0, 5, 10, 30, 60, 1_440)
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
|
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||||
|
ReminderUnit.Hours -> R.string.reminder_unit_hours
|
||||||
|
ReminderUnit.Days -> R.string.reminder_unit_days
|
||||||
|
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanise a reminder lead time (minutes before due) into one line: "At time of
|
||||||
|
* task" (0), "10 minutes before", "1 hour before", … Shared by the reminder
|
||||||
|
* pickers and the settings summaries so the wording never drifts. Ported from
|
||||||
|
* Calendula.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun reminderLeadTimeLabel(minutes: Int): String = when {
|
||||||
|
minutes <= 0 -> stringResource(R.string.reminder_at_due)
|
||||||
|
minutes % 10_080 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
||||||
|
minutes % 1_440 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
||||||
|
minutes % 60 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||||
|
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder lead-time picker, full-screen: the grouped list of preset lead times
|
||||||
|
* plus a "Custom" row that expands an inline amount field + unit selector.
|
||||||
|
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
|
||||||
|
* a "None" row. Returns the choice as a [ReminderOverride]. Ported from
|
||||||
|
* Calendula's ReminderDefaultPicker.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderLeadPicker(
|
||||||
|
title: String,
|
||||||
|
selected: ReminderOverride,
|
||||||
|
allowInherit: Boolean,
|
||||||
|
allowNone: Boolean,
|
||||||
|
onSelect: (ReminderOverride) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
presets: List<Int> = REMINDER_PRESETS,
|
||||||
|
) {
|
||||||
|
// Agendula is single-reminder: an override carries a one-element list, so
|
||||||
|
// take the single value for this single-select picker.
|
||||||
|
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes?.firstOrNull()
|
||||||
|
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
|
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
|
||||||
|
var unit by rememberSaveable { mutableStateOf(seed.unit) }
|
||||||
|
|
||||||
|
val options = buildList {
|
||||||
|
if (allowInherit) add(ReminderOverride.Inherit)
|
||||||
|
if (allowNone) add(ReminderOverride.None)
|
||||||
|
presets.forEach { add(ReminderOverride.Minutes(listOf(it))) }
|
||||||
|
}
|
||||||
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = reminderOverrideLabel(option),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Expanded, the Custom row connects downward into the editor card so the
|
||||||
|
// two read as one grouped container.
|
||||||
|
GroupedRow(
|
||||||
|
title = if (customSelected) {
|
||||||
|
stringResource(R.string.reminder_custom_with_value, reminderLeadTimeLabel(selectedMinutes!!))
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.reminder_custom)
|
||||||
|
},
|
||||||
|
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
||||||
|
selected = customSelected,
|
||||||
|
onClick = { customExpanded = !customExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = customExpanded,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
CustomReminderEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
unit = unit,
|
||||||
|
onUnitChange = { unit = it },
|
||||||
|
onConfirm = { minutes ->
|
||||||
|
onSelect(ReminderOverride.Minutes(listOf(minutes)))
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CustomReminderEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
unit: ReminderUnit,
|
||||||
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
|
onConfirm: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||||
|
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ReminderUnit.entries.forEachIndexed { index, entry ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = unit == entry,
|
||||||
|
onClick = { onUnitChange(entry) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
||||||
|
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10")
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
|
enabled = amount != null,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_custom_set))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
||||||
|
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes.first())
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.graphics.shapes.RoundedPolygon
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A top-bar action whose icon sits in a tonal container clipped to one of the
|
||||||
|
* M3 Expressive [MaterialShapes] (cookie, clover, sunny, …) — Agendula's playful
|
||||||
|
* take on a plain [androidx.compose.material3.IconButton]. Each action passes
|
||||||
|
* its own [shape] and [containerColor], so a row of them reads as a set of
|
||||||
|
* distinct little tokens rather than identical grey glyphs.
|
||||||
|
*
|
||||||
|
* Pressing springs the whole shape down and gives it a turn — the scalloped
|
||||||
|
* container is what dips and spins, while the glyph inside stays upright (a
|
||||||
|
* spinning magnifier or list icon would just read as wrong). [spinIcon] opts a
|
||||||
|
* glyph into turning too, for icons that read well mid-spin like the gear.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ShapedActionButton(
|
||||||
|
shape: RoundedPolygon,
|
||||||
|
icon: ImageVector,
|
||||||
|
contentDescription: String?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
containerColor: Color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
size: Dp = 40.dp,
|
||||||
|
iconSize: Dp = 22.dp,
|
||||||
|
spinIcon: Boolean = false,
|
||||||
|
) {
|
||||||
|
val interaction = remember { MutableInteractionSource() }
|
||||||
|
val pressed by interaction.collectIsPressedAsState()
|
||||||
|
// The shape (the scalloped cookie) turns and dips on press.
|
||||||
|
val shapeRotation by animateFloatAsState(if (pressed) 40f else 0f, label = "shapeRotation")
|
||||||
|
val scale by animateFloatAsState(if (pressed) 0.82f else 1f, label = "shapeScale")
|
||||||
|
// The glyph's *net* turn: 0 keeps it upright, spinIcon gives it a quarter turn.
|
||||||
|
val iconRotation by animateFloatAsState(
|
||||||
|
if (pressed && spinIcon) 90f else 0f,
|
||||||
|
label = "iconRotation",
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.scale(scale)
|
||||||
|
.rotate(shapeRotation),
|
||||||
|
shape = shape.toShape(),
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
interactionSource = interaction,
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
// Counter the container's turn so the glyph's net rotation is just
|
||||||
|
// [iconRotation] — upright by default, a quarter turn for the gear.
|
||||||
|
modifier = Modifier
|
||||||
|
.size(iconSize)
|
||||||
|
.rotate(iconRotation - shapeRotation),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named shapes for the app's top-bar actions, so each action is recognisably
|
||||||
|
* "its own" and new actions just pick the next unused shape. Expose the
|
||||||
|
* expressive [MaterialShapes] through a single opt-in site.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
object ActionShapes {
|
||||||
|
/** Settings — a 4-sided cookie (rounded, scalloped square). */
|
||||||
|
val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided
|
||||||
|
|
||||||
|
/** Search — a 6-sided cookie, the same family as [Settings] but distinct. */
|
||||||
|
val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided
|
||||||
|
|
||||||
|
/** New list — a sunny burst beside the Lists header. */
|
||||||
|
val AddList: RoundedPolygon get() = MaterialShapes.Sunny
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.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.agendula.ui.theme.AgendulaTheme
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 kit's report dialog. Plain [AgendulaTheme] defaults
|
||||||
|
* (follow-system, dynamic colour) avoid touching anything that might be the
|
||||||
|
* cause of the crash. The reusable machinery lives in floret-kit's core-crash.
|
||||||
|
*/
|
||||||
|
class CrashReportActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val report = CrashReporter.pendingReport(this)
|
||||||
|
if (report == null) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
AgendulaTheme {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.detail
|
package de.jeanlucmakiola.agendula.ui.detail
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -67,16 +67,16 @@ import androidx.compose.ui.text.style.TextDecoration
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.floret.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.floret.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.floret.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.floret.ui.common.GroupedSurface
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
import de.jeanlucmakiola.floret.ui.common.PriorityChip
|
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
|
||||||
import de.jeanlucmakiola.floret.ui.common.formatDate
|
import de.jeanlucmakiola.floret.time.formatDate
|
||||||
import de.jeanlucmakiola.floret.ui.common.formatTime
|
import de.jeanlucmakiola.floret.time.formatTime
|
||||||
import de.jeanlucmakiola.floret.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.ui.tasklist.priorityLabel
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One task's detail. Each fact is its own tonal card with a leading icon (the
|
* One task's detail. Each fact is its own tonal card with a leading icon (the
|
||||||
@@ -246,7 +246,7 @@ private fun DetailBody(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Priority — a coloured status chip in the level's hue.
|
// Priority — a coloured status chip in the level's hue.
|
||||||
if (task.priority != de.jeanlucmakiola.floret.domain.Priority.NONE) {
|
if (task.priority != de.jeanlucmakiola.agendula.domain.Priority.NONE) {
|
||||||
add {
|
add {
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Rounded.Flag,
|
icon = Icons.Rounded.Flag,
|
||||||
@@ -501,13 +501,17 @@ private fun taskWhenLines(task: Task): Pair<String, String?>? {
|
|||||||
val due = task.due
|
val due = task.due
|
||||||
return when {
|
return when {
|
||||||
start != null && due != null -> {
|
start != null && due != null -> {
|
||||||
val sameDay = start.formatDate() == due.formatDate()
|
val allDay = task.isAllDay
|
||||||
val primary = if (sameDay) due.formatDate() else "${start.formatDate()} – ${due.formatDate()}"
|
val sameDay = start.formatDate(allDay) == due.formatDate(allDay)
|
||||||
val secondary = if (task.isAllDay) null else "${start.formatTime()} – ${due.formatTime()}"
|
val primary =
|
||||||
|
if (sameDay) due.formatDate(allDay)
|
||||||
|
else "${start.formatDate(allDay)} – ${due.formatDate(allDay)}"
|
||||||
|
val secondary = if (allDay) null else "${start.formatTime()} – ${due.formatTime()}"
|
||||||
primary to secondary
|
primary to secondary
|
||||||
}
|
}
|
||||||
due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime()
|
due != null -> due.formatDate(task.isAllDay) to if (task.isAllDay) null else due.formatTime()
|
||||||
start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime()
|
start != null ->
|
||||||
|
start.formatDate(task.isAllDay) to if (task.isAllDay) null else start.formatTime()
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.detail
|
package de.jeanlucmakiola.agendula.ui.detail
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.floret.domain.Task
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.floret.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -42,14 +42,14 @@ class TaskDetailViewModel @Inject constructor(
|
|||||||
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
|
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
|
||||||
}
|
}
|
||||||
.onStart { emit(TaskDetailUiState.Loading) }
|
.onStart { emit(TaskDetailUiState.Loading) }
|
||||||
.catch { emit(TaskDetailUiState.NotFound) }
|
.recoveringFromProviderFailure { TaskDetailUiState.NotFound }
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
|
||||||
|
|
||||||
fun bind(id: Long) { taskId.value = id }
|
fun bind(id: Long) { taskId.value = id }
|
||||||
|
|
||||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
fun toggleComplete(task: Task) = viewModelScope.launch {
|
||||||
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
|
runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delete(task: Task) = viewModelScope.launch {
|
fun delete(task: Task) = viewModelScope.launch {
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.edit
|
package de.jeanlucmakiola.agendula.ui.edit
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -85,27 +80,29 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.floret.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.floret.domain.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.floret.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
import de.jeanlucmakiola.floret.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFormError
|
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.floret.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.floret.domain.TaskSection
|
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||||
import de.jeanlucmakiola.floret.domain.TaskSections
|
import de.jeanlucmakiola.agendula.domain.TaskSections
|
||||||
import de.jeanlucmakiola.floret.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.OptionalFormSection
|
||||||
import de.jeanlucmakiola.floret.ui.common.OptionCard
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.ui.common.priorityFill
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.floret.ui.common.formatDate
|
import de.jeanlucmakiola.agendula.ui.common.priorityFill
|
||||||
import de.jeanlucmakiola.floret.ui.common.formatTime
|
import de.jeanlucmakiola.floret.time.formatDate
|
||||||
import de.jeanlucmakiola.floret.ui.common.localToInstant
|
import de.jeanlucmakiola.floret.time.formatTime
|
||||||
import de.jeanlucmakiola.floret.ui.common.pastelize
|
import de.jeanlucmakiola.agendula.ui.common.localToInstant
|
||||||
import de.jeanlucmakiola.floret.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.ui.common.toLocalDate
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.ui.common.toLocalTime
|
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||||
import de.jeanlucmakiola.floret.ui.tasklist.priorityLabel
|
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
|
||||||
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -119,7 +116,7 @@ import kotlin.time.Instant
|
|||||||
* "When" card with the all-day toggle and tappable schedule rows, a tappable
|
* "When" card with the all-day toggle and tappable schedule rows, a tappable
|
||||||
* list card, and the optional Description / Priority / Reminder sections that
|
* list card, and the optional Description / Priority / Reminder sections that
|
||||||
* unfold from "More fields" (which ones start open is a setting). Validated
|
* unfold from "More fields" (which ones start open is a setting). Validated
|
||||||
* through the M1 [TaskEditViewModel] / [de.jeanlucmakiola.floret.domain.TaskForm];
|
* through the M1 [TaskEditViewModel] / [de.jeanlucmakiola.agendula.domain.TaskForm];
|
||||||
* the VM flips `saved` once the write lands, which pops us back.
|
* the VM flips `saved` once the write lands, which pops us back.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -182,7 +179,7 @@ private fun EditContent(
|
|||||||
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
|
var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showListPicker by rememberSaveable { mutableStateOf(false) }
|
var showListPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showParentPicker by rememberSaveable { mutableStateOf(false) }
|
var showParentPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -545,17 +542,6 @@ private fun EditContent(
|
|||||||
* open instead of popping in. (Initially-visible sections render without
|
* open instead of popping in. (Initially-visible sections render without
|
||||||
* animating on the first frame they're added.)
|
* animating on the first frame they're added.)
|
||||||
*/
|
*/
|
||||||
@Composable
|
|
||||||
private fun OptionalFormSection(visible: Boolean, content: @Composable ColumnScope.() -> Unit) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = expandVertically() + fadeIn(),
|
|
||||||
exit = shrinkVertically() + fadeOut(),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One info card mirroring the detail screen's card: tonal container, leading
|
* One info card mirroring the detail screen's card: tonal container, leading
|
||||||
* icon in the gutter, value to the right. Optionally clickable as a whole.
|
* icon in the gutter, value to the right. Optionally clickable as a whole.
|
||||||
@@ -668,7 +654,7 @@ private fun ScheduleRow(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = value.formatDate(),
|
text = value.formatDate(allDay),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = valueColor,
|
color = valueColor,
|
||||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||||
@@ -704,12 +690,15 @@ private fun DateTimePickerFlow(
|
|||||||
onResult: (Instant) -> Unit,
|
onResult: (Instant) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var pendingDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
|
var pendingDate by rememberSaveable { mutableStateOf<java.time.LocalDate?>(null) }
|
||||||
var showTime by remember { mutableStateOf(false) }
|
var showTime by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
if (!showTime) {
|
if (!showTime) {
|
||||||
|
// M3's DatePicker speaks UTC millis. An all-day value is already UTC-based,
|
||||||
|
// a timed one is read in the device zone — calendarDate picks the right frame
|
||||||
|
// so the dialog opens on the day the rest of the UI shows.
|
||||||
val initialMillis = (initial ?: nowInstant())
|
val initialMillis = (initial ?: nowInstant())
|
||||||
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
.calendarDate(allDay).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
||||||
DatePickerDialog(
|
DatePickerDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -718,7 +707,7 @@ private fun DateTimePickerFlow(
|
|||||||
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
|
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
|
||||||
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
if (allDay) {
|
if (allDay) {
|
||||||
onResult(localToInstant(date, LocalTime.MIDNIGHT))
|
onResult(allDayInstantOf(date))
|
||||||
} else {
|
} else {
|
||||||
pendingDate = date
|
pendingDate = date
|
||||||
showTime = true
|
showTime = true
|
||||||
@@ -853,7 +842,7 @@ private fun ParentPickerSheet(
|
|||||||
item(key = "none") {
|
item(key = "none") {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.edit_parent_none),
|
title = stringResource(R.string.edit_parent_none),
|
||||||
position = de.jeanlucmakiola.floret.ui.common.Position.Alone,
|
position = de.jeanlucmakiola.floret.components.Position.Alone,
|
||||||
selected = selectedId == null,
|
selected = selectedId == null,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
onClick = { choose(null) },
|
onClick = { choose(null) },
|
||||||
@@ -880,7 +869,7 @@ private fun ParentPickerSheet(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
title = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
||||||
position = positionOf(index, section.tasks.size),
|
position = positionOf(index, section.tasks.size),
|
||||||
summary = task.due?.formatDate(),
|
summary = task.due?.formatDate(task.isAllDay),
|
||||||
selected = task.taskId == selectedId,
|
selected = task.taskId == selectedId,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
onClick = { choose(task.taskId) },
|
onClick = { choose(task.taskId) },
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.edit
|
package de.jeanlucmakiola.agendula.ui.edit
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||||
import de.jeanlucmakiola.floret.data.tasks.TaskConflictException
|
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
|
||||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.floret.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
import de.jeanlucmakiola.floret.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFormError
|
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.floret.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.floret.domain.populatedFields
|
import de.jeanlucmakiola.agendula.domain.populatedFields
|
||||||
|
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -70,6 +71,15 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
private var editingTaskId: Long? = null
|
private var editingTaskId: Long? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the form has already been populated. The host `LaunchedEffect`
|
||||||
|
* re-fires whenever the composition restarts — an Activity recreation
|
||||||
|
* (rotation, theme/font/display-size change, split-screen, unfolding) — while
|
||||||
|
* this ViewModel survives on the nav back stack. Without this guard the
|
||||||
|
* rebind would overwrite in-progress edits with the untouched provider row.
|
||||||
|
*/
|
||||||
|
private var bound = false
|
||||||
|
|
||||||
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
|
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
|
||||||
private var baselineLastModified: Instant? = null
|
private var baselineLastModified: Instant? = null
|
||||||
|
|
||||||
@@ -78,6 +88,8 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||||
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
|
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
|
||||||
|
if (bound) return
|
||||||
|
bound = true
|
||||||
editingTaskId = null
|
editingTaskId = null
|
||||||
baselineLastModified = null
|
baselineLastModified = null
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -103,6 +115,8 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Load an existing task for editing. */
|
/** Load an existing task for editing. */
|
||||||
fun bindEdit(taskId: Long) {
|
fun bindEdit(taskId: Long) {
|
||||||
|
if (bound && editingTaskId == taskId) return
|
||||||
|
bound = true
|
||||||
editingTaskId = taskId
|
editingTaskId = taskId
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
defaultFields = settingsPrefs.settings.first().defaultEditFields
|
defaultFields = settingsPrefs.settings.first().defaultEditFields
|
||||||
@@ -126,6 +140,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
priority = task.priority,
|
priority = task.priority,
|
||||||
parentId = task.parentId,
|
parentId = task.parentId,
|
||||||
percentComplete = task.percentComplete,
|
percentComplete = task.percentComplete,
|
||||||
|
reminderMinutesBeforeDue = repository.reminderFor(taskId),
|
||||||
lists = lists,
|
lists = lists,
|
||||||
parentCandidates = loadParents(task.listId, selfId = taskId),
|
parentCandidates = loadParents(task.listId, selfId = taskId),
|
||||||
),
|
),
|
||||||
@@ -178,7 +193,19 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
||||||
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
|
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
|
||||||
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
|
/**
|
||||||
|
* All-day and timed values use different conventions (UTC midnight vs. a real
|
||||||
|
* instant in the device zone), so the switch has to move the timestamps too —
|
||||||
|
* flipping the flag alone makes an all-day task read back as "02:00", which
|
||||||
|
* looks to the user like the time reset itself.
|
||||||
|
*/
|
||||||
|
fun onAllDayChange(value: Boolean) = update {
|
||||||
|
it.copy(
|
||||||
|
isAllDay = value,
|
||||||
|
start = it.start?.rebasedForAllDay(value),
|
||||||
|
due = it.due?.rebasedForAllDay(value),
|
||||||
|
)
|
||||||
|
}
|
||||||
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
||||||
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
|
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
|
||||||
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
|
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.export
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Circle
|
||||||
|
import androidx.compose.material.icons.rounded.Folder
|
||||||
|
import androidx.compose.material.icons.rounded.FolderZip
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.agendula.data.export.ExportFailure
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
|
private const val ZIP_MIME = "application/zip"
|
||||||
|
private const val ZIP_NAME = "agendula-tasks.zip"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export the task lists as iCalendar. Which lists go out is a per-list tick; the
|
||||||
|
* destination is a folder or a single zip, both picked through SAF so the app
|
||||||
|
* needs no storage permission.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ExportScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ExportViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
val folderLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri -> uri?.let(viewModel::exportToFolder) }
|
||||||
|
val zipLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument(ZIP_MIME),
|
||||||
|
) { uri -> uri?.let(viewModel::exportToZip) }
|
||||||
|
|
||||||
|
val canExport = !state.running && state.selectedCount > 0
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_export),
|
||||||
|
onBack = onBack,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.export_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (state.lists.isEmpty()) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.export_no_lists),
|
||||||
|
position = Position.Alone,
|
||||||
|
dimmed = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
state.lists.forEachIndexed { index, list ->
|
||||||
|
val selected = state.isSelected(list.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = list.name,
|
||||||
|
// The account only says something when it isn't the device itself.
|
||||||
|
summary = list.accountName.takeIf { !list.isLocal },
|
||||||
|
position = positionOf(index, state.lists.size),
|
||||||
|
leading = {
|
||||||
|
Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark))
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
Checkbox(checked = selected, onCheckedChange = { viewModel.toggle(list.id) })
|
||||||
|
},
|
||||||
|
onClick = { viewModel.toggle(list.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { folderLauncher.launch(null) },
|
||||||
|
enabled = canExport,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Rounded.Folder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(stringResource(R.string.export_to_folder))
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { zipLauncher.launch(ZIP_NAME) },
|
||||||
|
enabled = canExport,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Rounded.FolderZip, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(stringResource(R.string.export_to_zip))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
ExportStatus(state = state)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The running spinner, then whatever the last export ended as — it stays put. */
|
||||||
|
@Composable
|
||||||
|
private fun ExportStatus(state: ExportUiState) {
|
||||||
|
val outcome = state.outcome
|
||||||
|
when {
|
||||||
|
state.running -> Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(Modifier.size(18.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.export_running),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
outcome is ExportOutcome.Success -> StatusText(
|
||||||
|
text = pluralStringResource(R.plurals.export_done, outcome.fileCount, outcome.fileCount),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
outcome is ExportOutcome.Failure -> StatusText(
|
||||||
|
text = stringResource(failureMessage(outcome.reason)),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failureMessage(reason: ExportFailure): Int = when (reason) {
|
||||||
|
ExportFailure.FOLDER_UNAVAILABLE -> R.string.export_failed_folder
|
||||||
|
ExportFailure.FOLDER_NOT_WRITABLE -> R.string.export_failed_read_only
|
||||||
|
ExportFailure.CANNOT_CREATE_FILE -> R.string.export_failed_create
|
||||||
|
ExportFailure.LOST_ACCESS -> R.string.export_failed_access
|
||||||
|
ExportFailure.WRITE_FAILED -> R.string.export_failed
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusText(text: String, color: Color) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = color,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.export
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.data.export.ExportFailedException
|
||||||
|
import de.jeanlucmakiola.agendula.data.export.ExportFailure
|
||||||
|
import de.jeanlucmakiola.agendula.data.export.ExportResult
|
||||||
|
import de.jeanlucmakiola.agendula.data.export.ExportWriter
|
||||||
|
import de.jeanlucmakiola.agendula.data.export.TaskExporter
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
/** How the last export ended, kept on screen rather than flashed past. */
|
||||||
|
sealed interface ExportOutcome {
|
||||||
|
data class Success(val fileCount: Int) : ExportOutcome
|
||||||
|
data class Failure(val reason: ExportFailure) : ExportOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ExportUiState(
|
||||||
|
val lists: List<TaskList> = emptyList(),
|
||||||
|
/** Lists the user has ticked *off*; everything else is included. */
|
||||||
|
val excluded: Set<Long> = emptySet(),
|
||||||
|
val running: Boolean = false,
|
||||||
|
val outcome: ExportOutcome? = null,
|
||||||
|
) {
|
||||||
|
fun isSelected(listId: Long): Boolean = listId !in excluded
|
||||||
|
|
||||||
|
val selectedCount: Int get() = lists.count { isSelected(it.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the export screen. Holds the selection as an exclusion set so a list
|
||||||
|
* that appears while the screen is open is exported too — the natural reading of
|
||||||
|
* "everything, minus what I unticked".
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ExportViewModel @Inject constructor(
|
||||||
|
repository: TasksRepository,
|
||||||
|
resolver: ProviderResolver,
|
||||||
|
private val exporter: TaskExporter,
|
||||||
|
private val writer: ExportWriter,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val excluded = MutableStateFlow(emptySet<Long>())
|
||||||
|
private val running = MutableStateFlow(false)
|
||||||
|
private val outcome = MutableStateFlow<ExportOutcome?>(null)
|
||||||
|
|
||||||
|
private var exportJob: Job? = null
|
||||||
|
|
||||||
|
// List ids are per-store, and Settings can switch stores with this ViewModel
|
||||||
|
// still alive — so the selection, the receipt and a write already addressing
|
||||||
|
// the old store's lists all go with it.
|
||||||
|
private val modeHandle = resolver.onModeChanged {
|
||||||
|
exportJob?.cancel()
|
||||||
|
excluded.value = emptySet()
|
||||||
|
outcome.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
modeHandle.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val state: StateFlow<ExportUiState> =
|
||||||
|
combine(
|
||||||
|
repository.taskLists().recoveringFromProviderFailure { emptyList() },
|
||||||
|
excluded,
|
||||||
|
running,
|
||||||
|
outcome,
|
||||||
|
) { lists, excluded, running, outcome ->
|
||||||
|
ExportUiState(lists, excluded, running, outcome)
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ExportUiState())
|
||||||
|
|
||||||
|
fun toggle(listId: Long) = excluded.update { current ->
|
||||||
|
if (listId in current) current - listId else current + listId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes one `.ics` per list into a folder the user picked through SAF. */
|
||||||
|
fun exportToFolder(tree: Uri) = export { documents -> writer.writeToTree(tree, documents) }
|
||||||
|
|
||||||
|
/** Writes every list into a single zip the user named through SAF. */
|
||||||
|
fun exportToZip(target: Uri) = export { documents -> writer.writeZip(target, documents) }
|
||||||
|
|
||||||
|
private fun export(write: suspend (List<ExportDocument>) -> ExportResult) {
|
||||||
|
if (running.value) return
|
||||||
|
running.value = true
|
||||||
|
outcome.value = null
|
||||||
|
exportJob = viewModelScope.launch {
|
||||||
|
// Null means "every list" to the exporter, and is what an untouched
|
||||||
|
// screen should send: the flow may not have emitted a list yet.
|
||||||
|
val selection = excluded.value.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { skipped -> state.value.lists.map { it.id }.toSet() - skipped }
|
||||||
|
try {
|
||||||
|
val result = write(exporter.export(selection))
|
||||||
|
outcome.value = ExportOutcome.Success(result.fileCount)
|
||||||
|
} catch (cancelled: CancellationException) {
|
||||||
|
// Leaving the screen mid-write is not a failed export.
|
||||||
|
throw cancelled
|
||||||
|
} catch (error: Exception) {
|
||||||
|
outcome.value = ExportOutcome.Failure(
|
||||||
|
(error as? ExportFailedException)?.failure ?: ExportFailure.WRITE_FAILED,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
running.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.lists
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Check
|
||||||
|
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.DefaultListColor
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.ListPalette
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
|
|
||||||
|
private const val SWATCHES_PER_ROW = 6
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or edit a task list: a name field over the palette of list colours, on
|
||||||
|
* the family's full-screen sheet with the commit in its title bar.
|
||||||
|
*
|
||||||
|
* [initial] null is the create case. [onDelete] is null for a list the app must
|
||||||
|
* not remove — an account's collection belongs to its server — which is also why
|
||||||
|
* the destructive row only appears when it is non-null.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ListEditorSheet(
|
||||||
|
initial: TaskList?,
|
||||||
|
onSave: (name: String, color: Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onDelete: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
var name by rememberSaveable(initial?.id) { mutableStateOf(initial?.name.orEmpty()) }
|
||||||
|
var color by rememberSaveable(initial?.id) { mutableIntStateOf(initial?.color ?: DefaultListColor) }
|
||||||
|
var confirmDelete by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val valid = name.isNotBlank()
|
||||||
|
val commit = {
|
||||||
|
if (valid) {
|
||||||
|
onSave(name.trim(), color)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(if (initial == null) R.string.list_new_title else R.string.list_edit_title),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = commit,
|
||||||
|
enabled = valid,
|
||||||
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
|
) { Text(stringResource(R.string.save)) }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
NameField(
|
||||||
|
name = name,
|
||||||
|
color = color,
|
||||||
|
// A new list opens with the keyboard up: naming it is the whole task.
|
||||||
|
autoFocus = initial == null,
|
||||||
|
onNameChange = { name = it },
|
||||||
|
onImeAction = commit,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
SectionLabel(stringResource(R.string.list_color))
|
||||||
|
ColorGrid(selected = color, onSelect = { color = it })
|
||||||
|
|
||||||
|
if (onDelete != null) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
DeleteRow(onClick = { confirmDelete = true })
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmDelete && onDelete != null) {
|
||||||
|
DeleteListDialog(
|
||||||
|
listName = initial?.name.orEmpty(),
|
||||||
|
onConfirm = {
|
||||||
|
confirmDelete = false
|
||||||
|
onDelete()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
onDismiss = { confirmDelete = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name, with the chosen colour beside it so the two read as one thing. */
|
||||||
|
@Composable
|
||||||
|
private fun NameField(
|
||||||
|
name: String,
|
||||||
|
color: Int,
|
||||||
|
autoFocus: Boolean,
|
||||||
|
onNameChange: (String) -> Unit,
|
||||||
|
onImeAction: () -> Unit,
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() }
|
||||||
|
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
ListColorChip(color)
|
||||||
|
InlineTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
placeholder = stringResource(R.string.list_name_hint),
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
onImeAction = onImeAction,
|
||||||
|
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The palette as two rows of round swatches; the chosen one carries a check. */
|
||||||
|
@Composable
|
||||||
|
private fun ColorGrid(selected: Int, onSelect: (Int) -> Unit) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
ListPalette.chunked(SWATCHES_PER_ROW).forEach { row ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
row.forEach { swatch ->
|
||||||
|
Swatch(
|
||||||
|
color = swatch,
|
||||||
|
dark = dark,
|
||||||
|
selected = swatch == selected,
|
||||||
|
onClick = { onSelect(swatch) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Keeps a short final row's swatches at the same size as a full
|
||||||
|
// one's rather than stretching them across the width.
|
||||||
|
repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Swatch(
|
||||||
|
color: Int,
|
||||||
|
dark: Boolean,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val fill = pastelize(color, dark)
|
||||||
|
val label = stringResource(colorLabel(color))
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(fill)
|
||||||
|
// selectable, not clickable: the swatch carries its chosen state in
|
||||||
|
// semantics, so the check below is decoration rather than the only cue.
|
||||||
|
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||||
|
.semantics { contentDescription = label },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (fill.luminance() > 0.5f) Color.Black else Color.White,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeleteRow(onClick: () -> Unit) {
|
||||||
|
GroupedSurface(
|
||||||
|
position = Position.Alone,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
onClick = onClick,
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.DeleteOutline,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.list_delete),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeleteListDialog(listName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.list_delete_confirm_title)) },
|
||||||
|
text = { Text(stringResource(R.string.list_delete_confirm_message, listName)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionLabel(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names the palette entries for screen readers; anything else is just "colour". */
|
||||||
|
private fun colorLabel(color: Int): Int = when (ListPalette.indexOf(color)) {
|
||||||
|
0 -> R.string.list_color_mauve
|
||||||
|
1 -> R.string.list_color_red
|
||||||
|
2 -> R.string.list_color_orange
|
||||||
|
3 -> R.string.list_color_amber
|
||||||
|
4 -> R.string.list_color_olive
|
||||||
|
5 -> R.string.list_color_green
|
||||||
|
6 -> R.string.list_color_teal
|
||||||
|
7 -> R.string.list_color_cyan
|
||||||
|
8 -> R.string.list_color_blue
|
||||||
|
9 -> R.string.list_color_indigo
|
||||||
|
10 -> R.string.list_color_purple
|
||||||
|
11 -> R.string.list_color_pink
|
||||||
|
else -> R.string.list_color
|
||||||
|
}
|
||||||
@@ -0,0 +1,782 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.lists
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ListAlt
|
||||||
|
import androidx.compose.material.icons.rounded.Add
|
||||||
|
import androidx.compose.material.icons.rounded.ChevronRight
|
||||||
|
import androidx.compose.material.icons.rounded.Close
|
||||||
|
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||||
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
|
import androidx.compose.material.icons.rounded.Search
|
||||||
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
|
import androidx.compose.material.icons.rounded.Today
|
||||||
|
import androidx.compose.material.icons.rounded.Upcoming
|
||||||
|
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChip
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||||
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home: a Today progress hero (wavy ring over tasks due today), an Overdue + All
|
||||||
|
* 2-up of tonal tiles, a live preview of the next upcoming tasks, then the user's
|
||||||
|
* lists grouped by account. Tapping a tile opens that smart list, an upcoming row
|
||||||
|
* opens that task, and the FAB starts a new task. The search action in the top bar
|
||||||
|
* slides open into a full-width field that filters every task by title.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ListsScreen(
|
||||||
|
onOpenFilter: (TaskFilter) -> Unit,
|
||||||
|
onOpenTask: (Long) -> Unit,
|
||||||
|
onNewTask: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var newList by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val closeSearch = {
|
||||||
|
query = ""
|
||||||
|
searchActive = false
|
||||||
|
}
|
||||||
|
// With no lists there is nowhere to put a task, so the primary action becomes
|
||||||
|
// making one — otherwise a fresh install's FAB opens a form that cannot save.
|
||||||
|
val noLists = (state as? ListsUiState.Content)?.groups?.isEmpty() == true
|
||||||
|
// The sheet closes on save, so a refused write has to report itself here.
|
||||||
|
val writeFailure by viewModel.writeFailure.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(writeFailure) {
|
||||||
|
if (writeFailure != null) {
|
||||||
|
delay(4_000)
|
||||||
|
viewModel.clearWriteFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// System back closes search before leaving the screen.
|
||||||
|
BackHandler(enabled = searchActive, onBack = closeSearch)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
HomeTopBar(
|
||||||
|
searchActive = searchActive,
|
||||||
|
query = query,
|
||||||
|
onQueryChange = { query = it },
|
||||||
|
onToggleSearch = { if (searchActive) closeSearch() else searchActive = true },
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
// The FAB would otherwise float over the search results.
|
||||||
|
if (!searchActive) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = if (noLists) ({ newList = true }) else onNewTask,
|
||||||
|
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||||
|
text = {
|
||||||
|
Text(stringResource(if (noLists) R.string.list_add else R.string.new_task))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { inner ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
|
||||||
|
when (val s = state) {
|
||||||
|
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||||
|
ListsUiState.Failure ->
|
||||||
|
CenteredMessage(stringResource(R.string.lists_failure), PaddingValues(0.dp))
|
||||||
|
is ListsUiState.Content -> {
|
||||||
|
ListsContent(
|
||||||
|
state = s,
|
||||||
|
onOpenFilter = onOpenFilter,
|
||||||
|
onOpenTask = onOpenTask,
|
||||||
|
onNewList = { newList = true },
|
||||||
|
topPadding = 0.dp,
|
||||||
|
bottomPadding = inner.calculateBottomPadding() + 96.dp,
|
||||||
|
)
|
||||||
|
// While searching with a non-blank query, results cover the home
|
||||||
|
// content; an empty query leaves the home content visible behind
|
||||||
|
// the open field.
|
||||||
|
if (searchActive) {
|
||||||
|
SearchResults(
|
||||||
|
query = query,
|
||||||
|
allTasks = s.allTasks,
|
||||||
|
onOpenTask = { taskId ->
|
||||||
|
closeSearch()
|
||||||
|
onOpenTask(taskId)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Anchored beside the FAB, at its height — the same receipt placement
|
||||||
|
// the task list uses.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(
|
||||||
|
start = SnackChipMargin,
|
||||||
|
bottom = inner.calculateBottomPadding() + SnackChipMargin,
|
||||||
|
)
|
||||||
|
.height(SnackChipHeight),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
SnackChip(
|
||||||
|
visible = writeFailure != null,
|
||||||
|
message = stringResource(R.string.list_save_failed),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newList) {
|
||||||
|
ListEditorSheet(
|
||||||
|
initial = null,
|
||||||
|
onSave = viewModel::createList,
|
||||||
|
onDismiss = { newList = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ListsContent(
|
||||||
|
state: ListsUiState.Content,
|
||||||
|
onOpenFilter: (TaskFilter) -> Unit,
|
||||||
|
onOpenTask: (Long) -> Unit,
|
||||||
|
onNewList: () -> Unit,
|
||||||
|
topPadding: androidx.compose.ui.unit.Dp,
|
||||||
|
bottomPadding: androidx.compose.ui.unit.Dp,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
top = topPadding,
|
||||||
|
bottom = bottomPadding,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
TodayHero(
|
||||||
|
done = state.todayDone,
|
||||||
|
total = state.todayTotal,
|
||||||
|
onClick = { onOpenFilter(TaskFilter.Smart(SmartList.TODAY)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Overdue + All sit below the Today hero as a quieter 2-up; Upcoming is no
|
||||||
|
// longer a count tile — it becomes the live preview further down.
|
||||||
|
item {
|
||||||
|
SmartPairRow(
|
||||||
|
counts = state.smartCounts.filter {
|
||||||
|
it.smart == SmartList.OVERDUE || it.smart == SmartList.ALL
|
||||||
|
},
|
||||||
|
onOpenFilter = onOpenFilter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.upcoming.isNotEmpty()) {
|
||||||
|
item { SectionHeader(stringResource(R.string.smart_upcoming)) }
|
||||||
|
item {
|
||||||
|
UpcomingPreview(
|
||||||
|
tasks = state.upcoming,
|
||||||
|
onOpenTask = onOpenTask,
|
||||||
|
onViewAll = { onOpenFilter(TaskFilter.Smart(SmartList.UPCOMING)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.groups.isEmpty()) {
|
||||||
|
item { EmptyLists(onNewList = onNewList) }
|
||||||
|
} else {
|
||||||
|
item {
|
||||||
|
SectionHeader(
|
||||||
|
text = stringResource(R.string.lists_header),
|
||||||
|
action = {
|
||||||
|
ShapedActionButton(
|
||||||
|
shape = ActionShapes.AddList,
|
||||||
|
icon = Icons.Rounded.Add,
|
||||||
|
contentDescription = stringResource(R.string.list_add),
|
||||||
|
onClick = onNewList,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.groups.forEach { group ->
|
||||||
|
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
|
||||||
|
itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview ->
|
||||||
|
GroupedRow(
|
||||||
|
title = overview.list.name,
|
||||||
|
position = positionOf(index, group.lists.size),
|
||||||
|
minHeight = 72.dp,
|
||||||
|
leading = { ListColorChip(overview.list.color) },
|
||||||
|
trailing = {
|
||||||
|
if (overview.openCount > 0) {
|
||||||
|
Text(
|
||||||
|
overview.openCount.toString(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = { onOpenFilter(TaskFilter.OfList(overview.list.id)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No lists at all — a fresh install, where nothing else on this screen works yet. */
|
||||||
|
@Composable
|
||||||
|
private fun EmptyLists(onNewList: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.lists_empty),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
FilledTonalButton(onClick = onNewList) {
|
||||||
|
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.lists_empty_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home top bar. There is no title — the launcher icon already says which app
|
||||||
|
* this is. Settings is pinned at the right; the search action sits just left of it
|
||||||
|
* and stays put. Tapping search unfurls a pill to its left (the field grows out
|
||||||
|
* from the icon's side) while the search icon remains as the bar's fixed trailing
|
||||||
|
* icon, so neither action moves.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun HomeTopBar(
|
||||||
|
searchActive: Boolean,
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onToggleSearch: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.height(72.dp)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Search occupies the flexible space left of settings. Pulled into its
|
||||||
|
// own composable so AnimatedVisibility resolves to the plain overload —
|
||||||
|
// a RowScope receiver in here would make that call ambiguous.
|
||||||
|
SearchSlot(
|
||||||
|
searchActive = searchActive,
|
||||||
|
query = query,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
onToggleSearch = onToggleSearch,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
ShapedActionButton(
|
||||||
|
shape = ActionShapes.Settings,
|
||||||
|
icon = Icons.Rounded.Settings,
|
||||||
|
contentDescription = stringResource(R.string.settings_title),
|
||||||
|
onClick = onOpenSettings,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
size = 48.dp,
|
||||||
|
iconSize = 26.dp,
|
||||||
|
spinIcon = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search action and the field it unfurls. The magnifier is pinned to the end
|
||||||
|
* (right) of this slot and never moves; tapping it toggles search. When active a
|
||||||
|
* pill expands leftward from it ([expandHorizontally] anchored at the end) holding
|
||||||
|
* the query field, so the icon reads as the bar's fixed trailing icon.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SearchSlot(
|
||||||
|
searchActive: Boolean,
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onToggleSearch: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier, contentAlignment = Alignment.CenterEnd) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = searchActive,
|
||||||
|
enter = expandHorizontally(tween(300), expandFrom = Alignment.End) + fadeIn(tween(280)),
|
||||||
|
exit = shrinkHorizontally(tween(220), shrinkTowards = Alignment.End) + fadeOut(tween(140)),
|
||||||
|
) {
|
||||||
|
SearchPill(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Always on top, at the end — the bar's fixed search icon and toggle.
|
||||||
|
ShapedActionButton(
|
||||||
|
shape = ActionShapes.Search,
|
||||||
|
icon = Icons.Rounded.Search,
|
||||||
|
contentDescription = stringResource(R.string.home_search_hint),
|
||||||
|
onClick = onToggleSearch,
|
||||||
|
size = 48.dp,
|
||||||
|
iconSize = 26.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanding search input pill: the query field (auto-focused on open) and a
|
||||||
|
* clear button once there is text. Its trailing 48dp is left empty for the search
|
||||||
|
* icon that [SearchSlot] overlays at the end.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SearchPill(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
modifier = modifier.height(52.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = 18.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
BasicTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(),
|
||||||
|
modifier = Modifier.weight(1f).focusRequester(focusRequester),
|
||||||
|
decorationBox = { innerField ->
|
||||||
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
|
if (query.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.home_search_hint),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerField()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Close,
|
||||||
|
contentDescription = stringResource(R.string.home_search_clear),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Space reserved for the search icon SearchSlot overlays at the end.
|
||||||
|
Spacer(Modifier.width(48.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search results overlaying the home content: tasks whose title matches the query,
|
||||||
|
* across every list (completed included), open ones first. A blank query renders
|
||||||
|
* nothing so the home content shows through behind the open field.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SearchResults(
|
||||||
|
query: String,
|
||||||
|
allTasks: List<Task>,
|
||||||
|
onOpenTask: (Long) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (query.isBlank()) return
|
||||||
|
val results = remember(query, allTasks) {
|
||||||
|
val q = query.trim()
|
||||||
|
allTasks
|
||||||
|
.filter { it.title.contains(q, ignoreCase = true) }
|
||||||
|
.sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() }))
|
||||||
|
}
|
||||||
|
Surface(modifier = modifier, color = MaterialTheme.colorScheme.surface) {
|
||||||
|
if (results.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.TopCenter) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.home_search_empty, query.trim()),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(results, key = { it.occurrenceKey }) { task ->
|
||||||
|
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day's momentum: a wavy progress ring over "x of y done" for tasks due today.
|
||||||
|
* Tapping opens the Today list. When nothing is due today it drops the ring and
|
||||||
|
* reads as a calm, finished state rather than an empty 0.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun TodayHero(done: Int, total: Int, onClick: () -> Unit) {
|
||||||
|
val interaction = remember { MutableInteractionSource() }
|
||||||
|
val pressed by interaction.collectIsPressedAsState()
|
||||||
|
val corner by animateDpAsState(if (pressed) 34.dp else 22.dp, label = "todayCorner")
|
||||||
|
val left = total - done
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(corner),
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
interactionSource = interaction,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(140.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Rounded.Today, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
Text(stringResource(R.string.smart_today), style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
val headline = when {
|
||||||
|
total == 0 -> stringResource(R.string.home_today_empty)
|
||||||
|
left == 0 -> stringResource(R.string.home_today_all_done)
|
||||||
|
else -> stringResource(R.string.home_today_progress, done, total)
|
||||||
|
}
|
||||||
|
Text(headline, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
if (total > 0 && left > 0) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.home_today_remaining, left),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (total > 0) {
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(84.dp)) {
|
||||||
|
CircularWavyProgressIndicator(
|
||||||
|
progress = { done.toFloat() / total },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.22f),
|
||||||
|
)
|
||||||
|
Text("$done/$total", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overdue + All as a 2-up row of the existing tonal tiles. */
|
||||||
|
@Composable
|
||||||
|
private fun SmartPairRow(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
counts.forEach { smart ->
|
||||||
|
SmartCard(
|
||||||
|
count = smart,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (counts.size == 1) Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A grouped card previewing the next few upcoming tasks, with a "view all" tail. */
|
||||||
|
@Composable
|
||||||
|
private fun UpcomingPreview(
|
||||||
|
tasks: List<Task>,
|
||||||
|
onOpenTask: (Long) -> Unit,
|
||||||
|
onViewAll: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(22.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
tasks.forEach { task ->
|
||||||
|
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
|
||||||
|
}
|
||||||
|
Surface(onClick = onViewAll, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.home_upcoming_view_all),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One slim upcoming row: list-colour avatar, title, then a quiet meta line of the
|
||||||
|
* relative due date and (if set) a tinted priority flag — the same calm one-line
|
||||||
|
* treatment as the task list, minus the swipe machinery.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UpcomingRow(task: Task, onClick: () -> Unit) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val metaStyle = MaterialTheme.typography.bodySmall
|
||||||
|
Surface(onClick = onClick, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
ListColorChip(task.effectiveColor)
|
||||||
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(
|
||||||
|
task.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
upcomingDueLabel(task)?.let { Text(it, style = metaStyle, color = muted) }
|
||||||
|
if (task.priority != Priority.NONE) {
|
||||||
|
Text("·", style = metaStyle, color = muted)
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Flag,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = priorityAccent(task.priority, dark),
|
||||||
|
modifier = Modifier.size(13.dp),
|
||||||
|
)
|
||||||
|
Text(priorityLabel(task.priority), style = metaStyle, color = muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Today" / "Tomorrow" for the near dates, else the compact date. */
|
||||||
|
@Composable
|
||||||
|
private fun upcomingDueLabel(task: Task): String? {
|
||||||
|
val due = task.due ?: return null
|
||||||
|
val zone = remember { ZoneId.systemDefault() }
|
||||||
|
val today = remember { LocalDate.now(zone) }
|
||||||
|
val dueDate = remember(due) {
|
||||||
|
java.time.Instant.ofEpochMilli(due.toEpochMilliseconds()).atZone(zone).toLocalDate()
|
||||||
|
}
|
||||||
|
return when (dueDate) {
|
||||||
|
today -> stringResource(R.string.home_due_today)
|
||||||
|
today.plusDays(1) -> stringResource(R.string.home_due_tomorrow)
|
||||||
|
else -> due.formatDateTimeCompact(task.isAllDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun smartStyle(smart: SmartList): SmartStyle {
|
||||||
|
val colors = MaterialTheme.colorScheme
|
||||||
|
return when (smart) {
|
||||||
|
SmartList.TODAY -> SmartStyle(Icons.Rounded.Today, R.string.smart_today, colors.primaryContainer, colors.onPrimaryContainer)
|
||||||
|
SmartList.OVERDUE -> SmartStyle(Icons.Rounded.ErrorOutline, R.string.smart_overdue, colors.errorContainer, colors.onErrorContainer)
|
||||||
|
SmartList.UPCOMING -> SmartStyle(Icons.Rounded.Upcoming, R.string.smart_upcoming, colors.tertiaryContainer, colors.onTertiaryContainer)
|
||||||
|
else -> SmartStyle(Icons.AutoMirrored.Rounded.ListAlt, R.string.smart_all, colors.secondaryContainer, colors.onSecondaryContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean tonal tile. The expressive touch is subtle: rounded corners gently morph
|
||||||
|
* rounder on press (the same quiet shape behaviour as the lists), not a loud
|
||||||
|
* silhouette.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
val style = smartStyle(count.smart)
|
||||||
|
val interaction = remember { MutableInteractionSource() }
|
||||||
|
val pressed by interaction.collectIsPressedAsState()
|
||||||
|
val corner by animateDpAsState(if (pressed) 34.dp else 22.dp, label = "smartCorner")
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(corner),
|
||||||
|
color = style.container,
|
||||||
|
contentColor = style.onContainer,
|
||||||
|
interactionSource = interaction,
|
||||||
|
modifier = modifier.height(116.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Icon(style.icon, contentDescription = null)
|
||||||
|
Column {
|
||||||
|
Text(count.count.toString(), style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Text(stringResource(style.labelRes), style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(text: String, action: (@Composable () -> Unit)? = null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
start = 28.dp,
|
||||||
|
end = if (action == null) 28.dp else 20.dp,
|
||||||
|
top = if (action == null) 16.dp else 12.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
action?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccountHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredMessage(text: String, inner: PaddingValues) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.lists
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a list write failed at. The screens turn this into wording; the view
|
||||||
|
* models stay free of resources.
|
||||||
|
*/
|
||||||
|
enum class ListWriteFailure { SAVE, DELETE }
|
||||||
|
|
||||||
|
data class ListOverview(val list: TaskList, val openCount: Int)
|
||||||
|
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
||||||
|
data class SmartCount(val smart: SmartList, val count: Int)
|
||||||
|
|
||||||
|
sealed interface ListsUiState {
|
||||||
|
data object Loading : ListsUiState
|
||||||
|
data object Failure : ListsUiState
|
||||||
|
data class Content(
|
||||||
|
val smartCounts: List<SmartCount>,
|
||||||
|
val groups: List<AccountGroup>,
|
||||||
|
/** Completed vs. total tasks *due today* — drives the progress ring. */
|
||||||
|
val todayDone: Int,
|
||||||
|
val todayTotal: Int,
|
||||||
|
/** The next few open tasks (due tomorrow onward) for the inline preview. */
|
||||||
|
val upcoming: List<Task>,
|
||||||
|
/** Every task (open and completed) — the corpus the home search filters. */
|
||||||
|
val allTasks: List<Task>,
|
||||||
|
) : ListsUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val UPCOMING_PREVIEW = 3
|
||||||
|
|
||||||
|
/** The home overview: smart lists with live counts, then user lists by account. */
|
||||||
|
@HiltViewModel
|
||||||
|
class ListsViewModel @Inject constructor(
|
||||||
|
private val repository: TasksRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val state: StateFlow<ListsUiState> =
|
||||||
|
combine(
|
||||||
|
repository.taskLists(),
|
||||||
|
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
|
||||||
|
// Open smart lists drop completed tasks, but the Today ring needs the
|
||||||
|
// ones already ticked off to show "x of y done", so read them too.
|
||||||
|
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
||||||
|
) { lists, openTasks, completedTasks ->
|
||||||
|
buildContent(lists, openTasks, completedTasks) as ListsUiState
|
||||||
|
}.recoveringFromProviderFailure { ListsUiState.Failure }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
||||||
|
|
||||||
|
private fun buildContent(
|
||||||
|
lists: List<TaskList>,
|
||||||
|
openTasks: List<Task>,
|
||||||
|
completedTasks: List<Task>,
|
||||||
|
): ListsUiState.Content {
|
||||||
|
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||||
|
// Count only top-level tasks: a subtask is represented by its parent (and
|
||||||
|
// its progress chip), and an open subtask under a *completed* parent must
|
||||||
|
// not read as an open item here. Mirrors the task list collapsing subtasks.
|
||||||
|
val topLevel = openTasks.filter { !it.isSubtask }
|
||||||
|
fun count(smart: SmartList) =
|
||||||
|
topLevel.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) }
|
||||||
|
|
||||||
|
val smartCounts = listOf(
|
||||||
|
SmartCount(SmartList.TODAY, count(SmartList.TODAY)),
|
||||||
|
SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)),
|
||||||
|
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
|
||||||
|
SmartCount(SmartList.ALL, topLevel.size),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Today ring: completed vs. total tasks *due today*. The numerator is the
|
||||||
|
// top-level completed tasks whose due date falls in today's window; the
|
||||||
|
// denominator adds the still-open ones (the Today smart count above).
|
||||||
|
val openToday = count(SmartList.TODAY)
|
||||||
|
val completedDueToday = completedTasks.count {
|
||||||
|
!it.isSubtask && it.due != null && it.due >= todayStart && it.due < todayEnd
|
||||||
|
}
|
||||||
|
val todayTotal = openToday + completedDueToday
|
||||||
|
|
||||||
|
// Upcoming preview: the next handful of open tasks due tomorrow onward,
|
||||||
|
// already sorted by the repository's default ordering.
|
||||||
|
val upcoming = topLevel
|
||||||
|
.filter { TaskFiltering.matches(it, TaskFilter.Smart(SmartList.UPCOMING), todayStart, todayEnd) }
|
||||||
|
.take(UPCOMING_PREVIEW)
|
||||||
|
val openByList = topLevel.groupingBy { it.listId }.eachCount()
|
||||||
|
val groups = lists
|
||||||
|
.groupBy { it.accountName }
|
||||||
|
.map { (account, accountLists) ->
|
||||||
|
AccountGroup(
|
||||||
|
accountName = account,
|
||||||
|
lists = accountLists.map { ListOverview(it, openByList[it.id] ?: 0) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedBy { it.accountName.lowercase() }
|
||||||
|
return ListsUiState.Content(
|
||||||
|
smartCounts = smartCounts,
|
||||||
|
groups = groups,
|
||||||
|
todayDone = completedDueToday,
|
||||||
|
todayTotal = todayTotal,
|
||||||
|
upcoming = upcoming,
|
||||||
|
allTasks = openTasks + completedTasks,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _writeFailure = MutableStateFlow<ListWriteFailure?>(null)
|
||||||
|
|
||||||
|
/** Set when a list write is refused; the screen shows it and clears it. */
|
||||||
|
val writeFailure: StateFlow<ListWriteFailure?> = _writeFailure.asStateFlow()
|
||||||
|
|
||||||
|
fun clearWriteFailure() { _writeFailure.value = null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a device-only list. The lists flow picks it up on the store change;
|
||||||
|
* a refusal (External mode, a provider that says no) surfaces through
|
||||||
|
* [writeFailure] rather than vanishing, because the sheet has already closed.
|
||||||
|
*/
|
||||||
|
fun createList(name: String, color: Int) = viewModelScope.launch {
|
||||||
|
if (name.isBlank()) return@launch
|
||||||
|
runCatching { repository.createLocalList(name.trim(), color) }
|
||||||
|
.onFailure { _writeFailure.value = ListWriteFailure.SAVE }
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user