Compare commits
68 Commits
v0.2.1
...
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 | |||
| 976d496d21 | |||
| 245f1db536 | |||
| 623e533547 | |||
| 2e50356f81 | |||
| e6f503c02a | |||
| c53511196d | |||
| a8595e26b4 | |||
| 411e27659f |
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.
|
||||||
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
|
||||||
@@ -37,14 +37,29 @@ jobs:
|
|||||||
- name: Reproducible-release invariant
|
- name: Reproducible-release invariant
|
||||||
run: bash scripts/check_reproducible_release.sh
|
run: bash scripts/check_reproducible_release.sh
|
||||||
|
|
||||||
# Decide whether anything that affects the app build changed. Docs,
|
# Decide whether anything that affects the app build changed. Docs, store
|
||||||
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
|
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||||
# Gradle work below but still report a green `ci`.
|
# the SDK + Gradle work below but still report a green `ci`.
|
||||||
- name: Classify change scope
|
- name: Classify change scope
|
||||||
id: 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: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
BASE="${{ github.base_ref }}"
|
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
|
# 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
|
# the PR branch forked several commits back; a shallow tip has no merge
|
||||||
# base with a divergent branch and `git diff base...HEAD` aborts.
|
# base with a divergent branch and `git diff base...HEAD` aborts.
|
||||||
@@ -58,11 +73,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
CHANGED=$(git diff --name-only "$MB" HEAD)
|
CHANGED=$(git diff --name-only "$MB" HEAD)
|
||||||
echo "Changed files:"; echo "$CHANGED"
|
echo "Changed files:"; echo "$CHANGED"
|
||||||
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
|
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"
|
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "code=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Docs/metadata-only change — skipping the Android build."
|
echo "Docs/metadata-only change — skipping the Android build."
|
||||||
|
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
@@ -72,9 +91,14 @@ jobs:
|
|||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
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
|
- name: Setup Android SDK
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
uses: android-actions/setup-android@v3
|
uses: https://github.com/android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
# Default ("tools platform-tools") drags in the Android Emulator
|
# Default ("tools platform-tools") drags in the Android Emulator
|
||||||
# (~300 MB) which the build never uses.
|
# (~300 MB) which the build never uses.
|
||||||
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
|
||||||
@@ -3,12 +3,18 @@ name: Release — F-Droid repo + Gitea/Codeberg release
|
|||||||
# A release is cut by merging a release branch into main with a bumped
|
# 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,
|
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
|
||||||
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
|
# 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 mirrors
|
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and publishes
|
||||||
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
|
# 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
|
# direct-download channel — the tag is an output of the pipeline, not its
|
||||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||||
# nothing.
|
# 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
|
# 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,
|
# 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
|
# without building an APK or creating a release. Used for key rotation / repo
|
||||||
@@ -27,6 +33,14 @@ jobs:
|
|||||||
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
||||||
# heavy job from running on every merge to main.
|
# heavy job from running on every merge to main.
|
||||||
detect:
|
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
|
||||||
outputs:
|
outputs:
|
||||||
is_release: ${{ steps.v.outputs.is_release }}
|
is_release: ${{ steps.v.outputs.is_release }}
|
||||||
@@ -42,8 +56,16 @@ jobs:
|
|||||||
- name: Resolve version and whether it is a new release
|
- name: Resolve version and whether it is a new release
|
||||||
id: v
|
id: v
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
||||||
|
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
||||||
|
# source does not have. A tag minted here on Gitea is therefore wiped
|
||||||
|
# by the next sync (Codeberg does not have it yet) and only reappears
|
||||||
|
# once the tag push at the end of this workflow propagates back.
|
||||||
|
# Asking Gitea inside that window would report "no tag" for a release
|
||||||
|
# that already shipped, and cut it a second time.
|
||||||
|
# Public repo, so this read needs no token.
|
||||||
|
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||||
@@ -65,15 +87,28 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
# A tag for this version already existing means the release shipped on
|
# A tag for this version already existing means the release shipped on
|
||||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
#
|
||||||
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
|
# Anything other than a clean 200/404 is treated as fatal rather than
|
||||||
if [ "$STATUS" = "200" ]; then
|
# as "no tag". A Codeberg outage or a network blip would otherwise
|
||||||
echo "Tag v$VERSION already exists — nothing to release."
|
# read as absent and re-cut a release that has already shipped —
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
# republishing to F-Droid. Failing here is recoverable; a duplicate
|
||||||
else
|
# release is not.
|
||||||
echo "No tag for v$VERSION yet — cutting the release."
|
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
||||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
case "$STATUS" in
|
||||||
fi
|
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
|
||||||
|
|
||||||
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||||
@@ -360,17 +395,21 @@ jobs:
|
|||||||
-F "attachment=@/tmp/$ASSET" \
|
-F "attachment=@/tmp/$ASSET" \
|
||||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
||||||
|
|
||||||
# Mirror the release to the Codeberg mirror as a direct-download channel
|
# Publish the release on Codeberg, which is canonical for tags and
|
||||||
# for users who don't want F-Droid. Gitea already push-mirrors branches +
|
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
|
||||||
# tags to Codeberg, but releases aren't git objects so they don't sync —
|
# to Gitea, but releases aren't git objects and don't sync in either
|
||||||
# we create the release there over the API and attach the signed APK plus
|
# direction — so this step pushes the tag straight to Codeberg and creates
|
||||||
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
|
# the release there over the API, attaching the signed APK plus a SHA-256
|
||||||
# key), so this adds no trust surface. Best-effort: a Codeberg outage
|
# checksum as the direct-download channel for users who don't want
|
||||||
# (it 504s under load) must never fail an already-published F-Droid
|
# F-Droid. The APK is identical to the F-Droid one (same app key), so this
|
||||||
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
# adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips
|
||||||
|
# cleanly if unset.
|
||||||
- name: Publish release to Codeberg
|
- name: Publish release to Codeberg
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
continue-on-error: 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:
|
env:
|
||||||
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||||
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||||
@@ -399,32 +438,58 @@ jobs:
|
|||||||
sed -i -e '/./,$!d' release-notes.md
|
sed -i -e '/./,$!d' release-notes.md
|
||||||
fi
|
fi
|
||||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
||||||
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > cb-payload.json
|
# 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
|
import json, sys
|
||||||
|
tag, pre = sys.argv[1:3]
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"tag_name": sys.argv[1],
|
"tag_name": tag,
|
||||||
"target_commitish": sys.argv[2],
|
"name": tag,
|
||||||
"name": sys.argv[1],
|
|
||||||
"body": open("release-notes.md").read(),
|
"body": open("release-notes.md").read(),
|
||||||
"draft": False,
|
"draft": False,
|
||||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||||
"prerelease": sys.argv[3] == "true",
|
"prerelease": pre == "true",
|
||||||
}))
|
}))
|
||||||
PY
|
PY
|
||||||
# Upsert (re-run safe). POST also creates the tag at target_commitish
|
# Create (or update) the release. Codeberg 500s on a POST/GET against a
|
||||||
# if the push mirror hasn't synced it yet.
|
# tag it has only just received — the release request outruns the
|
||||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
# indexing of the ref we pushed a moment ago — so a single attempt can
|
||||||
if [ -n "$ID" ]; then
|
# fail even though the very same call succeeds seconds later. Retry
|
||||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
# 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" \
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
-d @cb-payload.json "$API/releases/$ID"
|
-d @cb-payload.json "$API/releases")
|
||||||
else
|
echo "release POST attempt $attempt HTTP $CODE"
|
||||||
curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @cb-payload.json "$API/releases"
|
|
||||||
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
||||||
fi
|
[ -n "$ID" ] && break
|
||||||
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi
|
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.
|
# Attach APK + checksum, replacing any prior asset of the same name.
|
||||||
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
||||||
|
|||||||
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
|
||||||
|
|||||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
|||||||
[submodule "floret-kit"]
|
[submodule "floret-kit"]
|
||||||
path = floret-kit
|
path = floret-kit
|
||||||
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||||
|
|||||||
71
CHANGELOG.md
71
CHANGELOG.md
@@ -7,23 +7,62 @@ All notable changes to this project are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-08-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- M5 reminders onboarding & polish: a one-time reminder onboarding step after the
|
- Agendula now speaks **German** and **Brazilian Portuguese**, the first
|
||||||
provider grant explains that Agendula delivers due reminders itself and requests
|
community translations. Pick one under **Settings → App language**, or in
|
||||||
`POST_NOTIFICATIONS` (API 33+). A new **Settings** screen (gear on the lists
|
Android's own per-app language settings; leave it on *System default* and
|
||||||
overview), structured after Calendula as a category hub with sliding sub-screens
|
Agendula follows your phone. Anything not yet translated falls back to
|
||||||
(`CollapsingScaffold` + grouped category rows + full-screen `OptionPicker`):
|
English.
|
||||||
an About card, **Appearance** (theme, dynamic colour), **Task form** (which
|
- Agendula can now be translated. Pick or request a language on Weblate and
|
||||||
optional edit-form fields show by default, default list, the add-a-subtask-row
|
translate in the browser — the link sits at the top of the language picker in
|
||||||
opt-out), and **Reminders** (a master enable switch that re-requests the
|
**Settings → App language**. Partial translations are fine; anything
|
||||||
notification permission, a "when to remind" default offset, and — on Android 12
|
untranslated falls back to English.
|
||||||
only — an exact-alarm status row that deep-links to system settings).
|
|
||||||
- The reminders master switch gates the whole engine: turning it off clears every
|
### Changed
|
||||||
scheduled alarm and suppresses any that fire.
|
- Agendula's home is now **Codeberg** (`jlmakiola/agendula`) — that's where the
|
||||||
- Expressive top-bar actions: a reusable `ShapedActionButton` wraps an action icon
|
source, issues, pull requests and releases live. The Source and License links
|
||||||
in a tonal M3 Expressive `MaterialShapes` container (the settings entry uses the
|
in Settings, the issue-reporting link and the F-Droid metadata all point there
|
||||||
faceted Gem shape, tertiary tones), with a small press scale/rotate flourish.
|
now. The self-hosted Gitea instance stays as build infrastructure.
|
||||||
Future top-bar actions pick their own shape from the `ActionShapes` registry.
|
|
||||||
|
## [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
|
## [0.2.1] - 2026-07-19
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,33 @@ would expose provider details to a ViewModel or the UI, it's in the wrong layer.
|
|||||||
./gradlew lintDebug # Android lint (CI runs this on every PR)
|
./gradlew lintDebug # Android lint (CI runs this on every PR)
|
||||||
```
|
```
|
||||||
|
|
||||||
CI (`.gitea/workflows/ci.yaml`) runs a reproducible-release invariant check,
|
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
|
||||||
then lint → unit tests → debug build on every pull request, so run these locally
|
then lint → unit tests → debug build on every pull request, so run these locally
|
||||||
before opening a PR. Keep CI green.
|
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
|
||||||
|
|
||||||
| Layer | Lives in | Rule of thumb |
|
| Layer | Lives in | Rule of thumb |
|
||||||
|
|||||||
71
README.md
71
README.md
@@ -3,9 +3,10 @@
|
|||||||
<h1>Agendula</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,32 +14,66 @@ sync stack.</p>
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Agendula 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`, Agendula 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.
|
||||||
|
|
||||||
The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for
|
The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for
|
||||||
“things to be done” — given Calendula's `-ula` ending. Calendula keeps your days;
|
“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
|
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.)
|
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)
|
||||||
|
|
||||||
Agendula 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
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ android {
|
|||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
||||||
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
||||||
versionCode = 201
|
versionCode = 400
|
||||||
versionName = "0.2.1"
|
versionName = "0.4.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -111,12 +111,28 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
// Community translations are expected to be partial — a missing string
|
||||||
|
// falls back to the English base at runtime — so don't fail the build on
|
||||||
|
// it. 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 {
|
||||||
@@ -125,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))
|
||||||
@@ -145,7 +177,17 @@ 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)
|
||||||
@@ -173,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" />
|
||||||
@@ -74,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">
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
package de.jeanlucmakiola.agendula
|
package de.jeanlucmakiola.agendula
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import dagger.hilt.EntryPoint
|
import dagger.hilt.EntryPoint
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
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.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application entry point. Registered as android:name=".AgendulaApp". Besides
|
* Application entry point. Registered as android:name=".AgendulaApp". Besides
|
||||||
@@ -35,17 +39,42 @@ class AgendulaApp : Application() {
|
|||||||
issueTitle = getString(R.string.crash_report_issue_title),
|
issueTitle = getString(R.string.crash_report_issue_title),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val scheduler = EntryPointAccessors
|
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
||||||
.fromApplication(this, ReminderEntryPoint::class.java)
|
val scheduler = entryPoint.reminderScheduler()
|
||||||
.reminderScheduler()
|
// Mirror the stored storage mode into ProviderResolver and import a
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
// v0.3.x install's tasks, both before anything reads a store.
|
||||||
runCatching { scheduler.sync() }
|
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
|
@EntryPoint
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface ReminderEntryPoint {
|
interface AppEntryPoint {
|
||||||
fun reminderScheduler(): ReminderScheduler
|
fun reminderScheduler(): ReminderScheduler
|
||||||
|
fun startupGate(): StartupGate
|
||||||
|
fun providerResolver(): ProviderResolver
|
||||||
|
|
||||||
|
@ApplicationScope
|
||||||
|
fun applicationScope(): CoroutineScope
|
||||||
|
fun databaseCheckpoint(): DatabaseCheckpoint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class DemoSeeder @Inject constructor(
|
|||||||
repository.createTask(TaskForm(title = "Sketch the Agendula 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 {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.di
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@@ -10,12 +12,21 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
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.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.TasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
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.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import javax.inject.Provider
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
@@ -28,11 +39,11 @@ abstract class DataBindModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
|
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
|
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -44,7 +55,41 @@ object DataProvideModule {
|
|||||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
context.agendulaDataStore
|
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
|
@Provides
|
||||||
@IoDispatcher
|
@IoDispatcher
|
||||||
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,13 @@ import javax.inject.Qualifier
|
|||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class IoDispatcher
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ import androidx.datastore.preferences.core.intPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderLeadFor
|
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -39,15 +40,17 @@ data class Settings(
|
|||||||
val bottomAddBar: Boolean = false,
|
val bottomAddBar: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
||||||
* overrides the global default (a null value = no reminder); absent = inherit.
|
* 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, Int?> = emptyMap(),
|
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||||
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
||||||
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
||||||
) {
|
) {
|
||||||
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
||||||
fun reminderLeadFor(listId: Long): Int? =
|
fun reminderLeadFor(listId: Long): Int? =
|
||||||
perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes)
|
perListReminderOverride.reminderLeadsFor(listId, listOf(reminderLeadMinutes)).firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||||
@@ -80,6 +83,31 @@ class SettingsPrefs @Inject constructor(
|
|||||||
|
|
||||||
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
|
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. */
|
/** One-time reminder onboarding gate; false until the step has been shown. */
|
||||||
val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
|
val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
|
||||||
|
|
||||||
@@ -111,6 +139,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
|
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
|
||||||
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
|
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
|
||||||
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
|
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
|
val STORAGE_MODE = stringPreferencesKey("storage_mode")
|
||||||
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
|
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
|
||||||
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
|
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
@@ -45,7 +46,14 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ 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 android.os.SystemClock
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -20,10 +22,25 @@ import javax.inject.Inject
|
|||||||
class ProviderChangeReceiver : BroadcastReceiver() {
|
class ProviderChangeReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
@Inject lateinit var scheduler: ReminderScheduler
|
@Inject lateinit var scheduler: ReminderScheduler
|
||||||
|
@Inject lateinit var providerResolver: ProviderResolver
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
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()
|
val pending = goAsync()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -33,4 +50,11 @@ class ProviderChangeReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MIN_SYNC_INTERVAL_MS = 10_000L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var lastSyncAt = -MIN_SYNC_INTERVAL_MS
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,18 @@ import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The self-scheduled due-reminder engine. Tasks providers don't deliver
|
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
|
||||||
* reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager]
|
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
|
||||||
* alarm each, within a rolling window. Re-run on app start, boot and provider
|
* and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run
|
||||||
* change; it diffs against [ScheduledReminderStore] so only changed alarms move.
|
* 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
|
@Singleton
|
||||||
class ReminderScheduler @Inject constructor(
|
class ReminderScheduler @Inject constructor(
|
||||||
@@ -31,47 +34,89 @@ class ReminderScheduler @Inject constructor(
|
|||||||
private val providerResolver: ProviderResolver,
|
private val providerResolver: ProviderResolver,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) {
|
) {
|
||||||
suspend fun sync() = withContext(io) {
|
private val syncLock = Mutex()
|
||||||
val provider = providerResolver.resolve()
|
|
||||||
|
/**
|
||||||
|
* 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()
|
val settings = settingsPrefs.settings.first()
|
||||||
if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) {
|
// 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()
|
clearAll()
|
||||||
return@withContext
|
return
|
||||||
}
|
}
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val horizon = now + WINDOW_MS
|
val horizon = now + WINDOW_MS
|
||||||
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
||||||
.getOrElse { return@withContext }
|
.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
|
val desired = tasks
|
||||||
.filter { !it.isClosed && it.due != null }
|
.filter { !it.isClosed && it.due != null }
|
||||||
.mapNotNull { task ->
|
.mapNotNull { task ->
|
||||||
// The task's list may override the global lead, or opt out entirely
|
// A reminder set on the task itself wins; otherwise the task's list
|
||||||
// (override = null), in which case it gets no reminder at all.
|
// may override the global lead, or opt out entirely (override =
|
||||||
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
|
// null), in which case it gets no reminder at all.
|
||||||
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.toMap()
|
// The lower bound trails `now` so a reminder missed while the device was
|
||||||
.filterValues { it in now..horizon }
|
// 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()
|
val previous = store.all()
|
||||||
(previous.keys - desired.keys).forEach { cancel(it) }
|
(previous - desired).forEach { cancel(it) }
|
||||||
desired.forEach { (taskId, triggerAt) ->
|
(desired - previous).forEach { schedule(it) }
|
||||||
if (previous[taskId] != triggerAt) schedule(taskId, triggerAt)
|
|
||||||
}
|
|
||||||
store.replace(desired)
|
store.replace(desired)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
|
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
|
||||||
|
|
||||||
private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? {
|
private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? {
|
||||||
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
|
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags)
|
return PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
reminder.requestCode,
|
||||||
|
DueReminderReceiver.intent(context, reminder.taskId, reminder.triggerAt),
|
||||||
|
flags,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun schedule(taskId: Long, triggerAt: Long) {
|
private fun schedule(reminder: ScheduledReminder) {
|
||||||
val pi = pendingIntent(taskId, create = true) ?: return
|
val triggerAt = reminder.triggerAt
|
||||||
|
val pi = pendingIntent(reminder, create = true) ?: return
|
||||||
val am = alarmManager()
|
val am = alarmManager()
|
||||||
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||||
if (canExact) {
|
if (canExact) {
|
||||||
@@ -81,19 +126,21 @@ class ReminderScheduler @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancel(taskId: Long) {
|
private fun cancel(reminder: ScheduledReminder) {
|
||||||
pendingIntent(taskId, create = false)?.let {
|
pendingIntent(reminder, create = false)?.let {
|
||||||
alarmManager().cancel(it)
|
alarmManager().cancel(it)
|
||||||
it.cancel()
|
it.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun clearAll() {
|
private suspend fun clearAll() {
|
||||||
store.all().keys.forEach { cancel(it) }
|
store.all().forEach { cancel(it) }
|
||||||
store.replace(emptyMap())
|
store.replace(emptySet())
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,25 +9,38 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remembers which task reminders are currently scheduled (taskId → trigger time),
|
* One armed alarm. A recurring task has many occurrences sharing a [taskId], so
|
||||||
* so [ReminderScheduler] can diff against a fresh computation and cancel only the
|
* the trigger time is part of the identity — keying by task alone would collapse
|
||||||
* alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings.
|
* 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
|
@Singleton
|
||||||
class ScheduledReminderStore @Inject constructor(
|
class ScheduledReminderStore @Inject constructor(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
) {
|
) {
|
||||||
suspend fun all(): Map<Long, Long> =
|
suspend fun all(): Set<ScheduledReminder> =
|
||||||
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
||||||
val parts = entry.split('|')
|
val parts = entry.split('|')
|
||||||
val id = parts.getOrNull(0)?.toLongOrNull()
|
val id = parts.getOrNull(0)?.toLongOrNull()
|
||||||
val at = parts.getOrNull(1)?.toLongOrNull()
|
val at = parts.getOrNull(1)?.toLongOrNull()
|
||||||
if (id != null && at != null) id to at else null
|
if (id != null && at != null) ScheduledReminder(id, at) else null
|
||||||
}.toMap()
|
}.toSet()
|
||||||
|
|
||||||
suspend fun replace(scheduled: Map<Long, Long>) {
|
suspend fun replace(scheduled: Set<ScheduledReminder>) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet()
|
prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ import android.os.Looper
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
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.data.tasks.TasksContract.Tasks
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The only class that knows about the ContentResolver, [TasksContract] and the
|
* The only class that knows about the ContentResolver, [TasksContract] and the
|
||||||
@@ -83,6 +86,26 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
} ?: emptyList()
|
} ?: 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 ---------------------------------------------------------------
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
override fun insertTask(form: TaskForm): Long {
|
override fun insertTask(form: TaskForm): Long {
|
||||||
@@ -98,12 +121,109 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
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) {
|
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||||
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
|
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) {
|
override fun deleteTask(taskId: Long) {
|
||||||
resolver.delete(taskUri(authority(), taskId), null, null)
|
resolver.delete(taskUri(authority(), taskId), null, null)
|
||||||
}
|
}
|
||||||
@@ -120,6 +240,31 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id")
|
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 ----------------------------------------------------------
|
// --- observation ----------------------------------------------------------
|
||||||
|
|
||||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||||
@@ -127,9 +272,16 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||||
override fun onChange(selfChange: Boolean) = onChange()
|
override fun onChange(selfChange: Boolean) = onChange()
|
||||||
}
|
}
|
||||||
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
|
// Register both or neither: if the second call throws, the first
|
||||||
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
// registration would otherwise leak (no AutoCloseable was handed back yet).
|
||||||
return AutoCloseable { resolver.unregisterContentObserver(observer) }
|
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 {
|
private fun Map<String, Any?>.toContentValues(): ContentValues {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import android.content.Context
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every
|
* An external tasks provider Agendula can talk to. Every candidate runs the same
|
||||||
* candidate, so the [TasksContract] columns apply regardless of which is present.
|
* dmfs `TaskProvider`, so the [TasksContract] columns apply to either.
|
||||||
*/
|
*/
|
||||||
data class TaskProvider(
|
data class TaskProvider(
|
||||||
val authority: String,
|
val authority: String,
|
||||||
@@ -19,41 +16,122 @@ data class TaskProvider(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The A/B seam. Detects which tasks provider is installed at runtime and which
|
* Discovers the *external* tasks providers (OpenTasks, tasks.org) that
|
||||||
* permission set it needs, so nothing above the data layer hardcodes an
|
* [StorageMode.EXTERNAL] can be pointed at.
|
||||||
* authority. Under Posture B (bundled provider) this simply finds our own
|
*
|
||||||
* `org.dmfs.tasks` first. See docs/PLAN.md.
|
* 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
|
@Singleton
|
||||||
class ProviderResolver @Inject constructor(
|
class ProviderResolver @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
private val environment: ProviderEnvironment,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** The active provider, or `null` when no tasks provider is installed. */
|
private val modeListeners = CopyOnWriteArrayList<() -> Unit>()
|
||||||
fun resolve(): TaskProvider? {
|
|
||||||
for (candidate in CANDIDATES) {
|
/**
|
||||||
val info = context.packageManager.resolveContentProvider(candidate.authority, 0)
|
* The user's explicit choice, or `null` while they have not made one (which is
|
||||||
?: continue
|
* the normal state — most people never open Settings). Kept as a plain field
|
||||||
return candidate.copy(packageName = info.packageName)
|
* 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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasPermission(provider: TaskProvider): Boolean =
|
fun hasPermission(provider: TaskProvider): Boolean =
|
||||||
granted(provider.readPermission) && granted(provider.writePermission)
|
environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission)
|
||||||
|
|
||||||
private fun granted(permission: String): Boolean =
|
/**
|
||||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
* 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 {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
|
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
|
||||||
* `org.dmfs.provider.tasks.TaskProvider`, guarded by
|
* `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*`
|
||||||
* `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks`
|
* (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`.
|
||||||
* + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical
|
* OpenTasks is listed first as the canonical authority; on a device with
|
||||||
* authority; on a device with only one installed, order is moot.
|
* only one installed, order is moot.
|
||||||
*/
|
*/
|
||||||
val CANDIDATES: List<TaskProvider> = listOf(
|
val EXTERNAL_CANDIDATES: List<TaskProvider> = listOf(
|
||||||
TaskProvider(
|
TaskProvider(
|
||||||
authority = "org.dmfs.tasks",
|
authority = "org.dmfs.tasks",
|
||||||
readPermission = "org.dmfs.permission.READ_TASKS",
|
readPermission = "org.dmfs.permission.READ_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()
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
||||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -16,10 +17,17 @@ object TaskMapper {
|
|||||||
fun instant(name: String): Instant? =
|
fun instant(name: String): Instant? =
|
||||||
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
|
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
|
||||||
|
|
||||||
val instanceId = r.getLong(Tasks.ID) ?: 0L
|
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(
|
return Task(
|
||||||
id = instanceId,
|
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
|
||||||
taskId = r.getLong(Instances.TASK_ID) ?: instanceId,
|
|
||||||
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
|
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
|
||||||
title = r.getString(Tasks.TITLE).orEmpty(),
|
title = r.getString(Tasks.TITLE).orEmpty(),
|
||||||
description = r.getString(Tasks.DESCRIPTION),
|
description = r.getString(Tasks.DESCRIPTION),
|
||||||
@@ -38,13 +46,66 @@ object TaskMapper {
|
|||||||
listName = r.getString(Tasks.LIST_NAME),
|
listName = r.getString(Tasks.LIST_NAME),
|
||||||
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
||||||
parentId = r.getLong(Tasks.PARENT_ID),
|
parentId = r.getLong(Tasks.PARENT_ID),
|
||||||
isRecurring = r.getBoolean(Instances.IS_RECURRING),
|
isRecurring = recurring,
|
||||||
|
occurrenceStart = if (recurring) occurrenceAnchor(r) else null,
|
||||||
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
||||||
created = instant(Tasks.CREATED),
|
created = instant(Tasks.CREATED),
|
||||||
lastModified = instant(Tasks.LAST_MODIFIED),
|
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(
|
fun taskList(r: ColumnReader): TaskList = TaskList(
|
||||||
id = r.getLong(Lists.ID) ?: 0L,
|
id = r.getLong(Lists.ID) ?: 0L,
|
||||||
name = r.getString(Lists.NAME).orEmpty(),
|
name = r.getString(Lists.NAME).orEmpty(),
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
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.Lists
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
|
||||||
|
|
||||||
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
||||||
object TaskProjections {
|
object TaskProjections {
|
||||||
@@ -18,31 +16,9 @@ object TaskProjections {
|
|||||||
Lists.ACCOUNT_TYPE,
|
Lists.ACCOUNT_TYPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Read from the `instances` view (inherits all task columns). */
|
// No `instances` projection on purpose: that read passes `projection = null`
|
||||||
val INSTANCES: Array<String> = arrayOf(
|
// (all columns), because the view's shape differs across provider versions —
|
||||||
Tasks.ID,
|
// tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list
|
||||||
Instances.TASK_ID,
|
// here would drift out of sync with the by-name mapper and quietly drop
|
||||||
Tasks.LIST_ID,
|
// columns it depends on. See AndroidTasksDataSource.queryInstances.
|
||||||
Tasks.TITLE,
|
|
||||||
Tasks.DESCRIPTION,
|
|
||||||
Tasks.LOCATION,
|
|
||||||
Tasks.URL,
|
|
||||||
Tasks.PRIORITY,
|
|
||||||
Tasks.STATUS,
|
|
||||||
Tasks.PERCENT_COMPLETE,
|
|
||||||
Tasks.COMPLETED,
|
|
||||||
Tasks.IS_ALLDAY,
|
|
||||||
Tasks.TZ,
|
|
||||||
Instances.INSTANCE_START,
|
|
||||||
Instances.INSTANCE_DUE,
|
|
||||||
Tasks.TASK_COLOR,
|
|
||||||
Tasks.LIST_COLOR,
|
|
||||||
Tasks.LIST_NAME,
|
|
||||||
Tasks.ACCOUNT_NAME,
|
|
||||||
Tasks.PARENT_ID,
|
|
||||||
Instances.IS_RECURRING,
|
|
||||||
Instances.DISTANCE_FROM_CURRENT,
|
|
||||||
Tasks.CREATED,
|
|
||||||
Tasks.LAST_MODIFIED,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
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.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.toICal
|
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),
|
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
||||||
@@ -39,8 +51,17 @@ object TaskWriteMapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
|
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
|
||||||
put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
|
// All-day tasks are date-only in iCalendar. The provider reads them back
|
||||||
put(Tasks.DUE, form.due?.toEpochMilliseconds())
|
// 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)
|
put(Tasks.PARENT_ID, form.parentId)
|
||||||
// The provider treats a null tz as local time; set it explicitly for
|
// The provider treats a null tz as local time; set it explicitly for
|
||||||
// timed tasks so the stored instant is unambiguous across zones.
|
// timed tasks so the stored instant is unambiguous across zones.
|
||||||
@@ -48,6 +69,16 @@ object TaskWriteMapper {
|
|||||||
put(Tasks.TZ, if (timed) tzId else 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?> =
|
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
|
||||||
if (completed) {
|
if (completed) {
|
||||||
mapOf(
|
mapOf(
|
||||||
@@ -63,9 +94,26 @@ object TaskWriteMapper {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
/**
|
||||||
|
* 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.NAME to name.trim(),
|
||||||
Lists.COLOR to color,
|
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_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
|
||||||
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
|
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
|
||||||
Lists.VISIBLE to 1,
|
Lists.VISIBLE to 1,
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ package de.jeanlucmakiola.agendula.data.tasks
|
|||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
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. */
|
/** What to fetch from the provider. Smart-list date logic is applied above this. */
|
||||||
data class TaskQuery(
|
data class TaskQuery(
|
||||||
@@ -23,10 +34,67 @@ interface TasksDataSource {
|
|||||||
|
|
||||||
fun insertTask(form: TaskForm): Long
|
fun insertTask(form: TaskForm): Long
|
||||||
fun updateTask(taskId: Long, form: TaskForm)
|
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)
|
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 deleteTask(taskId: Long)
|
||||||
fun createLocalList(name: String, color: Int): 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. */
|
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
|
||||||
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,25 @@ interface TasksRepository {
|
|||||||
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
|
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
|
||||||
*/
|
*/
|
||||||
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
||||||
suspend fun setCompleted(taskId: Long, completed: Boolean)
|
/**
|
||||||
|
* 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)
|
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 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. */
|
/** Synchronous snapshot for the permission/onboarding gate. */
|
||||||
fun providerStatus(): ProviderStatus
|
fun providerStatus(): ProviderStatus
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
@@ -80,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) }
|
||||||
@@ -103,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.agendula.domain
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.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. */
|
||||||
@@ -15,7 +14,8 @@ data class TaskList(
|
|||||||
val owner: String?,
|
val owner: String?,
|
||||||
) {
|
) {
|
||||||
/** A device-only list Agendula (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"
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -19,6 +20,7 @@ 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.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||||
import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
|
import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
|
||||||
import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
|
import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
|
||||||
@@ -39,11 +41,23 @@ fun RootScreen(
|
|||||||
ActivityResultContracts.RequestMultiplePermissions(),
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
) { permissionViewModel.refresh() }
|
) { 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) {
|
when (permission.status) {
|
||||||
ProviderStatus.NO_PROVIDER -> Gate(
|
ProviderStatus.NO_PROVIDER -> Gate(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
title = stringResource(R.string.onboarding_no_provider_title),
|
title = stringResource(R.string.onboarding_no_provider_title),
|
||||||
body = stringResource(R.string.onboarding_no_provider_body),
|
body = stringResource(R.string.onboarding_no_provider_body),
|
||||||
|
secondaryAction = fallback,
|
||||||
|
onSecondaryAction = permissionViewModel::useOwnStore,
|
||||||
)
|
)
|
||||||
ProviderStatus.NEEDS_PERMISSION -> Gate(
|
ProviderStatus.NEEDS_PERMISSION -> Gate(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -51,6 +65,8 @@ fun RootScreen(
|
|||||||
body = stringResource(R.string.onboarding_permission_body),
|
body = stringResource(R.string.onboarding_permission_body),
|
||||||
action = stringResource(R.string.onboarding_permission_button),
|
action = stringResource(R.string.onboarding_permission_button),
|
||||||
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
|
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
|
||||||
|
secondaryAction = fallback,
|
||||||
|
onSecondaryAction = permissionViewModel::useOwnStore,
|
||||||
)
|
)
|
||||||
ProviderStatus.READY -> ReadyGate(modifier = modifier)
|
ProviderStatus.READY -> ReadyGate(modifier = modifier)
|
||||||
}
|
}
|
||||||
@@ -87,6 +103,8 @@ private fun Gate(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
action: String? = null,
|
action: String? = null,
|
||||||
onAction: () -> Unit = {},
|
onAction: () -> Unit = {},
|
||||||
|
secondaryAction: String? = null,
|
||||||
|
onSecondaryAction: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||||
@@ -96,5 +114,6 @@ private fun Gate(
|
|||||||
Text(title, style = MaterialTheme.typography.headlineSmall)
|
Text(title, style = MaterialTheme.typography.headlineSmall)
|
||||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||||
if (action != null) Button(onClick = onAction) { Text(action) }
|
if (action != null) Button(onClick = onAction) { Text(action) }
|
||||||
|
if (secondaryAction != null) TextButton(onClick = onSecondaryAction) { Text(secondaryAction) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.time.formatDateTime
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Clear
|
|
||||||
import androidx.compose.material.icons.rounded.Event
|
|
||||||
import androidx.compose.material3.DatePicker
|
|
||||||
import androidx.compose.material3.DatePickerDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TimePicker
|
|
||||||
import androidx.compose.material3.rememberDatePickerState
|
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import de.jeanlucmakiola.agendula.R
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
private val zone: ZoneId get() = ZoneId.systemDefault()
|
|
||||||
|
|
||||||
internal fun Instant.toLocalDate(): LocalDate =
|
|
||||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
|
|
||||||
|
|
||||||
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())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A labelled date(-time) field for the edit form: a tonal row showing the
|
|
||||||
* current value (or nothing), tappable to pick a date and — unless [allDay] —
|
|
||||||
* a time. A clear affordance appears once a value is set. Emits `null` when
|
|
||||||
* cleared. Styled to match the app's rounded tonal family.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun DateTimeField(
|
|
||||||
label: String,
|
|
||||||
value: Instant?,
|
|
||||||
allDay: Boolean,
|
|
||||||
onChange: (Instant?) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
var showDatePicker by remember { mutableStateOf(false) }
|
|
||||||
var showTimePicker by remember { mutableStateOf(false) }
|
|
||||||
var pendingDate by remember { mutableStateOf<LocalDate?>(null) }
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
onClick = { showDatePicker = true },
|
|
||||||
shape = RoundedCornerShape(22.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (value != null) {
|
|
||||||
IconButton(onClick = { onChange(null) }) {
|
|
||||||
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDatePicker) {
|
|
||||||
val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()))
|
|
||||||
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
|
||||||
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
|
||||||
DatePickerDialog(
|
|
||||||
onDismissRequest = { showDatePicker = false },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
showDatePicker = false
|
|
||||||
val millis = dateState.selectedDateMillis ?: return@TextButton
|
|
||||||
val date = java.time.Instant.ofEpochMilli(millis)
|
|
||||||
.atZone(ZoneOffset.UTC).toLocalDate()
|
|
||||||
if (allDay) {
|
|
||||||
onChange(localToInstant(date, LocalTime.MIDNIGHT))
|
|
||||||
} else {
|
|
||||||
pendingDate = date
|
|
||||||
showTimePicker = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { Text(stringResource(android.R.string.ok)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showDatePicker = false }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { DatePicker(state = dateState) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showTimePicker) {
|
|
||||||
val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
|
||||||
val timeState = rememberTimePickerState(
|
|
||||||
initialHour = base.toLocalTime().hour,
|
|
||||||
initialMinute = base.toLocalTime().minute,
|
|
||||||
)
|
|
||||||
Dialog(onDismissRequest = { showTimePicker = false }) {
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
TimePicker(state = timeState)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
) {
|
|
||||||
TextButton(onClick = { showTimePicker = false }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
TextButton(onClick = {
|
|
||||||
showTimePicker = false
|
|
||||||
val date = pendingDate ?: return@TextButton
|
|
||||||
onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute)))
|
|
||||||
}) { Text(stringResource(android.R.string.ok)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
||||||
@@ -54,7 +54,9 @@ fun ReminderLeadPicker(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
presets: List<Int> = REMINDER_PRESETS,
|
presets: List<Int> = REMINDER_PRESETS,
|
||||||
) {
|
) {
|
||||||
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
|
// 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 customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
@@ -65,7 +67,7 @@ fun ReminderLeadPicker(
|
|||||||
val options = buildList {
|
val options = buildList {
|
||||||
if (allowInherit) add(ReminderOverride.Inherit)
|
if (allowInherit) add(ReminderOverride.Inherit)
|
||||||
if (allowNone) add(ReminderOverride.None)
|
if (allowNone) add(ReminderOverride.None)
|
||||||
presets.forEach { add(ReminderOverride.Minutes(it)) }
|
presets.forEach { add(ReminderOverride.Minutes(listOf(it))) }
|
||||||
}
|
}
|
||||||
val rowCount = options.size + 1 // + the custom row
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ fun ReminderLeadPicker(
|
|||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(ReminderOverride.Minutes(minutes))
|
onSelect(ReminderOverride.Minutes(listOf(minutes)))
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -168,5 +170,5 @@ private fun CustomReminderEditor(
|
|||||||
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
||||||
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes.first())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ import androidx.graphics.shapes.RoundedPolygon
|
|||||||
* its own [shape] and [containerColor], so a row of them reads as a set of
|
* its own [shape] and [containerColor], so a row of them reads as a set of
|
||||||
* distinct little tokens rather than identical grey glyphs.
|
* distinct little tokens rather than identical grey glyphs.
|
||||||
*
|
*
|
||||||
* Pressing springs the icon down a touch and gives it a small turn — a light
|
* Pressing springs the whole shape down and gives it a turn — the scalloped
|
||||||
* expressive flourish, no library motion APIs needed.
|
* 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)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -46,15 +48,25 @@ fun ShapedActionButton(
|
|||||||
contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer,
|
contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
size: Dp = 40.dp,
|
size: Dp = 40.dp,
|
||||||
iconSize: Dp = 22.dp,
|
iconSize: Dp = 22.dp,
|
||||||
|
spinIcon: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val interaction = remember { MutableInteractionSource() }
|
val interaction = remember { MutableInteractionSource() }
|
||||||
val pressed by interaction.collectIsPressedAsState()
|
val pressed by interaction.collectIsPressedAsState()
|
||||||
val scale by animateFloatAsState(if (pressed) 0.88f else 1f, label = "actionScale")
|
// The shape (the scalloped cookie) turns and dips on press.
|
||||||
val rotation by animateFloatAsState(if (pressed) 24f else 0f, label = "actionRotation")
|
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(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.size(size),
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.scale(scale)
|
||||||
|
.rotate(shapeRotation),
|
||||||
shape = shape.toShape(),
|
shape = shape.toShape(),
|
||||||
color = containerColor,
|
color = containerColor,
|
||||||
contentColor = contentColor,
|
contentColor = contentColor,
|
||||||
@@ -64,10 +76,11 @@ fun ShapedActionButton(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = contentDescription,
|
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
|
modifier = Modifier
|
||||||
.size(iconSize)
|
.size(iconSize)
|
||||||
.scale(scale)
|
.rotate(iconRotation - shapeRotation),
|
||||||
.rotate(rotation),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,4 +95,10 @@ fun ShapedActionButton(
|
|||||||
object ActionShapes {
|
object ActionShapes {
|
||||||
/** Settings — a 4-sided cookie (rounded, scalloped square). */
|
/** Settings — a 4-sided cookie (rounded, scalloped square). */
|
||||||
val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
@@ -11,7 +12,6 @@ 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 {
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ import de.jeanlucmakiola.floret.time.formatTime
|
|||||||
import de.jeanlucmakiola.agendula.ui.common.localToInstant
|
import de.jeanlucmakiola.agendula.ui.common.localToInstant
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.agendula.ui.common.toLocalDate
|
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||||
|
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||||
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
|
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
|
||||||
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
@@ -178,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) }
|
||||||
@@ -653,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),
|
||||||
@@ -689,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,
|
||||||
@@ -703,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
|
||||||
@@ -865,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) },
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.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
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.lists
|
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.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.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -12,119 +20,262 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
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.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ListAlt
|
import androidx.compose.material.icons.automirrored.rounded.ListAlt
|
||||||
import androidx.compose.material.icons.rounded.Add
|
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.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.Settings
|
||||||
import androidx.compose.material.icons.rounded.Today
|
import androidx.compose.material.icons.rounded.Today
|
||||||
import androidx.compose.material.icons.rounded.Upcoming
|
import androidx.compose.material.icons.rounded.Upcoming
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MediumTopAppBar
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.res.stringResource
|
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.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
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.ListColorChip
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
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.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live
|
* Home: a Today progress hero (wavy ring over tasks due today), an Overdue + All
|
||||||
* counts, then the user's lists grouped by account. Tapping a card or row opens
|
* 2-up of tonal tiles, a live preview of the next upcoming tasks, then the user's
|
||||||
* that task list; the FAB starts a new task.
|
* 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.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ListsScreen(
|
fun ListsScreen(
|
||||||
onOpenFilter: (TaskFilter) -> Unit,
|
onOpenFilter: (TaskFilter) -> Unit,
|
||||||
|
onOpenTask: (Long) -> Unit,
|
||||||
onNewTask: () -> Unit,
|
onNewTask: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: ListsViewModel = hiltViewModel(),
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
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(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
MediumTopAppBar(
|
HomeTopBar(
|
||||||
title = { Text(stringResource(R.string.app_name)) },
|
searchActive = searchActive,
|
||||||
actions = {
|
query = query,
|
||||||
ShapedActionButton(
|
onQueryChange = { query = it },
|
||||||
shape = ActionShapes.Settings,
|
onToggleSearch = { if (searchActive) closeSearch() else searchActive = true },
|
||||||
icon = Icons.Rounded.Settings,
|
onOpenSettings = onOpenSettings,
|
||||||
contentDescription = stringResource(R.string.settings_title),
|
|
||||||
onClick = onOpenSettings,
|
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
size = 48.dp,
|
|
||||||
iconSize = 26.dp,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
ExtendedFloatingActionButton(
|
// The FAB would otherwise float over the search results.
|
||||||
onClick = onNewTask,
|
if (!searchActive) {
|
||||||
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
ExtendedFloatingActionButton(
|
||||||
text = { Text(stringResource(R.string.new_task)) },
|
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 ->
|
) { inner ->
|
||||||
when (val s = state) {
|
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
|
||||||
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
when (val s = state) {
|
||||||
ListsUiState.Failure -> CenteredMessage(stringResource(R.string.lists_failure), inner)
|
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||||
is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter)
|
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
|
@Composable
|
||||||
private fun ListsContent(
|
private fun ListsContent(
|
||||||
state: ListsUiState.Content,
|
state: ListsUiState.Content,
|
||||||
inner: PaddingValues,
|
|
||||||
onOpenFilter: (TaskFilter) -> Unit,
|
onOpenFilter: (TaskFilter) -> Unit,
|
||||||
|
onOpenTask: (Long) -> Unit,
|
||||||
|
onNewList: () -> Unit,
|
||||||
|
topPadding: androidx.compose.ui.unit.Dp,
|
||||||
|
bottomPadding: androidx.compose.ui.unit.Dp,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
top = inner.calculateTopPadding(),
|
top = topPadding,
|
||||||
bottom = inner.calculateBottomPadding() + 96.dp,
|
bottom = bottomPadding,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
item { SmartGrid(state.smartCounts, onOpenFilter) }
|
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()) {
|
if (state.groups.isEmpty()) {
|
||||||
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
|
item { EmptyLists(onNewList = onNewList) }
|
||||||
} else {
|
} else {
|
||||||
item { SectionHeader(stringResource(R.string.lists_header)) }
|
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 ->
|
state.groups.forEach { group ->
|
||||||
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
|
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
|
||||||
itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview ->
|
itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview ->
|
||||||
@@ -150,27 +301,394 @@ private fun ListsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** No lists at all — a fresh install, where nothing else on this screen works yet. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SmartGrid(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) {
|
private fun EmptyLists(onNewList: () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 32.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
counts.chunked(2).forEach { row ->
|
Text(
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
text = stringResource(R.string.lists_empty),
|
||||||
row.forEach { smart ->
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
SmartCard(
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
count = smart,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.weight(1f),
|
)
|
||||||
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) },
|
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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (row.size == 1) Spacer(Modifier.weight(1f))
|
}
|
||||||
|
// 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)
|
private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -217,12 +735,25 @@ private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionHeader(text: String) {
|
private fun SectionHeader(text: String, action: (@Composable () -> Unit)? = null) {
|
||||||
Text(
|
Row(
|
||||||
text = text,
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.titleMedium,
|
.fillMaxWidth()
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp),
|
.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
|
@Composable
|
||||||
|
|||||||
@@ -4,21 +4,30 @@ 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.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.floret.time.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
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.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Clock
|
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 ListOverview(val list: TaskList, val openCount: Int)
|
||||||
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
||||||
data class SmartCount(val smart: SmartList, val count: Int)
|
data class SmartCount(val smart: SmartList, val count: Int)
|
||||||
@@ -29,25 +38,41 @@ sealed interface ListsUiState {
|
|||||||
data class Content(
|
data class Content(
|
||||||
val smartCounts: List<SmartCount>,
|
val smartCounts: List<SmartCount>,
|
||||||
val groups: List<AccountGroup>,
|
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
|
) : ListsUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val UPCOMING_PREVIEW = 3
|
||||||
|
|
||||||
/** The home overview: smart lists with live counts, then user lists by account. */
|
/** The home overview: smart lists with live counts, then user lists by account. */
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ListsViewModel @Inject constructor(
|
class ListsViewModel @Inject constructor(
|
||||||
repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val state: StateFlow<ListsUiState> =
|
val state: StateFlow<ListsUiState> =
|
||||||
combine(
|
combine(
|
||||||
repository.taskLists(),
|
repository.taskLists(),
|
||||||
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
|
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
|
||||||
) { lists, openTasks ->
|
// Open smart lists drop completed tasks, but the Today ring needs the
|
||||||
buildContent(lists, openTasks) as ListsUiState
|
// ones already ticked off to show "x of y done", so read them too.
|
||||||
}.catch { emit(ListsUiState.Failure) }
|
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)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
||||||
|
|
||||||
private fun buildContent(lists: List<TaskList>, openTasks: List<Task>): ListsUiState.Content {
|
private fun buildContent(
|
||||||
|
lists: List<TaskList>,
|
||||||
|
openTasks: List<Task>,
|
||||||
|
completedTasks: List<Task>,
|
||||||
|
): ListsUiState.Content {
|
||||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||||
// Count only top-level tasks: a subtask is represented by its parent (and
|
// 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
|
// its progress chip), and an open subtask under a *completed* parent must
|
||||||
@@ -62,6 +87,21 @@ class ListsViewModel @Inject constructor(
|
|||||||
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
|
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
|
||||||
SmartCount(SmartList.ALL, topLevel.size),
|
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 openByList = topLevel.groupingBy { it.listId }.eachCount()
|
||||||
val groups = lists
|
val groups = lists
|
||||||
.groupBy { it.accountName }
|
.groupBy { it.accountName }
|
||||||
@@ -72,6 +112,31 @@ class ListsViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sortedBy { it.accountName.lowercase() }
|
.sortedBy { it.accountName.lowercase() }
|
||||||
return ListsUiState.Content(smartCounts, groups)
|
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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ fun AgendulaNavHost(modifier: Modifier = Modifier) {
|
|||||||
composable(Dest.LISTS) {
|
composable(Dest.LISTS) {
|
||||||
ListsScreen(
|
ListsScreen(
|
||||||
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
|
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
|
||||||
|
onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) },
|
||||||
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
|
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
|
||||||
onOpenSettings = { nav.navigate(Dest.SETTINGS) },
|
onOpenSettings = { nav.navigate(Dest.SETTINGS) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.permission
|
package de.jeanlucmakiola.agendula.ui.permission
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.buffer
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class PermissionUiState(
|
data class PermissionUiState(
|
||||||
@@ -20,23 +32,51 @@ data class PermissionUiState(
|
|||||||
* Gates app entry: is a tasks provider installed, and do we hold its permissions?
|
* Gates app entry: is a tasks provider installed, and do we hold its permissions?
|
||||||
* The Composable owns the actual permission-launcher and store intents; this VM
|
* The Composable owns the actual permission-launcher and store intents; this VM
|
||||||
* supplies the [status] and the exact permission strings to ask for.
|
* supplies the [status] and the exact permission strings to ask for.
|
||||||
|
*
|
||||||
|
* In the default Own mode this gate never appears at all — the store is our own
|
||||||
|
* Room database, so there is nothing to install and nothing to grant. It exists
|
||||||
|
* for External mode, which also makes it the only screen an External user can
|
||||||
|
* reach once their provider app stops answering: hence [useOwnStore].
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PermissionViewModel @Inject constructor(
|
class PermissionViewModel @Inject constructor(
|
||||||
private val repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
private val providerResolver: ProviderResolver,
|
private val providerResolver: ProviderResolver,
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(PermissionUiState())
|
private val refreshes = MutableStateFlow(0)
|
||||||
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
init { refresh() }
|
// Re-evaluated when the *resolver's* mode lands, not when the preference is
|
||||||
|
// written: anything read in between still answers for the store we just left.
|
||||||
|
// Conflated, as everywhere else this signal is bridged: only the latest mode
|
||||||
|
// matters, and a full buffer drops it rather than the ones it supersedes.
|
||||||
|
private val modeChanges: Flow<Unit> = callbackFlow {
|
||||||
|
trySend(Unit)
|
||||||
|
val handle = providerResolver.onModeChanged { trySend(Unit) }
|
||||||
|
awaitClose { handle.close() }
|
||||||
|
}.buffer(Channel.CONFLATED)
|
||||||
|
|
||||||
|
val state: StateFlow<PermissionUiState> =
|
||||||
|
combine(refreshes, modeChanges) { _, _ -> currentState() }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), currentState())
|
||||||
|
|
||||||
/** Re-read provider + permission state (call after returning from a request). */
|
/** Re-read provider + permission state (call after returning from a request). */
|
||||||
fun refresh() {
|
fun refresh() = refreshes.update { it + 1 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave a store this device can no longer read. The provider app can be
|
||||||
|
* uninstalled, or its permission revoked, after External was chosen — and the
|
||||||
|
* gate is then the only screen reachable, Settings included. Our own store
|
||||||
|
* always reads, so it is the way out.
|
||||||
|
*/
|
||||||
|
fun useOwnStore() = viewModelScope.launch { prefs.setStorageMode(StorageMode.OWN) }
|
||||||
|
|
||||||
|
private fun currentState(): PermissionUiState {
|
||||||
val provider = providerResolver.resolve()
|
val provider = providerResolver.resolve()
|
||||||
_state.value = PermissionUiState(
|
return PermissionUiState(
|
||||||
status = repository.providerStatus(),
|
status = repository.providerStatus(),
|
||||||
|
// Null in OWN mode, where there is no provider and nothing to grant.
|
||||||
permissionsToRequest = provider
|
permissionsToRequest = provider
|
||||||
?.let { listOf(it.readPermission, it.writePermission) }
|
?.let { listOf(it.readPermission, it.writePermission) }
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
|
|||||||
@@ -43,11 +43,13 @@ import androidx.compose.material.icons.filled.Gavel
|
|||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.Translate
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material.icons.rounded.AccountTree
|
import androidx.compose.material.icons.rounded.AccountTree
|
||||||
import androidx.compose.material.icons.rounded.Circle
|
import androidx.compose.material.icons.rounded.Circle
|
||||||
import androidx.compose.material.icons.rounded.Flag
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
import androidx.compose.material.icons.rounded.Percent
|
import androidx.compose.material.icons.rounded.Percent
|
||||||
|
import androidx.compose.material.icons.rounded.Storage
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -55,7 +57,6 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -74,17 +75,14 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
|
import de.jeanlucmakiola.agendula.ui.export.ExportScreen
|
||||||
import de.jeanlucmakiola.floret.components.AboutCard
|
import de.jeanlucmakiola.floret.components.AboutCard
|
||||||
import de.jeanlucmakiola.floret.components.AboutLink
|
import de.jeanlucmakiola.floret.components.AboutLink
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.LanguagePickerRow
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -92,12 +90,26 @@ import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
|
|||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
private enum class SettingsSection { Appearance, TaskForm, Reminders }
|
private enum class SettingsSection {
|
||||||
|
Appearance,
|
||||||
|
TaskForm,
|
||||||
|
Reminders,
|
||||||
|
Storage,
|
||||||
|
Export,
|
||||||
|
;
|
||||||
|
|
||||||
|
/** Where back goes: Export is opened from Storage, not from the hub. */
|
||||||
|
val parent: SettingsSection?
|
||||||
|
get() = if (this == Export) Storage else null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token-based accent for a leading icon chip (container / on-container pair),
|
* Token-based accent for a leading icon chip (container / on-container pair),
|
||||||
@@ -123,7 +135,7 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
// Inside a sub-screen, system back (button or gesture) returns to the hub
|
// Inside a sub-screen, system back (button or gesture) returns to the hub
|
||||||
// rather than popping the whole Settings destination to the lists overview.
|
// rather than popping the whole Settings destination to the lists overview.
|
||||||
BackHandler(enabled = section != null) { section = null }
|
BackHandler(enabled = section != null) { section = section?.parent }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -141,6 +153,19 @@ fun SettingsScreen(
|
|||||||
SlideInSection(visible = section == SettingsSection.Reminders) {
|
SlideInSection(visible = section == SettingsSection.Reminders) {
|
||||||
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
}
|
}
|
||||||
|
// Storage stays composed under Export, so the deeper screen slides over it.
|
||||||
|
val storageOpen = section == SettingsSection.Storage ||
|
||||||
|
section?.parent == SettingsSection.Storage
|
||||||
|
SlideInSection(visible = storageOpen) {
|
||||||
|
StorageScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onOpenExport = { section = SettingsSection.Export },
|
||||||
|
onBack = { section = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SlideInSection(visible = section == SettingsSection.Export) {
|
||||||
|
ExportScreen(onBack = { section = SettingsSection.Storage })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,19 +235,77 @@ private fun SettingsHub(
|
|||||||
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
|
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
|
||||||
onClick = { onOpenSection(SettingsSection.Reminders) },
|
onClick = { onOpenSection(SettingsSection.Reminders) },
|
||||||
)
|
)
|
||||||
LanguagePickerRow(
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_section_storage),
|
||||||
|
summary = stringResource(R.string.settings_storage_subtitle),
|
||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
title = stringResource(R.string.settings_language),
|
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Neutral) },
|
||||||
autoLabel = stringResource(R.string.settings_language_auto),
|
onClick = { onOpenSection(SettingsSection.Storage) },
|
||||||
localesConfig = R.xml.locales_config,
|
|
||||||
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
|
|
||||||
)
|
)
|
||||||
|
LanguageRow(position = Position.Middle)
|
||||||
ReportProblemRow(position = Position.Bottom)
|
ReportProblemRow(position = Position.Bottom)
|
||||||
|
|
||||||
AppVersionText()
|
AppVersionText()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app-language row. Deliberately not floret-kit's `LanguagePickerRow`: the
|
||||||
|
* picker it opens carries a "Help translate" header, and inviting contributions
|
||||||
|
* right where a user goes looking for their language is app-specific framing,
|
||||||
|
* not a family primitive. Everything else matches that recipe.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LanguageRow(position: Position) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||||
|
// row updates instantly even before the recreation lands.
|
||||||
|
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||||
|
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
|
||||||
|
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_language),
|
||||||
|
summary = languageLabel(current),
|
||||||
|
position = position,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
|
||||||
|
onClick = { showDialog = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_language),
|
||||||
|
predictiveBack = true,
|
||||||
|
options = options,
|
||||||
|
selected = current,
|
||||||
|
label = { languageLabel(it) },
|
||||||
|
onSelect = {
|
||||||
|
current = it
|
||||||
|
AppLanguage.apply(it)
|
||||||
|
},
|
||||||
|
onDismiss = { showDialog = false },
|
||||||
|
// Invite contributions right where users pick their language.
|
||||||
|
header = {
|
||||||
|
val translateUrl = stringResource(R.string.about_translate_url)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_translate),
|
||||||
|
summary = stringResource(R.string.settings_translate_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
|
||||||
|
onClick = { openUrl(context, translateUrl) },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun languageLabel(tag: String?): String =
|
||||||
|
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||||
|
|
||||||
/** Opens the project's issue tracker; no data leaves the device until submitted. */
|
/** Opens the project's issue tracker; no data leaves the device until submitted. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReportProblemRow(position: Position) {
|
private fun ReportProblemRow(position: Position) {
|
||||||
@@ -483,10 +566,10 @@ private fun RemindersScreen(
|
|||||||
if (showOffset) {
|
if (showOffset) {
|
||||||
ReminderLeadPicker(
|
ReminderLeadPicker(
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
selected = ReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
selected = ReminderOverride.Minutes(listOf(state.settings.reminderLeadMinutes)),
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
allowNone = false,
|
allowNone = false,
|
||||||
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
|
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes.first()) },
|
||||||
onDismiss = { showOffset = false },
|
onDismiss = { showOffset = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -504,14 +587,8 @@ private fun RemindersScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
||||||
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride {
|
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride =
|
||||||
val map = state.settings.perListReminderOverride
|
state.settings.perListReminderOverride.reminderOverrideFor(listId)
|
||||||
return when {
|
|
||||||
!map.containsKey(listId) -> ReminderOverride.Inherit
|
|
||||||
map[listId] == null -> ReminderOverride.None
|
|
||||||
else -> ReminderOverride.Minutes(map.getValue(listId)!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Row summary for a list: its override, or the inherited global default. */
|
/** Row summary for a list: its override, or the inherited global default. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -519,7 +596,7 @@ private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): S
|
|||||||
ReminderOverride.Inherit ->
|
ReminderOverride.Inherit ->
|
||||||
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
||||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes.first())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -623,15 +700,10 @@ private fun rememberExactAlarmAllowed(context: Context): Boolean {
|
|||||||
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
|
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
OnResume {
|
||||||
DisposableEffect(lifecycle) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
val obs = LifecycleEventObserver { _, event ->
|
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
|
||||||
if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
lifecycle.addObserver(obs)
|
|
||||||
onDispose { lifecycle.removeObserver(obs) }
|
|
||||||
}
|
}
|
||||||
return allowed
|
return allowed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,27 @@ package de.jeanlucmakiola.agendula.ui.settings
|
|||||||
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.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TaskProvider
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
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.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -23,6 +32,22 @@ data class SettingsUiState(
|
|||||||
val lists: List<TaskList> = emptyList(),
|
val lists: List<TaskList> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The storage half of Settings: which store is active, and what picking the
|
||||||
|
* other one would mean on this device.
|
||||||
|
*
|
||||||
|
* Kept apart from [SettingsUiState] because that one is collected for the whole
|
||||||
|
* Activity lifetime to drive the theme, and re-probing PackageManager on every
|
||||||
|
* theme emission would be work for nothing.
|
||||||
|
*/
|
||||||
|
data class StorageUiState(
|
||||||
|
val mode: StorageMode,
|
||||||
|
/** The external provider installed here, or null when there is none to pick. */
|
||||||
|
val external: TaskProvider? = null,
|
||||||
|
/** That provider's own app name, for a row that names what it is switching to. */
|
||||||
|
val externalLabel: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drives both the Settings screen and the app theme (MainActivity collects the
|
* Drives both the Settings screen and the app theme (MainActivity collects the
|
||||||
* same instance), so a theme change applies app-wide at once.
|
* same instance), so a theme change applies app-wide at once.
|
||||||
@@ -30,14 +55,48 @@ data class SettingsUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
|
private val resolver: ProviderResolver,
|
||||||
|
private val environment: ProviderEnvironment,
|
||||||
|
@IoDispatcher io: CoroutineDispatcher,
|
||||||
repository: TasksRepository,
|
repository: TasksRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// MainActivity collects this for the theme, above the permission gate and for
|
||||||
|
// the whole Activity lifetime — so the list flow must survive the pre-grant
|
||||||
|
// SecurityException and recover once permission is given, not die for good.
|
||||||
val state: StateFlow<SettingsUiState> =
|
val state: StateFlow<SettingsUiState> =
|
||||||
combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists ->
|
combine(
|
||||||
|
prefs.settings,
|
||||||
|
repository.taskLists().recoveringFromProviderFailure { emptyList() },
|
||||||
|
) { settings, lists ->
|
||||||
SettingsUiState(settings, lists)
|
SettingsUiState(settings, lists)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
||||||
|
|
||||||
|
// Bumped to re-probe the device; installing a provider or granting its
|
||||||
|
// permission happens outside the app, so nothing else would emit.
|
||||||
|
private val providerProbe = MutableStateFlow(0)
|
||||||
|
|
||||||
|
// Null until the first emission lands: the mode comes from DataStore and the
|
||||||
|
// rest from PackageManager, off the main thread, so any seeded default would
|
||||||
|
// name the wrong store for the first frames. flowOn, because every field here
|
||||||
|
// costs a PackageManager lookup or a permission check.
|
||||||
|
val storage: StateFlow<StorageUiState?> =
|
||||||
|
combine(prefs.storageMode, providerProbe) { stored, _ ->
|
||||||
|
val external = resolver.resolveExternal()
|
||||||
|
StorageUiState(
|
||||||
|
// No stored choice is the normal state; show what autoMode resolves
|
||||||
|
// to rather than a default that may not be the store in use.
|
||||||
|
mode = stored ?: resolver.autoMode(),
|
||||||
|
external = external,
|
||||||
|
externalLabel = external?.packageName?.let(environment::appLabel),
|
||||||
|
)
|
||||||
|
}.flowOn(io).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
|
/** Re-read the device's provider state, after a permission request or a resume. */
|
||||||
|
fun refreshStorage() = providerProbe.update { it + 1 }
|
||||||
|
|
||||||
|
fun setStorageMode(mode: StorageMode) = viewModelScope.launch { prefs.setStorageMode(mode) }
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
|
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||||
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
|
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Apps
|
||||||
|
import androidx.compose.material.icons.rounded.PhoneAndroid
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TaskProvider
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the tasks live: the store picker the resolver's `autoMode()` has always
|
||||||
|
* assumed, plus the way out of a store that lives in our own private storage.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun StorageScreen(
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onOpenExport: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val storage by viewModel.storage.collectAsStateWithLifecycle()
|
||||||
|
var showPicker by remember { mutableStateOf(false) }
|
||||||
|
var denied by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// The mode is committed only once the grant is in — switching first drops the
|
||||||
|
// user on the app-wide permission gate.
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
) { grants ->
|
||||||
|
viewModel.refreshStorage()
|
||||||
|
val granted = grants.isNotEmpty() && grants.values.all { it }
|
||||||
|
denied = !granted
|
||||||
|
if (granted) viewModel.setStorageMode(StorageMode.EXTERNAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A provider can be installed, or its permission revoked, while we're away.
|
||||||
|
// Deliberately does not clear [denied]: this fires on returning from the
|
||||||
|
// permission dialog too, and would wipe the refusal before it is read.
|
||||||
|
OnResume { viewModel.refreshStorage() }
|
||||||
|
|
||||||
|
CollapsingScaffold(title = stringResource(R.string.settings_section_storage), onBack = onBack) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_task_store),
|
||||||
|
summary = storage?.let { storeLabel(it) },
|
||||||
|
position = Position.Top,
|
||||||
|
// Nothing to pick until the stored mode has landed; opening the picker
|
||||||
|
// on the seeded state would offer the wrong store as the current one.
|
||||||
|
onClick = storage?.let {
|
||||||
|
{
|
||||||
|
denied = false
|
||||||
|
showPicker = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_export),
|
||||||
|
summary = stringResource(R.string.settings_export_hint),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = onOpenExport,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (denied) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_store_permission_denied),
|
||||||
|
summary = stringResource(R.string.settings_store_permission_denied_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { context.openAppSettings() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storage?.let { state ->
|
||||||
|
if (showPicker) {
|
||||||
|
StorePicker(
|
||||||
|
storage = state,
|
||||||
|
onSelect = { mode ->
|
||||||
|
val external = state.external
|
||||||
|
if (mode == StorageMode.EXTERNAL && external != null) {
|
||||||
|
// Asked even when the grant looks held: an already-granted
|
||||||
|
// request returns at once, a stale belief would strand them.
|
||||||
|
permissionLauncher.launch(
|
||||||
|
arrayOf(external.readPermission, external.writePermission),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
viewModel.setStorageMode(mode)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { showPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two stores, as rows. External is offered only when a provider is actually
|
||||||
|
* installed — dimmed and inert otherwise, because a mode with nothing behind it
|
||||||
|
* empties the app.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun StorePicker(
|
||||||
|
storage: StorageUiState,
|
||||||
|
onSelect: (StorageMode) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
FullScreenPicker(title = stringResource(R.string.settings_task_store), onDismiss = onDismiss) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_task_store_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
val select: (StorageMode) -> Unit = { chosen ->
|
||||||
|
onSelect(chosen)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
val external = storage.external
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_store_own),
|
||||||
|
summary = stringResource(R.string.settings_store_own_hint),
|
||||||
|
position = Position.Top,
|
||||||
|
selected = storage.mode == StorageMode.OWN,
|
||||||
|
leading = { Icon(Icons.Rounded.PhoneAndroid, contentDescription = null) },
|
||||||
|
trailing = if (storage.mode == StorageMode.OWN) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { select(StorageMode.OWN) },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = externalTitle(external, storage.externalLabel),
|
||||||
|
summary = stringResource(
|
||||||
|
if (external == null) {
|
||||||
|
R.string.settings_store_external_missing
|
||||||
|
} else {
|
||||||
|
R.string.settings_store_external_hint
|
||||||
|
},
|
||||||
|
),
|
||||||
|
position = Position.Bottom,
|
||||||
|
selected = storage.mode == StorageMode.EXTERNAL,
|
||||||
|
dimmed = external == null,
|
||||||
|
leading = { Icon(Icons.Rounded.Apps, contentDescription = null) },
|
||||||
|
trailing = if (storage.mode == StorageMode.EXTERNAL) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = if (external != null) ({ select(StorageMode.EXTERNAL) }) else null,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active store, named the way the picker names it. */
|
||||||
|
@Composable
|
||||||
|
private fun storeLabel(storage: StorageUiState): String = when (storage.mode) {
|
||||||
|
StorageMode.OWN -> stringResource(R.string.settings_store_own)
|
||||||
|
StorageMode.EXTERNAL -> externalTitle(storage.external, storage.externalLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The provider's own app name, its authority, or the generic wording. */
|
||||||
|
@Composable
|
||||||
|
private fun externalTitle(provider: TaskProvider?, label: String?): String =
|
||||||
|
label ?: provider?.authority ?: stringResource(R.string.settings_store_external)
|
||||||
|
|
||||||
|
private fun Context.openAppSettings() {
|
||||||
|
runCatching {
|
||||||
|
startActivity(
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:$packageName".toUri()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ import androidx.compose.material.icons.rounded.Add
|
|||||||
import androidx.compose.material.icons.rounded.Check
|
import androidx.compose.material.icons.rounded.Check
|
||||||
import androidx.compose.material.icons.rounded.Checklist
|
import androidx.compose.material.icons.rounded.Checklist
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
|
import androidx.compose.material.icons.rounded.Edit
|
||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
import androidx.compose.material.icons.rounded.ExpandMore
|
||||||
import androidx.compose.material.icons.rounded.Flag
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
@@ -97,7 +98,12 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskSection
|
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSections
|
import de.jeanlucmakiola.agendula.domain.TaskSections
|
||||||
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||||
|
import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet
|
||||||
|
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChip
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||||
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
|
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
@@ -123,8 +129,22 @@ fun TaskListScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
val content = state as? TaskListUiState.Content
|
val content = state as? TaskListUiState.Content
|
||||||
val listName = content?.listName
|
val list = content?.list
|
||||||
|
val listName = list?.name
|
||||||
val listId = (filter as? TaskFilter.OfList)?.listId
|
val listId = (filter as? TaskFilter.OfList)?.listId
|
||||||
|
// Editing is offered for a device-only list. A collection that belongs to an
|
||||||
|
// account is the server's to rename or remove, not ours.
|
||||||
|
var editingList by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle()
|
||||||
|
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
|
||||||
|
// The list this screen is about is gone; there is nothing left to show.
|
||||||
|
LaunchedEffect(listDeleted) { if (listDeleted) onBack() }
|
||||||
|
LaunchedEffect(listWriteFailure) {
|
||||||
|
if (listWriteFailure != null) {
|
||||||
|
delay(4_000)
|
||||||
|
viewModel.clearListWriteFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
// One add affordance, never two: a real list with the setting on gets a pinned
|
// One add affordance, never two: a real list with the setting on gets a pinned
|
||||||
// bottom quick-add bar; everything else (incl. smart lists, which have no single
|
// bottom quick-add bar; everything else (incl. smart lists, which have no single
|
||||||
// target list) gets the floating "New task" button.
|
// target list) gets the floating "New task" button.
|
||||||
@@ -159,6 +179,16 @@ fun TaskListScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
if (list != null && list.isLocal) {
|
||||||
|
IconButton(onClick = { editingList = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Edit,
|
||||||
|
contentDescription = stringResource(R.string.list_edit_title),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -189,61 +219,50 @@ fun TaskListScreen(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.padding(start = 16.dp, bottom = inner.calculateBottomPadding() + 16.dp)
|
.padding(
|
||||||
.height(56.dp),
|
start = SnackChipMargin,
|
||||||
|
bottom = inner.calculateBottomPadding() + SnackChipMargin,
|
||||||
|
)
|
||||||
|
.height(SnackChipHeight),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
UndoChip(
|
// One chip, one anchor: the undo receipt takes precedence, and a
|
||||||
visible = undoTarget != null,
|
// refused list write reports itself once the undo window is clear.
|
||||||
onUndo = {
|
val failure = listWriteFailure
|
||||||
undoTarget?.let { viewModel.undoDelete(it.taskId) }
|
if (undoTarget != null || failure == null) {
|
||||||
undoTarget = null
|
SnackChip(
|
||||||
},
|
visible = undoTarget != null,
|
||||||
)
|
message = stringResource(R.string.task_deleted),
|
||||||
}
|
actionLabel = stringResource(R.string.undo),
|
||||||
}
|
onAction = {
|
||||||
}
|
undoTarget?.let { viewModel.undoDelete(it.taskId) }
|
||||||
}
|
undoTarget = null
|
||||||
|
},
|
||||||
/**
|
)
|
||||||
* A compact floating "snackchip" for an undoable delete — a rounded pill (not a
|
} else {
|
||||||
* full-width snackbar) sized to its content, sliding up from the bottom centre.
|
SnackChip(
|
||||||
*/
|
visible = true,
|
||||||
@Composable
|
message = stringResource(listWriteFailureMessage(failure)),
|
||||||
private fun UndoChip(visible: Boolean, onUndo: () -> Unit, modifier: Modifier = Modifier) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = slideInVertically { it } + fadeIn(),
|
|
||||||
exit = slideOutVertically { it } + fadeOut(),
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
shape = RoundedCornerShape(50),
|
|
||||||
shadowElevation = 6.dp,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(start = 20.dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.task_deleted),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
TextButton(
|
|
||||||
onClick = onUndo,
|
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.undo),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editingList && list != null) {
|
||||||
|
ListEditorSheet(
|
||||||
|
initial = list,
|
||||||
|
onSave = { name, color -> viewModel.updateList(list.id, name, color) },
|
||||||
|
onDismiss = { editingList = false },
|
||||||
|
onDelete = { viewModel.deleteList(list.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wording for a refused list write. */
|
||||||
|
private fun listWriteFailureMessage(failure: ListWriteFailure): Int = when (failure) {
|
||||||
|
ListWriteFailure.SAVE -> R.string.list_save_failed
|
||||||
|
ListWriteFailure.DELETE -> R.string.list_delete_failed
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@@ -806,20 +825,20 @@ private fun SubtaskExpandButton(expanded: Boolean, onToggle: () -> Unit) {
|
|||||||
|
|
||||||
/** A visual row in a flattened section run: a top-level task, or one of its subtasks. */
|
/** A visual row in a flattened section run: a top-level task, or one of its subtasks. */
|
||||||
private sealed interface ListRow {
|
private sealed interface ListRow {
|
||||||
val key: Long
|
val key: String
|
||||||
|
|
||||||
data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow {
|
data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow {
|
||||||
override val key: Long get() = task.taskId
|
override val key: String get() = task.occurrenceKey
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Sub(val task: Task) : ListRow {
|
data class Sub(val task: Task) : ListRow {
|
||||||
override val key: Long get() = task.taskId
|
override val key: String get() = task.occurrenceKey
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The inline "add a subtask" row that closes an expanded group. */
|
/** The inline "add a subtask" row that closes an expanded group. */
|
||||||
data class AddSub(val parent: Task) : ListRow {
|
data class AddSub(val parent: Task) : ListRow {
|
||||||
// Negative so it never collides with a real (positive) provider task id.
|
// Prefixed so it never collides with the task row it belongs to.
|
||||||
override val key: Long get() = -parent.taskId
|
override val key: String get() = "add-${parent.occurrenceKey}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
|
||||||
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.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -28,13 +31,13 @@ sealed interface TaskListUiState {
|
|||||||
data object Failure : TaskListUiState
|
data object Failure : TaskListUiState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [listName] is the real list's name when the filter is a
|
* [list] is the real list when the filter is a [TaskFilter.OfList] — it
|
||||||
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
|
* titles the bar and backs the edit action — and `null` for smart lists,
|
||||||
* the screen falls back to the smart label in that case.
|
* where the screen falls back to the smart label.
|
||||||
*/
|
*/
|
||||||
data class Content(
|
data class Content(
|
||||||
val tasks: List<Task>,
|
val tasks: List<Task>,
|
||||||
val listName: String? = null,
|
val list: TaskList? = null,
|
||||||
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
|
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
|
||||||
val showAddSubtaskRow: Boolean = true,
|
val showAddSubtaskRow: Boolean = true,
|
||||||
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
|
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
|
||||||
@@ -65,8 +68,8 @@ class TaskListViewModel @Inject constructor(
|
|||||||
val tasks = repository.tasks(f)
|
val tasks = repository.tasks(f)
|
||||||
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
|
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
|
||||||
is TaskFilter.OfList ->
|
is TaskFilter.OfList ->
|
||||||
combine(tasks, repository.taskLists()) { list, lists ->
|
combine(tasks, repository.taskLists()) { rows, lists ->
|
||||||
TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name)
|
TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId })
|
||||||
}
|
}
|
||||||
is TaskFilter.Smart ->
|
is TaskFilter.Smart ->
|
||||||
tasks.map { TaskListUiState.Content(it) }
|
tasks.map { TaskListUiState.Content(it) }
|
||||||
@@ -87,7 +90,10 @@ class TaskListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onStart { emit(TaskListUiState.Loading) }
|
.onStart { emit(TaskListUiState.Loading) }
|
||||||
.catch { emit(TaskListUiState.Failure) }
|
// Recover rather than terminate: a provider hiccup (mid-update,
|
||||||
|
// permission not yet granted) shows Failure but keeps retrying,
|
||||||
|
// so the screen heals itself instead of staying stuck.
|
||||||
|
.recoveringFromProviderFailure { TaskListUiState.Failure }
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
||||||
|
|
||||||
@@ -112,6 +118,9 @@ class TaskListViewModel @Inject constructor(
|
|||||||
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
|
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Without this an exception here escapes stateIn's coroutine, past
|
||||||
|
// viewModelScope's SupervisorJob, and crashes the process.
|
||||||
|
.recoveringFromProviderFailure { emptyMap() }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||||
|
|
||||||
/** The screen reports which expanded parents need their children fetched. */
|
/** The screen reports which expanded parents need their children fetched. */
|
||||||
@@ -120,7 +129,7 @@ class TaskListViewModel @Inject constructor(
|
|||||||
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
||||||
|
|
||||||
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */
|
/** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */
|
||||||
@@ -149,6 +158,36 @@ class TaskListViewModel @Inject constructor(
|
|||||||
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
|
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _listWriteFailure = MutableStateFlow<ListWriteFailure?>(null)
|
||||||
|
|
||||||
|
/** Set when a list write is refused; the screen shows it and clears it. */
|
||||||
|
val listWriteFailure: StateFlow<ListWriteFailure?> = _listWriteFailure.asStateFlow()
|
||||||
|
|
||||||
|
private val _listDeleted = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** Flips once the list this screen shows is really gone, so it can leave. */
|
||||||
|
val listDeleted: StateFlow<Boolean> = _listDeleted.asStateFlow()
|
||||||
|
|
||||||
|
fun clearListWriteFailure() { _listWriteFailure.value = null }
|
||||||
|
|
||||||
|
/** Rename / recolour the list this screen is showing. */
|
||||||
|
fun updateList(listId: Long, name: String, color: Int) = viewModelScope.launch {
|
||||||
|
if (name.isBlank()) return@launch
|
||||||
|
runCatching { repository.updateList(listId, name.trim(), color) }
|
||||||
|
.onFailure { _listWriteFailure.value = ListWriteFailure.SAVE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the list **and its tasks**. The screen navigates away on
|
||||||
|
* [listDeleted], not on the call — leaving first would strand a refusal on a
|
||||||
|
* screen that no longer exists.
|
||||||
|
*/
|
||||||
|
fun deleteList(listId: Long) = viewModelScope.launch {
|
||||||
|
runCatching { repository.deleteList(listId) }
|
||||||
|
.onSuccess { _listDeleted.value = true }
|
||||||
|
.onFailure { _listWriteFailure.value = ListWriteFailure.DELETE }
|
||||||
|
}
|
||||||
|
|
||||||
/** Inline "add subtask" from an expanded list group — files it under [parent]. */
|
/** Inline "add subtask" from an expanded list group — files it under [parent]. */
|
||||||
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
|
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
|
||||||
if (title.isBlank() || parent.listId <= 0L) return@launch
|
if (title.isBlank() || parent.listId <= 0L) return@launch
|
||||||
|
|||||||
@@ -1,22 +1,89 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!--
|
<!--
|
||||||
Agendula launcher icon foreground — PLACEHOLDER.
|
Agendula launcher icon foreground.
|
||||||
|
|
||||||
A simple rounded check mark inside the 108dp adaptive-icon canvas
|
Converted from design/icon/agendula_icon.svg (512x512 viewport): a rounded
|
||||||
(72dp safe zone). Deliberately not Calendula's calendar mark, so the two
|
line-art task card with a check mark, plus a small Calendula bloom badge in
|
||||||
apps never look alike. Replace with real branding (a stylized agendula)
|
the bottom-right corner — the sibling of Calendula's calendar mark.
|
||||||
when the design lands — see docs/PLAN.md §9.
|
|
||||||
|
Strokes render in off-white (#FAF6F0) over the plum background drawable
|
||||||
|
(drawable/ic_launcher_background.xml = @color/ic_launcher_background, the
|
||||||
|
hue-rotated counterpart of Calendula's slate). The same vector fills the
|
||||||
|
<monochrome> slot so Android 13+ themed-icon launchers can recolour it.
|
||||||
|
|
||||||
|
Centering / scale:
|
||||||
|
- Scale 0.66 about the canvas centre (matching Calendula's ~2.8dp stroke
|
||||||
|
weight and generous padding).
|
||||||
|
- The eye centres on the task CARD, not the card+bloom bounding box (the
|
||||||
|
bloom is a small badge that overhangs the bottom-right). So vertically we
|
||||||
|
centre the card itself: its geometric centre is y=242.76, so we pivot the
|
||||||
|
Y-scale there and translate +13.24 to drop that centre onto the canvas
|
||||||
|
centre (256). Horizontally the card already sits centred, so X scales
|
||||||
|
about 256 untouched.
|
||||||
-->
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="512"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="512">
|
||||||
<path
|
<group
|
||||||
android:fillColor="#00000000"
|
android:pivotX="256"
|
||||||
android:strokeColor="#FFFFFF"
|
android:pivotY="242.76"
|
||||||
android:strokeWidth="9"
|
android:scaleX="0.66"
|
||||||
android:strokeLineCap="round"
|
android:scaleY="0.66"
|
||||||
android:strokeLineJoin="round"
|
android:translateY="13.24">
|
||||||
android:pathData="M36,55 l13,13 l25,-27" />
|
<!-- Task card (rounded square, opening at the bottom-right for the bloom) -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="20"
|
||||||
|
android:strokeMiterLimit="12"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M370.207 254.345V168.276C370.207 144.508 350.939 125.241 327.172 125.241H178.207C154.439 125.241 135.172 144.508 135.172 168.276V317.241C135.172 341.008 154.439 360.276 178.207 360.276H269.241" />
|
||||||
|
<!-- Check mark -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="20"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M193 245.569L231.822 287L320 199" />
|
||||||
|
<!-- Calendula bloom: petals around the centre -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="16"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M346.672 323.721C356.301 323.721 364.107 312.367 364.107 298.361C364.107 284.354 356.301 273 346.672 273C337.042 273 329.236 284.354 329.236 298.361C329.236 312.367 337.042 323.721 346.672 323.721Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="16"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M355.716 330.293C358.692 339.451 371.903 343.366 385.224 339.038C398.544 334.71 406.931 323.777 403.955 314.619C400.98 305.461 387.769 301.546 374.448 305.874C361.127 310.202 352.741 321.135 355.716 330.293Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="16"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M352.262 340.926C344.471 346.586 344.83 360.36 353.063 371.691C361.295 383.022 374.284 387.62 382.075 381.96C389.865 376.3 389.506 362.526 381.274 351.194C373.041 339.863 360.052 335.266 352.262 340.926Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="16"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M341.082 340.926C333.291 335.266 320.302 339.863 312.069 351.194C303.837 362.526 303.478 376.3 311.268 381.96C319.059 387.62 332.048 383.022 340.28 371.691C348.513 360.36 348.872 346.585 341.082 340.926Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="16"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M337.627 330.293C340.602 321.135 332.216 310.202 318.895 305.874C305.574 301.546 292.363 305.461 289.388 314.619C286.412 323.777 294.799 334.71 308.119 339.038C321.44 343.366 334.651 339.451 337.627 330.293Z" />
|
||||||
|
<!-- Bloom centre -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="16"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M346.672 342.742C351.924 342.742 356.182 338.484 356.182 333.232C356.182 327.979 351.924 323.721 346.672 323.721C341.419 323.721 337.161 327.979 337.161 333.232C337.161 338.484 341.419 342.742 346.672 342.742Z" />
|
||||||
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
190
app/src/main/res/values-de/strings.xml
Normal file
190
app/src/main/res/values-de/strings.xml
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="back">Zurück</string>
|
||||||
|
<string name="save">Speichern</string>
|
||||||
|
<string name="delete">Löschen</string>
|
||||||
|
<string name="edit">Bearbeiten</string>
|
||||||
|
<string name="field_description">Beschreibung</string>
|
||||||
|
<string name="task_deleted">Gelöscht</string>
|
||||||
|
<string name="app_name">Agendula</string>
|
||||||
|
<string name="task_untitled">Unbenannte Aufgabe</string>
|
||||||
|
<string name="tasks_title">Aufgaben</string>
|
||||||
|
<string name="task_list_empty">Keine Aufgaben bisher.</string>
|
||||||
|
<string name="task_list_failure">Aufgaben konnten nicht geladen werden.</string>
|
||||||
|
<string name="smart_no_date">Kein Datum</string>
|
||||||
|
<string name="smart_completed">Abgeschlossen</string>
|
||||||
|
<string name="task_detail_title">Aufgabe</string>
|
||||||
|
<string name="task_detail_not_found">Diese Aufgabe ist nicht mehr verfügbar.</string>
|
||||||
|
<string name="edit_task_title">Aufgabe bearbeiten</string>
|
||||||
|
<string name="new_task_title">Neue Aufgabe</string>
|
||||||
|
<string name="field_title">Titel</string>
|
||||||
|
<string name="edit_save_failed">Konnte nicht gespeichert werden. Versuche es erneut.</string>
|
||||||
|
<string name="add_task_hint">Aufgabe hinzufügen</string>
|
||||||
|
<string name="add_subtask_hint">Unteraufgabe hinzufügen</string>
|
||||||
|
<string name="cd_add_task">Aufgabe hinzufügen</string>
|
||||||
|
<string name="cd_complete">Abgeschlossen</string>
|
||||||
|
<string name="cd_reopen">Erneut öffnen</string>
|
||||||
|
<string name="undo">Rückgängig</string>
|
||||||
|
<string name="section_today">Heute</string>
|
||||||
|
<string name="cd_expand_subtasks">Unteraufgaben anzeigen</string>
|
||||||
|
<string name="cd_collapse_subtasks">Unteraufgaben ausblenden</string>
|
||||||
|
<string name="section_overdue">Überfällig</string>
|
||||||
|
<string name="section_upcoming">Anstehend</string>
|
||||||
|
<string name="section_no_date">Kein Datum</string>
|
||||||
|
<string name="section_completed">Abgeschlossen</string>
|
||||||
|
<string name="priority_none">Keine</string>
|
||||||
|
<string name="priority_low">Niedrig</string>
|
||||||
|
<string name="priority_medium">Mittel</string>
|
||||||
|
<string name="priority_high">Hoch</string>
|
||||||
|
<string name="detail_mark_complete">Als abgeschlossen markieren</string>
|
||||||
|
<string name="detail_mark_incomplete">Als nicht abgeschlossen markieren</string>
|
||||||
|
<string name="detail_list">Liste</string>
|
||||||
|
<string name="detail_due">Fällig</string>
|
||||||
|
<string name="detail_start">Beginn</string>
|
||||||
|
<string name="detail_priority">Priorität</string>
|
||||||
|
<string name="detail_progress">Fortschritt</string>
|
||||||
|
<string name="detail_subtasks">Unteraufgaben</string>
|
||||||
|
<string name="detail_parent">Hauptaufgabe</string>
|
||||||
|
<string name="detail_part_of">Teil von</string>
|
||||||
|
<string name="edit_list_label">Liste</string>
|
||||||
|
<string name="edit_start_label">Beginn</string>
|
||||||
|
<string name="edit_due_label">Fällig</string>
|
||||||
|
<string name="edit_all_day">Ganzer Tag</string>
|
||||||
|
<string name="edit_priority_label">Priorität</string>
|
||||||
|
<string name="edit_progress_label">Fortschritt</string>
|
||||||
|
<string name="edit_parent_label">Hauptaufgabe</string>
|
||||||
|
<string name="edit_parent_none">Keine (Hauptebene)</string>
|
||||||
|
<string name="edit_parent_search">Aufgaben suchen</string>
|
||||||
|
<string name="edit_parent_empty">Keine passenden Aufgaben in der Liste gefunden.</string>
|
||||||
|
<string name="edit_reminder_label">Erinnerungen</string>
|
||||||
|
<string name="edit_set">Übernehmen</string>
|
||||||
|
<string name="edit_clear">Leeren</string>
|
||||||
|
<string name="edit_title_hint">Aufgaben-Titel</string>
|
||||||
|
<string name="edit_when">Wann</string>
|
||||||
|
<string name="edit_more_fields">Weitere Felder</string>
|
||||||
|
<string name="close">Schließen</string>
|
||||||
|
<string name="dialog_cancel">Abbrechen</string>
|
||||||
|
<string name="edit_conflict_title">Termin wurde extern geändert</string>
|
||||||
|
<string name="edit_conflict_body">Die Aufgabe wurde seit der Öffnung wo anders geändert (über Sync oder einer anderen App). Möchtest die Änderungen überschreiben?</string>
|
||||||
|
<string name="edit_conflict_overwrite">Überschreiben</string>
|
||||||
|
<string name="reminder_none">Keine</string>
|
||||||
|
<string name="reminder_at_due">Zum Start der Aufgabe</string>
|
||||||
|
<string name="reminder_5_min">5 Minuten vorher</string>
|
||||||
|
<string name="reminder_10_min">10 Minuten vorher</string>
|
||||||
|
<string name="reminder_30_min">30 Minuten vorher</string>
|
||||||
|
<string name="reminder_1_hour">1 Stunde vorher</string>
|
||||||
|
<string name="reminder_1_day">1 Tag vorher</string>
|
||||||
|
<string name="reminder_custom">Benutzerdefiniert</string>
|
||||||
|
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
|
||||||
|
<string name="reminder_custom_set">Übernehmen</string>
|
||||||
|
<string name="reminder_custom_amount">Anzahl eingeben</string>
|
||||||
|
<string name="reminder_use_default">Standard verwenden</string>
|
||||||
|
<string name="reminder_unit_minutes">Minuten</string>
|
||||||
|
<string name="reminder_unit_hours">Stunden</string>
|
||||||
|
<string name="reminder_unit_days">Tage</string>
|
||||||
|
<string name="reminder_unit_weeks">Woche</string>
|
||||||
|
<string name="settings_list_reminders_title">Erinnerungen (Pro Liste)</string>
|
||||||
|
<string name="settings_list_reminders_hint">Überschreibe die Standard-Erinnerungen für individuelle Listen</string>
|
||||||
|
<string name="settings_list_reminder_inherits">Standard (%1$s)</string>
|
||||||
|
<string name="error_blank_title">Gebe einen Titel ein.</string>
|
||||||
|
<string name="error_no_list">Wähle eine Liste.</string>
|
||||||
|
<string name="error_due_before_start">Fälligkeit darf nicht vor dem Start liegen.</string>
|
||||||
|
<string name="error_reminder_without_due">Setze ein Fälligkeits-Datum für Erinnerungen.</string>
|
||||||
|
<string name="lists_header">Listen</string>
|
||||||
|
<string name="new_task">Neue Aufgabe</string>
|
||||||
|
<string name="lists_failure">Deine Aufgaben konnten nicht gelesen werden.</string>
|
||||||
|
<string name="lists_empty">Keine Aufgaben-Listen bisher. Füge eine in deiner Aufgaben-App hinzu oder mit der + Schaltfläche.</string>
|
||||||
|
<string name="smart_today">Heute</string>
|
||||||
|
<string name="smart_overdue">Überfällig</string>
|
||||||
|
<string name="smart_upcoming">Anstehend</string>
|
||||||
|
<string name="smart_all">Alle</string>
|
||||||
|
<string name="open_count">%1$d offen</string>
|
||||||
|
<string name="home_today_progress">%1$d von %2$d erledigt</string>
|
||||||
|
<string name="home_today_remaining">%1$d übrig</string>
|
||||||
|
<string name="home_today_all_done">Alles erledigt 🎉</string>
|
||||||
|
<string name="home_today_empty">Heute keine Fälligkeiten 🎉</string>
|
||||||
|
<string name="home_upcoming_view_all">Alle anzeigen</string>
|
||||||
|
<string name="home_due_today">Heute</string>
|
||||||
|
<string name="home_due_tomorrow">Morgen</string>
|
||||||
|
<string name="home_search_hint">Aufgaben suchen</string>
|
||||||
|
<string name="home_search_clear">Suche zurücksetzen</string>
|
||||||
|
<string name="home_search_close">Suche schließen</string>
|
||||||
|
<string name="home_search_empty">Keine Aufgaben passen zu „%1$s“</string>
|
||||||
|
<string name="reminder_due_at">Fälligkeiten %1$s</string>
|
||||||
|
<string name="reminder_channel_name">Aufgaben Erinnerungen</string>
|
||||||
|
<string name="reminder_channel_desc">Benachrichtigungen für fällige Aufgaben.</string>
|
||||||
|
<string name="onboarding_no_provider_title">Eine Task-App wird benötigt</string>
|
||||||
|
<string name="onboarding_no_provider_body">Agendula zeigt und bearbeitet die Aufgaben, die in OpenTasks oder tasks.org gespeichert sind und per DAVx5 synchronisiert werden. Installiere eine davon, um zu starten.</string>
|
||||||
|
<string name="onboarding_permission_title">Erlaube Zugriff auf deine Aufgaben</string>
|
||||||
|
<string name="onboarding_permission_body">Agendula benötigt die Berechtigung, deine Aufgaben zu lesen und zu schreiben. Das ist die einzige Berechtigung, die es jemals anfragt.</string>
|
||||||
|
<string name="onboarding_permission_button">Zugriff gewähren</string>
|
||||||
|
<string name="onboarding_install_opentasks">Installiere OpenTasks</string>
|
||||||
|
<string name="onboarding_install_tasksorg">Installiere tasks.org</string>
|
||||||
|
<string name="reminder_onboarding_title">Verpasse nichts, was fällig ist</string>
|
||||||
|
<string name="reminder_onboarding_body">Aufgaben-Apps senden selbst keine Erinnerungen, daher liefert Agendula sie für dich. Schalte sie ein, um eine Benachrichtigung zu erhalten, wenn eine Aufgabe fällig ist.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Nicht jetzt</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Agendula erinnert dich</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Es plant eine Benachrichtigung für jede Aufgabe mit einem Fälligkeitsdatum.</string>
|
||||||
|
<string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string>
|
||||||
|
<string name="reminder_benefit_timing_body">Wähle in den Einstellungen, wie weit im Voraus du erinnert werden möchtest.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">Schalte Erinnerungen aus, wann immer du willst — es ist nur ein Schalter.</string>
|
||||||
|
<string name="settings_title">Einstellungen</string>
|
||||||
|
<string name="settings_section_appearance">Darstellung</string>
|
||||||
|
<string name="settings_appearance_subtitle">Theme und Farbe</string>
|
||||||
|
<string name="settings_section_task_form">Aufgaben-Formular</string>
|
||||||
|
<string name="settings_task_form_subtitle">Standard Felder, Liste und Unter-Aufgaben</string>
|
||||||
|
<string name="settings_reminders_subtitle">Benachrichtigungen für fällige Aufgaben</string>
|
||||||
|
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter „Weitere Felder“.</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Verfügbar für Android 12 und höher</string>
|
||||||
|
<string name="settings_about_author">von Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">Quellcode</string>
|
||||||
|
<string name="settings_license">Lizenz</string>
|
||||||
|
<string name="settings_about_support">Entwicklung unterstützen</string>
|
||||||
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
||||||
|
<string name="settings_language">App-Sprache</string>
|
||||||
|
<string name="settings_language_auto">Systemstandard</string>
|
||||||
|
<string name="settings_report_problem">Problem melden</string>
|
||||||
|
<string name="settings_report_problem_hint">Öffne den Issue-Tracker</string>
|
||||||
|
<string name="crash_report_issue_title">Absturzbericht</string>
|
||||||
|
<string name="settings_theme">Design</string>
|
||||||
|
<string name="settings_theme_system">System folgen</string>
|
||||||
|
<string name="settings_theme_light">Hell</string>
|
||||||
|
<string name="settings_theme_dark">Dunkel</string>
|
||||||
|
<string name="settings_dynamic_color">Dynamische Farben</string>
|
||||||
|
<string name="settings_dynamic_color_hint">Verwende Farben aus dem Hintergrundbild</string>
|
||||||
|
<string name="settings_section_reminders">Erinnerungen</string>
|
||||||
|
<string name="settings_reminders">Fälligkeits-Erinnerungen</string>
|
||||||
|
<string name="settings_reminders_hint">Benachrichtige mich wenn eine Aufgabe fällig ist</string>
|
||||||
|
<string name="settings_default_reminder">Wann erinnern</string>
|
||||||
|
<string name="settings_exact_alarms">Exakter Zeitpunkt</string>
|
||||||
|
<string name="settings_exact_alarms_allowed">Die Erinnerungen erfolgen zum exakten Zeitpunkt</string>
|
||||||
|
<string name="settings_exact_alarms_blocked">Blockiert – tippe, um exakte Erinnerungen zu erlauben</string>
|
||||||
|
<string name="settings_section_tasks">Aufgaben</string>
|
||||||
|
<string name="settings_default_list">Standard-Liste</string>
|
||||||
|
<string name="settings_default_list_first">Erste verfügbare Felder</string>
|
||||||
|
<string name="settings_add_subtask_row">Zeile \"Unteraufgaben hinzufügen\" anzeigen</string>
|
||||||
|
<string name="settings_add_subtask_row_hint">Ein Eingabefeld am Ende der Unteraufgaben einer aufgeklappten Aufgabe</string>
|
||||||
|
<string name="settings_bottom_add_bar">Untere Schnellhinzufüge-Leiste</string>
|
||||||
|
<string name="settings_bottom_add_bar_hint">Füge Aufgaben hinzu, über eine Leiste, die unten an einer Liste angepinnt ist, statt über die schwebende Schaltfläche</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%1$d Minute vorher</item>
|
||||||
|
<item quantity="other">%1$d Minuten vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%1$d Stunde vorher</item>
|
||||||
|
<item quantity="other">%1$d Stunden vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%1$d Tag vorher</item>
|
||||||
|
<item quantity="other">%1$d Tage vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%1$d Woche vorher</item>
|
||||||
|
<item quantity="other">%1$d Wochen vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_translate">Hilf beim Übersetzen</string>
|
||||||
|
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
|
||||||
|
</resources>
|
||||||
196
app/src/main/res/values-pt-rBR/strings.xml
Normal file
196
app/src/main/res/values-pt-rBR/strings.xml
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Agendula</string>
|
||||||
|
<string name="app_tagline">Um app de tarefas em Material 3 Expressive. \nFlorescendo.</string>
|
||||||
|
<string name="task_untitled">Tarefa sem título</string>
|
||||||
|
<string name="back">Voltar</string>
|
||||||
|
<string name="save">Salvar</string>
|
||||||
|
<string name="delete">Deletar</string>
|
||||||
|
<string name="tasks_title">Tarefas</string>
|
||||||
|
<string name="task_list_empty">Nenhuma tarefa aqui ainda.</string>
|
||||||
|
<string name="task_list_failure">Não foi possível carregar estas tarefas.</string>
|
||||||
|
<string name="smart_no_date">Sem data</string>
|
||||||
|
<string name="smart_completed">Completo</string>
|
||||||
|
<string name="task_detail_title">Tarefa</string>
|
||||||
|
<string name="task_detail_not_found">Esta tarefa não existe mais.</string>
|
||||||
|
<string name="edit">Editar</string>
|
||||||
|
<string name="edit_task_title">Editar tarefa</string>
|
||||||
|
<string name="new_task_title">Nova tarefa</string>
|
||||||
|
<string name="field_title">Título</string>
|
||||||
|
<string name="field_description">Descrição</string>
|
||||||
|
<string name="edit_save_failed">Não foi possível salvar. Tente novamente.</string>
|
||||||
|
<string name="add_task_hint">Adicionar tarefa</string>
|
||||||
|
<string name="add_subtask_hint">Adicionar subtarefa</string>
|
||||||
|
<string name="cd_add_task">Adicionar tarefa</string>
|
||||||
|
<string name="cd_complete">Concluído</string>
|
||||||
|
<string name="cd_reopen">Reabrir</string>
|
||||||
|
<string name="task_deleted">Deletado</string>
|
||||||
|
<string name="undo">Desfazer</string>
|
||||||
|
<string name="cd_expand_subtasks">Mostrar subtarefas</string>
|
||||||
|
<string name="cd_collapse_subtasks">Esconder subtarefas</string>
|
||||||
|
<string name="section_overdue">Prazo expirado</string>
|
||||||
|
<string name="section_today">Hoje</string>
|
||||||
|
<string name="section_upcoming">A seguir</string>
|
||||||
|
<string name="section_no_date">Sem data</string>
|
||||||
|
<string name="section_completed">Concluído</string>
|
||||||
|
<string name="priority_none">Nenhuma</string>
|
||||||
|
<string name="priority_low">Baixa</string>
|
||||||
|
<string name="priority_medium">Média</string>
|
||||||
|
<string name="priority_high">Alta</string>
|
||||||
|
<string name="detail_mark_complete">Marcar como concluído</string>
|
||||||
|
<string name="detail_mark_incomplete">Marcar como não concluído</string>
|
||||||
|
<string name="detail_list">Lista</string>
|
||||||
|
<string name="detail_due">Prazo</string>
|
||||||
|
<string name="detail_start">Inicia</string>
|
||||||
|
<string name="detail_priority">Prioridade</string>
|
||||||
|
<string name="detail_progress">Progresso</string>
|
||||||
|
<string name="detail_subtasks">Subtarefas</string>
|
||||||
|
<string name="detail_parent">Tarefa pai</string>
|
||||||
|
<string name="detail_part_of">Parte de</string>
|
||||||
|
<string name="percent_complete">%1$d%%</string>
|
||||||
|
<string name="edit_list_label">Lista</string>
|
||||||
|
<string name="edit_start_label">Inicia</string>
|
||||||
|
<string name="edit_due_label">Prazo</string>
|
||||||
|
<string name="edit_all_day">O dia todo</string>
|
||||||
|
<string name="edit_priority_label">Prioridade</string>
|
||||||
|
<string name="edit_progress_label">Progresso</string>
|
||||||
|
<string name="edit_parent_label">Tarefa pai</string>
|
||||||
|
<string name="edit_parent_none">Nenhum (primeiro nível)</string>
|
||||||
|
<string name="edit_parent_search">Buscar tarefas</string>
|
||||||
|
<string name="edit_parent_empty">Nenhuma tarefa correspondente nesta lista.</string>
|
||||||
|
<string name="edit_reminder_label">Lembrete</string>
|
||||||
|
<string name="edit_set">Ajustar</string>
|
||||||
|
<string name="edit_clear">Limpar</string>
|
||||||
|
<string name="edit_title_hint">Título da tarefa</string>
|
||||||
|
<string name="edit_when">Quando</string>
|
||||||
|
<string name="edit_more_fields">Mais campos</string>
|
||||||
|
<string name="close">Fechar</string>
|
||||||
|
<string name="dialog_cancel">Cancelar</string>
|
||||||
|
<string name="edit_conflict_title">A tarefa foi alterada em outro lugar</string>
|
||||||
|
<string name="edit_conflict_body">Esta tarefa foi atualizada em outro local (uma sincronização ou outro app) desde que você a abriu. Sobrescrever estas mudanças com suas edições?</string>
|
||||||
|
<string name="edit_conflict_overwrite">Sobrescrever</string>
|
||||||
|
<string name="reminder_none">Nenhum</string>
|
||||||
|
<string name="reminder_at_due">Na hora da tarefa</string>
|
||||||
|
<string name="reminder_5_min">5 minutos antes</string>
|
||||||
|
<string name="reminder_10_min">10 minutos antes</string>
|
||||||
|
<string name="reminder_30_min">30 minutos antes</string>
|
||||||
|
<string name="reminder_1_hour">1 hora antes</string>
|
||||||
|
<string name="reminder_1_day">1 dia antes</string>
|
||||||
|
<string name="reminder_custom">Personalizado</string>
|
||||||
|
<string name="reminder_custom_with_value">Personalizado (%1$s)</string>
|
||||||
|
<string name="reminder_custom_set">Ajustar</string>
|
||||||
|
<string name="reminder_custom_amount">Insira uma quantidade</string>
|
||||||
|
<string name="reminder_use_default">Usar padrão</string>
|
||||||
|
<string name="reminder_unit_minutes">Minutos</string>
|
||||||
|
<string name="reminder_unit_hours">Horas</string>
|
||||||
|
<string name="reminder_unit_days">Dias</string>
|
||||||
|
<string name="reminder_unit_weeks">Semanas</string>
|
||||||
|
<string name="settings_list_reminders_title">Lembretes por lista</string>
|
||||||
|
<string name="settings_list_reminders_hint">Alterar o lembrete padrão para listas individuais</string>
|
||||||
|
<string name="settings_list_reminder_inherits">Padrão (%1$s)</string>
|
||||||
|
<string name="error_blank_title">Insira um título.</string>
|
||||||
|
<string name="error_no_list">Escolha uma lista.</string>
|
||||||
|
<string name="error_due_before_start">Prazo não pode ser antes do início.</string>
|
||||||
|
<string name="error_reminder_without_due">Ajuste um prazo para usar um lembrete.</string>
|
||||||
|
<string name="lists_header">Listas</string>
|
||||||
|
<string name="new_task">Nova tarefa</string>
|
||||||
|
<string name="lists_failure">Não foi possível ler suas tarefas.</string>
|
||||||
|
<string name="lists_empty">Nenhuma lista de tarefas ainda. Adicione uma no seu app de tarefas ou com o botão +.</string>
|
||||||
|
<string name="smart_today">Hoje</string>
|
||||||
|
<string name="smart_overdue">Prazo expirado</string>
|
||||||
|
<string name="smart_upcoming">A seguir</string>
|
||||||
|
<string name="smart_all">Tudo</string>
|
||||||
|
<string name="open_count">%1$d abertos</string>
|
||||||
|
<string name="home_today_progress">%1$d de %2$d concluídos</string>
|
||||||
|
<string name="home_today_remaining">%1$d restantes</string>
|
||||||
|
<string name="home_today_all_done">Tudo pronto 🎉</string>
|
||||||
|
<string name="home_today_empty">Nada terminando hoje 🎉</string>
|
||||||
|
<string name="home_upcoming_view_all">Ver tudo</string>
|
||||||
|
<string name="home_due_today">Hoje</string>
|
||||||
|
<string name="home_due_tomorrow">Amanhã</string>
|
||||||
|
<string name="home_search_hint">Buscar tarefas</string>
|
||||||
|
<string name="home_search_clear">Limpar buscas</string>
|
||||||
|
<string name="home_search_close">Fechar busca</string>
|
||||||
|
<string name="home_search_empty">Nenhuma tarefa corresponde a \"%1$s\"</string>
|
||||||
|
<string name="reminder_due_at">Termina em %1$s</string>
|
||||||
|
<string name="reminder_channel_name">Lembretes de tarefas</string>
|
||||||
|
<string name="reminder_channel_desc">Notificações para tarefas com prazo.</string>
|
||||||
|
<string name="onboarding_no_provider_title">Um app de tarefas é necessário</string>
|
||||||
|
<string name="onboarding_no_provider_body">O Agendula mostra e edita as tarefas armazenadas no OpenTasks ou tasks.org, sincronizadas pelo DAVx5. Instale um para começar.</string>
|
||||||
|
<string name="onboarding_permission_title">Permitir acesso às suas tarefas</string>
|
||||||
|
<string name="onboarding_permission_body">O Agendula precisa de permissão para ler e salvar suas tarefas. É tudo o que ele pede.</string>
|
||||||
|
<string name="onboarding_permission_button">Conceder acesso às tarefas</string>
|
||||||
|
<string name="onboarding_install_opentasks">Instalar OpenTasks</string>
|
||||||
|
<string name="onboarding_install_tasksorg">Instalar tasks.org</string>
|
||||||
|
<string name="reminder_onboarding_title">Nunca perca seus prazos</string>
|
||||||
|
<string name="reminder_onboarding_body">Apps de tarefas não enviam lembretes sozinhos, então o Agendula os entrega para você. Ative para receber uma notificação de quando uma tarefa estiver no prazo.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Ativar lembretes</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Agora não</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">O Agendula lembra você</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Ele agenda uma notificação para cada tarefa com prazo.</string>
|
||||||
|
<string name="reminder_benefit_timing_title">Na sua rotina</string>
|
||||||
|
<string name="reminder_benefit_timing_body">Escolha o quão à frente quer ser lembrado em Ajustes.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Altere a qualquer momento</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">Desative os lembretes quando quiser — é só um botão.</string>
|
||||||
|
<string name="settings_title">Ajustes</string>
|
||||||
|
<string name="settings_section_appearance">Aparência</string>
|
||||||
|
<string name="settings_appearance_subtitle">Tema e cor</string>
|
||||||
|
<string name="settings_section_task_form">Formulário de tarefa</string>
|
||||||
|
<string name="settings_task_form_subtitle">Campos, listas e subtarefas padrão</string>
|
||||||
|
<string name="settings_reminders_subtitle">Notificações para tarefas com prazo acabando</string>
|
||||||
|
<string name="settings_form_fields_hint">Campos mostrados por padrão em uma nova tarefa. O resto está atrás de \"Mais campos\".</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Disponível no Android 12 e mais recente</string>
|
||||||
|
<string name="settings_about_author">por Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">Código-fonte</string>
|
||||||
|
<string name="settings_license">Licença</string>
|
||||||
|
<string name="settings_about_support">Apoie o desenvolvimento</string>
|
||||||
|
<string name="settings_about_version">Versão %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Ícone de app do Agendula</string>
|
||||||
|
<string name="settings_language">Idioma do app</string>
|
||||||
|
<string name="settings_language_auto">Padrão do sistema</string>
|
||||||
|
<string name="settings_translate">Ajude a traduzir</string>
|
||||||
|
<string name="settings_translate_hint">Adicione ou melhore um idioma no Weblate</string>
|
||||||
|
<string name="settings_report_problem">Relate um problema</string>
|
||||||
|
<string name="settings_report_problem_hint">Abrir o rastreador de problemas</string>
|
||||||
|
<string name="crash_report_issue_title">Relatório de quebra</string>
|
||||||
|
<string name="settings_theme">Tema</string>
|
||||||
|
<string name="settings_theme_system">Seguir o sistema</string>
|
||||||
|
<string name="settings_theme_light">Claro</string>
|
||||||
|
<string name="settings_theme_dark">Escuro</string>
|
||||||
|
<string name="settings_dynamic_color">Cor dinâmica</string>
|
||||||
|
<string name="settings_dynamic_color_hint">Usar cores do papel de parede</string>
|
||||||
|
<string name="settings_section_reminders">Lembretes</string>
|
||||||
|
<string name="settings_reminders">Lembretes no prazo</string>
|
||||||
|
<string name="settings_reminders_hint">Me notifique quando uma tarefa estiver no prazo</string>
|
||||||
|
<string name="settings_default_reminder">Quando lembrar</string>
|
||||||
|
<string name="settings_exact_alarms">Na hora</string>
|
||||||
|
<string name="settings_exact_alarms_allowed">Lembretes disparam na mesma hora</string>
|
||||||
|
<string name="settings_exact_alarms_blocked">Bloqueado — toque para permitir lembretes na hora</string>
|
||||||
|
<string name="settings_section_tasks">Tarefas</string>
|
||||||
|
<string name="settings_default_list">Lista padrão</string>
|
||||||
|
<string name="settings_default_list_first">Primeira lista disponível</string>
|
||||||
|
<string name="settings_add_subtask_row">Mostrar uma linha \"adicionar subtarefa\"</string>
|
||||||
|
<string name="settings_add_subtask_row_hint">Um campo Adicionar no fim das subtarefas de uma tarefa expandida</string>
|
||||||
|
<string name="settings_bottom_add_bar">Barra adicionar-rápido abaixo</string>
|
||||||
|
<string name="settings_bottom_add_bar_hint">Adicione tarefas de uma barra presa abaixo de uma lista, em vez do botão flutuante</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%1$d minuto antes</item>
|
||||||
|
<item quantity="many">%1$d minutos antes</item>
|
||||||
|
<item quantity="other">%1$d minutos antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%1$d hora antes</item>
|
||||||
|
<item quantity="many">%1$d horas antes</item>
|
||||||
|
<item quantity="other">%1$d horas antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%1$d dia antes</item>
|
||||||
|
<item quantity="many">%1$d dias antes</item>
|
||||||
|
<item quantity="other">%1$d dias antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%1$d semana antes</item>
|
||||||
|
<item quantity="many">%1$d semanas antes</item>
|
||||||
|
<item quantity="other">%1$d semanas antes</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
@@ -122,13 +122,55 @@
|
|||||||
<string name="lists_header">Lists</string>
|
<string name="lists_header">Lists</string>
|
||||||
<string name="new_task">New task</string>
|
<string name="new_task">New task</string>
|
||||||
<string name="lists_failure">Could not read your tasks.</string>
|
<string name="lists_failure">Could not read your tasks.</string>
|
||||||
<string name="lists_empty">No task lists yet. Add one in your tasks app or with the + button.</string>
|
<string name="lists_empty">No task lists yet.</string>
|
||||||
|
<string name="lists_empty_action">Create a list</string>
|
||||||
|
|
||||||
|
<!-- Task lists: create, edit, delete -->
|
||||||
|
<string name="list_add">New list</string>
|
||||||
|
<string name="list_new_title">New list</string>
|
||||||
|
<string name="list_edit_title">Edit list</string>
|
||||||
|
<string name="list_name_hint">List name</string>
|
||||||
|
<string name="list_color">Colour</string>
|
||||||
|
<string name="list_delete">Delete list</string>
|
||||||
|
<string name="list_save_failed">Could not save the list.</string>
|
||||||
|
<string name="list_delete_failed">Could not delete the list.</string>
|
||||||
|
<string name="list_delete_confirm_title">Delete list?</string>
|
||||||
|
<string name="list_delete_confirm_message">“%1$s” and all of its tasks will be deleted. This can\'t be undone.</string>
|
||||||
|
<string name="list_color_mauve">Mauve</string>
|
||||||
|
<string name="list_color_red">Red</string>
|
||||||
|
<string name="list_color_orange">Orange</string>
|
||||||
|
<string name="list_color_amber">Amber</string>
|
||||||
|
<string name="list_color_olive">Olive</string>
|
||||||
|
<string name="list_color_green">Green</string>
|
||||||
|
<string name="list_color_teal">Teal</string>
|
||||||
|
<string name="list_color_cyan">Cyan</string>
|
||||||
|
<string name="list_color_blue">Blue</string>
|
||||||
|
<string name="list_color_indigo">Indigo</string>
|
||||||
|
<string name="list_color_purple">Purple</string>
|
||||||
|
<string name="list_color_pink">Pink</string>
|
||||||
<string name="smart_today">Today</string>
|
<string name="smart_today">Today</string>
|
||||||
<string name="smart_overdue">Overdue</string>
|
<string name="smart_overdue">Overdue</string>
|
||||||
<string name="smart_upcoming">Upcoming</string>
|
<string name="smart_upcoming">Upcoming</string>
|
||||||
<string name="smart_all">All</string>
|
<string name="smart_all">All</string>
|
||||||
<string name="open_count">%1$d open</string>
|
<string name="open_count">%1$d open</string>
|
||||||
|
|
||||||
|
<!-- Home: Today progress hero -->
|
||||||
|
<string name="home_today_progress">%1$d of %2$d done</string>
|
||||||
|
<string name="home_today_remaining">%1$d left</string>
|
||||||
|
<string name="home_today_all_done">All done 🎉</string>
|
||||||
|
<string name="home_today_empty">Nothing due today 🎉</string>
|
||||||
|
|
||||||
|
<!-- Home: Upcoming preview -->
|
||||||
|
<string name="home_upcoming_view_all">View all</string>
|
||||||
|
<string name="home_due_today">Today</string>
|
||||||
|
<string name="home_due_tomorrow">Tomorrow</string>
|
||||||
|
|
||||||
|
<!-- Home: search -->
|
||||||
|
<string name="home_search_hint">Search tasks</string>
|
||||||
|
<string name="home_search_clear">Clear search</string>
|
||||||
|
<string name="home_search_close">Close search</string>
|
||||||
|
<string name="home_search_empty">No tasks match “%1$s”</string>
|
||||||
|
|
||||||
<!-- Reminders -->
|
<!-- Reminders -->
|
||||||
<string name="reminder_due_at">Due %1$s</string>
|
<string name="reminder_due_at">Due %1$s</string>
|
||||||
<string name="reminder_channel_name">Task reminders</string>
|
<string name="reminder_channel_name">Task reminders</string>
|
||||||
@@ -173,13 +215,16 @@
|
|||||||
<string name="settings_about_logo_desc">Agendula app icon</string>
|
<string name="settings_about_logo_desc">Agendula app icon</string>
|
||||||
<string name="settings_language">App language</string>
|
<string name="settings_language">App language</string>
|
||||||
<string name="settings_language_auto">System default</string>
|
<string name="settings_language_auto">System default</string>
|
||||||
|
<string name="settings_translate">Help translate</string>
|
||||||
|
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||||
<string name="settings_report_problem">Report a problem</string>
|
<string name="settings_report_problem">Report a problem</string>
|
||||||
<string name="settings_report_problem_hint">Open the issue tracker</string>
|
<string name="settings_report_problem_hint">Open the issue tracker</string>
|
||||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula</string>
|
<string name="about_source_url" translatable="false">https://codeberg.org/jlmakiola/agendula</string>
|
||||||
<string name="crash_report_issue_title">Crash report</string>
|
<string name="crash_report_issue_title">Crash report</string>
|
||||||
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new</string>
|
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/agendula/issues/new</string>
|
||||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/src/branch/main/LICENSE</string>
|
<string name="about_license_url" translatable="false">https://codeberg.org/jlmakiola/agendula/src/branch/main/LICENSE</string>
|
||||||
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||||
|
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/agendula/</string>
|
||||||
<string name="settings_theme">Theme</string>
|
<string name="settings_theme">Theme</string>
|
||||||
<string name="settings_theme_system">Follow system</string>
|
<string name="settings_theme_system">Follow system</string>
|
||||||
<string name="settings_theme_light">Light</string>
|
<string name="settings_theme_light">Light</string>
|
||||||
@@ -201,6 +246,37 @@
|
|||||||
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
|
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
|
||||||
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
|
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
|
||||||
|
|
||||||
|
<string name="onboarding_use_own_store">Use this device\'s storage instead</string>
|
||||||
|
|
||||||
|
<!-- Storage and export -->
|
||||||
|
<string name="settings_section_storage">Storage</string>
|
||||||
|
<string name="settings_storage_subtitle">Where tasks are kept, and export</string>
|
||||||
|
<string name="settings_task_store">Task store</string>
|
||||||
|
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — export first if you want a copy.</string>
|
||||||
|
<string name="settings_store_own">On this device</string>
|
||||||
|
<string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string>
|
||||||
|
<string name="settings_store_external">Another task app</string>
|
||||||
|
<string name="settings_store_external_hint">Share tasks with the app that syncs them</string>
|
||||||
|
<string name="settings_store_external_missing">No compatible task app is installed</string>
|
||||||
|
<string name="settings_store_permission_denied">Permission denied</string>
|
||||||
|
<string name="settings_store_permission_denied_hint">The other app\'s tasks stay unreachable until you allow access. Tap to open app settings.</string>
|
||||||
|
<string name="settings_export">Export tasks</string>
|
||||||
|
<string name="settings_export_hint">Save your lists as iCalendar files</string>
|
||||||
|
<string name="export_hint">One .ics file per list, readable by other task and calendar apps. The ticked lists go to a folder you pick, or into a single zip.</string>
|
||||||
|
<string name="export_no_lists">No lists to export</string>
|
||||||
|
<string name="export_to_folder">Save to a folder</string>
|
||||||
|
<string name="export_to_zip">Save as a zip file</string>
|
||||||
|
<string name="export_running">Exporting…</string>
|
||||||
|
<plurals name="export_done">
|
||||||
|
<item quantity="one">Exported %1$d list</item>
|
||||||
|
<item quantity="other">Exported %1$d lists</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="export_failed">The export could not be written</string>
|
||||||
|
<string name="export_failed_folder">The chosen folder could not be opened</string>
|
||||||
|
<string name="export_failed_read_only">The chosen folder is not writable</string>
|
||||||
|
<string name="export_failed_create">A file could not be created in the chosen folder</string>
|
||||||
|
<string name="export_failed_access">Access to the chosen location was lost</string>
|
||||||
|
|
||||||
<!-- Reminder lead times (custom amounts) -->
|
<!-- Reminder lead times (custom amounts) -->
|
||||||
<plurals name="reminder_minutes">
|
<plurals name="reminder_minutes">
|
||||||
<item quantity="one">%1$d minute before</item>
|
<item quantity="one">%1$d minute before</item>
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<full-backup-content>
|
<full-backup-content>
|
||||||
<!-- No file-based backups; settings live in DataStore which is backed up by default. -->
|
<!--
|
||||||
|
Agendula's own task store. Room runs in WAL mode and Auto Backup copies
|
||||||
|
files without checkpointing, so the `-wal` sidecar can hold writes the
|
||||||
|
`.db` alone does not — all three go in together, and the app checkpoints
|
||||||
|
on ON_STOP so a restore is consistent either way.
|
||||||
|
|
||||||
|
Naming any <include> makes everything else excluded by default, so the
|
||||||
|
archived dmfs database (`tasks.db.imported`, kept one release as the
|
||||||
|
import's rollback path) is already left out. An explicit <exclude> for it
|
||||||
|
would be redundant *and* rejected — lint's FullBackupContent check errors
|
||||||
|
on an exclude that sits under no included path.
|
||||||
|
|
||||||
|
Settings live in DataStore, which this exclusion now also covers, so its
|
||||||
|
sharedpref file is listed back in.
|
||||||
|
-->
|
||||||
|
<include domain="database" path="agendula-tasks.db" />
|
||||||
|
<include domain="database" path="agendula-tasks.db-wal" />
|
||||||
|
<include domain="database" path="agendula-tasks.db-shm" />
|
||||||
|
<include domain="file" path="datastore/" />
|
||||||
</full-backup-content>
|
</full-backup-content>
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<data-extraction-rules>
|
<data-extraction-rules>
|
||||||
|
<!--
|
||||||
|
See backup_rules.xml: the WAL sidecars travel with the database, and
|
||||||
|
naming any <include> makes everything else excluded by default — which is
|
||||||
|
what keeps the archived `tasks.db.imported` out without an <exclude> that
|
||||||
|
lint would reject.
|
||||||
|
-->
|
||||||
<cloud-backup>
|
<cloud-backup>
|
||||||
<!-- Allow DataStore backup, exclude nothing extra. -->
|
<include domain="database" path="agendula-tasks.db" />
|
||||||
|
<include domain="database" path="agendula-tasks.db-wal" />
|
||||||
|
<include domain="database" path="agendula-tasks.db-shm" />
|
||||||
|
<include domain="file" path="datastore/" />
|
||||||
</cloud-backup>
|
</cloud-backup>
|
||||||
<device-transfer>
|
<device-transfer>
|
||||||
|
<include domain="database" path="agendula-tasks.db" />
|
||||||
|
<include domain="database" path="agendula-tasks.db-wal" />
|
||||||
|
<include domain="database" path="agendula-tasks.db-shm" />
|
||||||
|
<include domain="file" path="datastore/" />
|
||||||
</device-transfer>
|
</device-transfer>
|
||||||
</data-extraction-rules>
|
</data-extraction-rules>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user