Compare commits
1 Commits
217d5d7afd
...
fix/provid
| Author | SHA1 | Date | |
|---|---|---|---|
| 47cf99af32 |
@@ -1,24 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<!--
|
|
||||||
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
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -37,29 +37,14 @@ 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, store
|
# Decide whether anything that affects the app build changed. Docs,
|
||||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
|
||||||
# the SDK + Gradle work below but still report a green `ci`.
|
# 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.
|
||||||
@@ -73,15 +58,11 @@ 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"
|
||||||
RELEVANT=$(echo "$CHANGED" | grep -vE "$SKIP_RE" || true)
|
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
|
||||||
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 "Docs/metadata-only change — skipping the Android build."
|
|
||||||
echo "code=false" >> "$GITHUB_OUTPUT"
|
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Docs/metadata-only change — skipping the Android build."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
@@ -91,14 +72,9 @@ 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: https://github.com/android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
# Default ("tools platform-tools") drags in the Android Emulator
|
# Default ("tools platform-tools") drags in the Android Emulator
|
||||||
# (~300 MB) which the build never uses.
|
# (~300 MB) which the build never uses.
|
||||||
@@ -3,18 +3,12 @@ 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 publishes
|
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
|
||||||
# the release on Codeberg with the signed APK + a SHA-256 checksum as a
|
# that release to 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
|
||||||
@@ -33,14 +27,6 @@ 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 }}
|
||||||
@@ -56,16 +42,8 @@ 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:
|
||||||
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
# 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)
|
||||||
@@ -87,28 +65,15 @@ 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}' \
|
||||||
# Anything other than a clean 200/404 is treated as fatal rather than
|
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
|
||||||
# as "no tag". A Codeberg outage or a network blip would otherwise
|
if [ "$STATUS" = "200" ]; then
|
||||||
# read as absent and re-cut a release that has already shipped —
|
echo "Tag v$VERSION already exists — nothing to release."
|
||||||
# republishing to F-Droid. Failing here is recoverable; a duplicate
|
|
||||||
# release is not.
|
|
||||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
|
||||||
case "$STATUS" in
|
|
||||||
200)
|
|
||||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
;;
|
else
|
||||||
404)
|
echo "No tag for v$VERSION yet — cutting the release."
|
||||||
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
|
||||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||||
;;
|
fi
|
||||||
*)
|
|
||||||
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
|
||||||
@@ -395,15 +360,14 @@ 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"
|
||||||
|
|
||||||
# Publish the release on Codeberg, which is canonical for tags and
|
# Mirror the release to the Codeberg mirror as a direct-download channel
|
||||||
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
|
# for users who don't want F-Droid. Gitea already push-mirrors branches +
|
||||||
# to Gitea, but releases aren't git objects and don't sync in either
|
# tags to Codeberg, but releases aren't git objects so they don't sync —
|
||||||
# direction — so this step pushes the tag straight to Codeberg and creates
|
# we create the release there over the API and attach the signed APK plus
|
||||||
# the release there over the API, attaching the signed APK plus a SHA-256
|
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
|
||||||
# checksum as the direct-download channel for users who don't want
|
# key), so this adds no trust surface. Best-effort: a Codeberg outage
|
||||||
# F-Droid. The APK is identical to the F-Droid one (same app key), so this
|
# (it 504s under load) must never fail an already-published F-Droid
|
||||||
# adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips
|
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
||||||
# cleanly if unset.
|
|
||||||
- name: Publish release to Codeberg
|
- name: Publish release to Codeberg
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
||||||
@@ -413,7 +377,6 @@ jobs:
|
|||||||
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
|
||||||
SHA: ${{ github.sha }}
|
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
if [ -z "${TOKEN:-}" ]; then
|
if [ -z "${TOKEN:-}" ]; then
|
||||||
@@ -438,21 +401,34 @@ 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
|
||||||
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the
|
# Never mint the tag here. Gitea's push mirror owns getting it to
|
||||||
# mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
|
# Codeberg; this step's only job is to attach a release to a tag that
|
||||||
# from Gitea (what 0.3.2 did) would wait forever. The tag this
|
# has already landed. That split matters because every way of creating
|
||||||
# pipeline minted on Gitea is in fact *deleted* by the next mirror
|
# a tag from here — git push, or a release POST carrying
|
||||||
# sync until Codeberg has it — so pushing it here is what makes it
|
# target_commitish for a tag Codeberg lacks — is a ref WRITE, and ref
|
||||||
# durable on both forges.
|
# writes are what fail on this repo ("cannot lock references" on push,
|
||||||
|
# an empty-bodied 500 on the API). Attaching to a tag that is already
|
||||||
|
# present needs no ref write and succeeds.
|
||||||
#
|
#
|
||||||
# Pushing the ref first and attaching with NO target_commitish is
|
# So: wait for the mirror, verify, then attach. If the tag never shows
|
||||||
# deliberate: a release POST carrying a target_commitish for a commit
|
# up, fail — do NOT fall back to creating it, which is what produced
|
||||||
# or tag Codeberg hasn't received yet is what produced the
|
# the silent breakage across 0.2.1 through 0.3.2.
|
||||||
# empty-bodied 500s. Attaching to a ref that already exists doesn't
|
TAG_OK=""
|
||||||
# need the API to write one.
|
for i in $(seq 1 30); do
|
||||||
git tag -f "$TAG" "$SHA"
|
if [ "$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
|
-H "Authorization: token $TOKEN" "$API/tags/$TAG")" = "200" ]; then
|
||||||
"refs/tags/$TAG"
|
TAG_OK=1; echo "Codeberg has $TAG (after ~$((i*10))s)"; break
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
if [ -z "$TAG_OK" ]; then
|
||||||
|
echo "Codeberg never received $TAG from the push mirror (waited 300s)." >&2
|
||||||
|
echo "Not creating it here: ref writes to this repo fail, so that" >&2
|
||||||
|
echo "would 500. Check the mirror, then re-run once the tag is there." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# No target_commitish: the tag exists, so the API must attach to it
|
||||||
|
# rather than resolve a commit and mint one.
|
||||||
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
|
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
|
||||||
import json, sys
|
import json, sys
|
||||||
tag, pre = sys.argv[1:3]
|
tag, pre = sys.argv[1:3]
|
||||||
@@ -465,31 +441,20 @@ jobs:
|
|||||||
"prerelease": pre == "true",
|
"prerelease": pre == "true",
|
||||||
}))
|
}))
|
||||||
PY
|
PY
|
||||||
# Create (or update) the release. Codeberg 500s on a POST/GET against a
|
# Upsert (re-run safe): a release already attached to this tag is
|
||||||
# tag it has only just received — the release request outruns the
|
# PATCHed in place, so re-running never disturbs a published release.
|
||||||
# indexing of the ref we pushed a moment ago — so a single attempt can
|
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||||
# fail even though the very same call succeeds seconds later. Retry
|
if [ -n "$ID" ]; then
|
||||||
# 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 \
|
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
-d @cb-payload.json "$API/releases/$EXIST"
|
-d @cb-payload.json "$API/releases/$ID"
|
||||||
ID="$EXIST"; break
|
else
|
||||||
fi
|
curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \
|
||||||
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")
|
-d @cb-payload.json "$API/releases"
|
||||||
echo "release POST attempt $attempt HTTP $CODE"
|
|
||||||
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
||||||
[ -n "$ID" ] && break
|
fi
|
||||||
sleep $((attempt * 10))
|
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi
|
||||||
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
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
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
|
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -53,15 +53,5 @@ 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/
|
||||||
|
|||||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
|||||||
[submodule "floret-kit"]
|
[submodule "floret-kit"]
|
||||||
path = floret-kit
|
path = floret-kit
|
||||||
url = https://codeberg.org/jlmakiola/floret-kit.git
|
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
||||||
|
|||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -7,18 +7,6 @@ All notable changes to this project are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
|
||||||
- Agendula can now be translated. Pick or request a language on Weblate and
|
|
||||||
translate in the browser — the link sits at the top of the language picker in
|
|
||||||
**Settings → App language**. Partial translations are fine; anything
|
|
||||||
untranslated falls back to English.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Agendula's home is now **Codeberg** (`jlmakiola/agendula`) — that's where the
|
|
||||||
source, issues, pull requests and releases live. The Source and License links
|
|
||||||
in Settings, the issue-reporting link and the F-Droid metadata all point there
|
|
||||||
now. The self-hosted Gitea instance stays as build infrastructure.
|
|
||||||
|
|
||||||
## [0.3.2] - 2026-07-20
|
## [0.3.2] - 2026-07-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -33,33 +33,10 @@ 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 (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
|
CI (`.gitea/workflows/ci.yaml`) 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 |
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -6,7 +6,6 @@
|
|||||||
Reads, writes, and reminds — on top of an existing tasks provider, with no own
|
Reads, writes, and reminds — on top of an existing tasks provider, with no own
|
||||||
sync stack.</p>
|
sync stack.</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">
|
||||||
@@ -14,7 +13,7 @@ sync stack.</p>
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
|
Agendula is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/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 is
|
||||||
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
|
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
|
||||||
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
|
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
|
||||||
@@ -41,18 +40,6 @@ adapter — because it builds on the provider, not on any one sync app. Google
|
|||||||
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
|
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
|
||||||
owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane.
|
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
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -111,18 +111,6 @@ 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() }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,35 +43,57 @@ class ReminderScheduler @Inject constructor(
|
|||||||
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
||||||
.getOrElse { return@withContext }
|
.getOrElse { return@withContext }
|
||||||
|
|
||||||
|
// One reminder per *occurrence*: the instances view 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 (the query is unsorted, so which one
|
||||||
|
// survived was provider-defined).
|
||||||
|
// Per-task leads, stored as Alarm property rows. 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 lead = perTask[task.taskId]
|
||||||
|
?: settings.reminderLeadFor(task.listId)
|
||||||
|
?: return@mapNotNull null
|
||||||
|
ScheduledReminder(
|
||||||
|
taskId = task.taskId,
|
||||||
|
triggerAt = task.due!!.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 +103,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,6 +11,7 @@ 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
|
||||||
@@ -98,6 +99,50 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateInstance(instanceId: Long, form: TaskForm) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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, Int> {
|
||||||
|
val uri = TasksContract.propertiesUri(authority())
|
||||||
|
val projection = arrayOf(Properties.TASK_ID, TasksContract.Alarm.MINUTES_BEFORE)
|
||||||
|
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)
|
||||||
|
if (id != null && minutes != null) put(id, minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: 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)
|
||||||
@@ -127,9 +172,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()
|
||||||
}
|
}
|
||||||
|
// Register both or neither: if the second call throws, the first
|
||||||
|
// registration would otherwise leak (no AutoCloseable was handed back yet).
|
||||||
|
try {
|
||||||
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
|
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
|
||||||
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
||||||
return AutoCloseable { resolver.unregisterContentObserver(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,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
|
||||||
|
}
|
||||||
@@ -38,7 +38,14 @@ 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),
|
// 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.
|
||||||
|
isRecurring = r.getString(Tasks.RRULE) != null ||
|
||||||
|
r.getString(Tasks.RDATE) != null ||
|
||||||
|
r.getBoolean(Instances.IS_RECURRING),
|
||||||
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),
|
||||||
|
|||||||
@@ -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,6 +94,19 @@ object TaskWriteMapper {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
)
|
||||||
|
|
||||||
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
fun localListValues(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,
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -98,6 +101,50 @@ object TasksContract {
|
|||||||
const val IS_RECURRING = "is_recurring"
|
const val IS_RECURRING = "is_recurring"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
@@ -112,6 +159,15 @@ object TasksContract {
|
|||||||
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()
|
||||||
|
|||||||
@@ -23,6 +23,25 @@ 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 its *instance*
|
||||||
|
* row id. The provider forks an override task rather than moving the series
|
||||||
|
* anchor — which is what [updateTask] would do, since a recurring task's
|
||||||
|
* start/due are read from the instances view.
|
||||||
|
*/
|
||||||
|
fun updateInstance(instanceId: Long, 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 lead, by task id. One query, for the scheduler. */
|
||||||
|
fun alarms(): Map<Long, Int>
|
||||||
|
|
||||||
fun setCompleted(taskId: Long, completed: Boolean)
|
fun setCompleted(taskId: Long, completed: Boolean)
|
||||||
fun deleteTask(taskId: Long)
|
fun deleteTask(taskId: Long)
|
||||||
fun createLocalList(name: String, color: Int): Long
|
fun createLocalList(name: String, color: Int): Long
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ interface TasksRepository {
|
|||||||
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)
|
suspend fun setCompleted(taskId: Long, 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
|
||||||
|
|
||||||
/** Synchronous snapshot for the permission/onboarding gate. */
|
/** Synchronous snapshot for the permission/onboarding gate. */
|
||||||
|
|||||||
@@ -80,19 +80,40 @@ 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] }.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 come from the instances view, so writing
|
||||||
|
// them back to tasks/<id> would re-anchor the whole series. Going through
|
||||||
|
// the occurrence lets the provider fork an override instead.
|
||||||
|
if (current != null && current.isRecurring) {
|
||||||
|
dataSource.updateInstance(current.id, form)
|
||||||
|
} else {
|
||||||
dataSource.updateTask(taskId, form)
|
dataSource.updateTask(taskId, form)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
|
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
|
||||||
withContext(io) { dataSource.setCompleted(taskId, completed) }
|
withContext(io) { dataSource.setCompleted(taskId, completed) }
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -48,6 +48,12 @@ 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 [id] is one occurrence of a series
|
||||||
|
* and [start]/[due] are that occurrence's resolved times — *not* the master's
|
||||||
|
* anchor. Edits must go through the instances URI (see
|
||||||
|
* [de.jeanlucmakiola.agendula.data.tasks.TasksContract.instanceUri]).
|
||||||
|
*/
|
||||||
val isRecurring: Boolean,
|
val isRecurring: Boolean,
|
||||||
val distanceFromCurrent: Int?,
|
val distanceFromCurrent: Int?,
|
||||||
val created: Instant?,
|
val created: Instant?,
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ 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.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.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.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.tasks.ProviderStatus
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||||
@@ -39,6 +43,18 @@ 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.
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) permissionViewModel.refresh()
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
|
||||||
when (permission.status) {
|
when (permission.status) {
|
||||||
ProviderStatus.NO_PROVIDER -> Gate(
|
ProviderStatus.NO_PROVIDER -> Gate(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|||||||
@@ -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,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())
|
||||||
@@ -501,13 +501,16 @@ 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,7 +42,7 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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.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
|
||||||
@@ -12,7 +13,6 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
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.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -56,7 +56,7 @@ class ListsViewModel @Inject constructor(
|
|||||||
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
||||||
) { lists, openTasks, completedTasks ->
|
) { lists, openTasks, completedTasks ->
|
||||||
buildContent(lists, openTasks, completedTasks) as ListsUiState
|
buildContent(lists, openTasks, completedTasks) as ListsUiState
|
||||||
}.catch { emit(ListsUiState.Failure) }
|
}.recoveringFromProviderFailure { ListsUiState.Failure }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
||||||
|
|
||||||
private fun buildContent(
|
private fun buildContent(
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ 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
|
||||||
@@ -85,6 +84,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormField
|
|||||||
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,7 +92,6 @@ 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.floret.reminders.reminderOverrideFor
|
||||||
@@ -212,70 +211,19 @@ 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) },
|
||||||
)
|
)
|
||||||
LanguageRow(position = Position.Middle)
|
LanguagePickerRow(
|
||||||
|
position = Position.Middle,
|
||||||
|
title = stringResource(R.string.settings_language),
|
||||||
|
autoLabel = stringResource(R.string.settings_language_auto),
|
||||||
|
localesConfig = R.xml.locales_config,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
|
||||||
|
)
|
||||||
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) {
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ 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.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.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.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -33,8 +33,14 @@ class SettingsViewModel @Inject constructor(
|
|||||||
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())
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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
|
||||||
@@ -12,7 +13,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.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
|
||||||
@@ -87,7 +87,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 +115,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. */
|
||||||
|
|||||||
@@ -190,16 +190,13 @@
|
|||||||
<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://codeberg.org/jlmakiola/agendula</string>
|
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/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://codeberg.org/jlmakiola/agendula/issues/new</string>
|
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new</string>
|
||||||
<string name="about_license_url" translatable="false">https://codeberg.org/jlmakiola/agendula/src/branch/main/LICENSE</string>
|
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/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>
|
||||||
|
|||||||
@@ -50,6 +50,31 @@ class TaskMapperTest {
|
|||||||
assertThat(task.isSubtask).isTrue()
|
assertThat(task.isSubtask).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recurrence is detected from rrule when is_recurring is absent`() {
|
||||||
|
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column;
|
||||||
|
// reading it alone would report the series as one-off and send its edits
|
||||||
|
// to the master row, re-anchoring the whole thing.
|
||||||
|
val task = TaskMapper.task(
|
||||||
|
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")),
|
||||||
|
)
|
||||||
|
assertThat(task.isRecurring).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recurrence is detected from rdate alone`() {
|
||||||
|
val task = TaskMapper.task(
|
||||||
|
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")),
|
||||||
|
)
|
||||||
|
assertThat(task.isRecurring).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a plain task is not recurring`() {
|
||||||
|
val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
|
||||||
|
assertThat(task.isRecurring).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `falls back to instance id when task_id missing, and list color when no task color`() {
|
fun `falls back to instance id when task_id missing, and list color when no task color`() {
|
||||||
val task = TaskMapper.task(
|
val task = TaskMapper.task(
|
||||||
|
|||||||
@@ -85,6 +85,57 @@ class TaskWriteMapperTest {
|
|||||||
assertThat(values[Tasks.TZ]).isNull()
|
assertThat(values[Tasks.TZ]).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day timestamps are pinned to UTC midnight`() {
|
||||||
|
// 2026-07-20T22:00Z — i.e. local midnight on the 21st in Berlin (UTC+2).
|
||||||
|
// The provider resolves all-day dates against UTC, so storing this as-is
|
||||||
|
// would land the task on the 20th for anyone reading it back.
|
||||||
|
val berlinMidnight = Instant.fromEpochMilliseconds(1_784_412_000_000L)
|
||||||
|
val values = TaskWriteMapper.taskValues(
|
||||||
|
TaskForm(title = "Holiday", listId = 1L, start = berlinMidnight, due = berlinMidnight, isAllDay = true),
|
||||||
|
tzId = "Europe/Berlin",
|
||||||
|
)
|
||||||
|
|
||||||
|
val dayMs = 24L * 60 * 60 * 1000
|
||||||
|
assertThat(values[Tasks.DUE] as Long % dayMs).isEqualTo(0L)
|
||||||
|
assertThat(values[Tasks.DTSTART] as Long % dayMs).isEqualTo(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed timestamps are written untouched`() {
|
||||||
|
val at = Instant.fromEpochMilliseconds(1_784_412_345_678L)
|
||||||
|
val values = TaskWriteMapper.taskValues(
|
||||||
|
TaskForm(title = "Standup", listId = 1L, start = at, due = at),
|
||||||
|
tzId = "Europe/Berlin",
|
||||||
|
)
|
||||||
|
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L)
|
||||||
|
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duration is always cleared so it cannot collide with due`() {
|
||||||
|
// The provider validates the *merged* row and throws "Only one of DUE or
|
||||||
|
// DURATION must be supplied" if the stored row still carries a duration.
|
||||||
|
val values = TaskWriteMapper.taskValues(
|
||||||
|
TaskForm(title = "x", listId = 1L, due = Instant.fromEpochMilliseconds(5_000L)),
|
||||||
|
tzId = "UTC",
|
||||||
|
)
|
||||||
|
assertThat(values.containsKey(Tasks.DURATION)).isTrue()
|
||||||
|
assertThat(values[Tasks.DURATION]).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instance values drop list and parent, which an override cannot express`() {
|
||||||
|
val form = TaskForm(title = "x", listId = 4L, parentId = 7L, due = Instant.fromEpochMilliseconds(1_000L))
|
||||||
|
val values = TaskWriteMapper.instanceValues(form, tzId = "UTC")
|
||||||
|
|
||||||
|
assertThat(values.containsKey(Tasks.LIST_ID)).isFalse()
|
||||||
|
assertThat(values.containsKey(Tasks.PARENT_ID)).isFalse()
|
||||||
|
// …but still carries the edit itself.
|
||||||
|
assertThat(values[Tasks.TITLE]).isEqualTo("x")
|
||||||
|
assertThat(values[Tasks.DUE]).isEqualTo(1_000L)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `completion sets status, percent and timestamp, un-completion clears them`() {
|
fun `completion sets status, percent and timestamp, un-completion clears them`() {
|
||||||
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
|
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
|
||||||
@@ -97,6 +148,22 @@ class TaskWriteMapperTest {
|
|||||||
assertThat(undone[Tasks.COMPLETED]).isNull()
|
assertThat(undone[Tasks.COMPLETED]).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `alarm carries every column the provider's validator demands`() {
|
||||||
|
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
|
||||||
|
|
||||||
|
assertThat(values[TasksContract.Properties.TASK_ID]).isEqualTo(12L)
|
||||||
|
assertThat(values[TasksContract.Properties.MIMETYPE])
|
||||||
|
.isEqualTo("vnd.android.cursor.item/alarm")
|
||||||
|
assertThat(values[TasksContract.Alarm.MINUTES_BEFORE]).isEqualTo(30)
|
||||||
|
// REFERENCE must be present and non-negative, ALARM_TYPE present and
|
||||||
|
// non-zero (0 is excluded from the provider's has_alarms count).
|
||||||
|
assertThat(values[TasksContract.Alarm.REFERENCE]).isEqualTo(TasksContract.Alarm.REFERENCE_DUE)
|
||||||
|
assertThat(values[TasksContract.Alarm.ALARM_TYPE]).isEqualTo(TasksContract.Alarm.TYPE_MESSAGE)
|
||||||
|
// property_id must be absent or the insert is rejected.
|
||||||
|
assertThat(values.containsKey(TasksContract.Properties.PROPERTY_ID)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `local list uses the LOCAL account`() {
|
fun `local list uses the LOCAL account`() {
|
||||||
val values = TaskWriteMapper.localListValues("Inbox", 0x123)
|
val values = TaskWriteMapper.localListValues("Inbox", 0x123)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class AllDayTimeTest {
|
||||||
|
|
||||||
|
private val berlin = ZoneId.of("Europe/Berlin") // UTC+2 in July
|
||||||
|
private val newYork = ZoneId.of("America/New_York") // UTC-4 in July
|
||||||
|
private val julyTwentieth = LocalDate.of(2026, 7, 20)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day instant is UTC midnight of its date`() {
|
||||||
|
val instant = allDayInstantOf(julyTwentieth)
|
||||||
|
assertThat(instant.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0L)
|
||||||
|
assertThat(instant.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day date reads the same everywhere, unlike a timed one`() {
|
||||||
|
val allDay = allDayInstantOf(julyTwentieth)
|
||||||
|
// The whole point: zone must not change which day an all-day value denotes.
|
||||||
|
assertThat(allDay.calendarDate(allDay = true, zone = berlin)).isEqualTo(julyTwentieth)
|
||||||
|
assertThat(allDay.calendarDate(allDay = true, zone = newYork)).isEqualTo(julyTwentieth)
|
||||||
|
// Read as a timed value in New York it would slip to the 19th — the bug.
|
||||||
|
assertThat(allDay.calendarDate(allDay = false, zone = newYork)).isEqualTo(julyTwentieth.minusDays(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggling all-day off keeps the day and lands on local midnight`() {
|
||||||
|
val allDay = allDayInstantOf(julyTwentieth)
|
||||||
|
val timed = allDay.rebasedForAllDay(allDay = false, zone = berlin)
|
||||||
|
|
||||||
|
assertThat(timed.calendarDate(allDay = false, zone = berlin)).isEqualTo(julyTwentieth)
|
||||||
|
val local = java.time.Instant.ofEpochMilli(timed.toEpochMilliseconds()).atZone(berlin)
|
||||||
|
assertThat(local.toLocalTime()).isEqualTo(java.time.LocalTime.MIDNIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggling all-day on keeps the day the user was looking at`() {
|
||||||
|
// 2026-07-20T23:30 in Berlin — late enough that a naive UTC read slips a day.
|
||||||
|
val lateEvening = Instant.fromEpochMilliseconds(
|
||||||
|
julyTwentieth.atTime(23, 30).atZone(berlin).toInstant().toEpochMilli(),
|
||||||
|
)
|
||||||
|
val allDay = lateEvening.rebasedForAllDay(allDay = true, zone = berlin)
|
||||||
|
|
||||||
|
assertThat(allDay.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-tripping the toggle is stable`() {
|
||||||
|
val original = allDayInstantOf(julyTwentieth)
|
||||||
|
val there = original.rebasedForAllDay(allDay = false, zone = newYork)
|
||||||
|
val back = there.rebasedForAllDay(allDay = true, zone = newYork)
|
||||||
|
assertThat(back).isEqualTo(original)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ Agendula is a Material 3 Expressive **front-end** over the OpenTasks
|
|||||||
`TaskContract` provider — it reads, writes, and reminds on top of a tasks store
|
`TaskContract` provider — it reads, writes, and reminds on top of a tasks store
|
||||||
that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over
|
that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over
|
||||||
CalDAV. **Agendula owns no database and no sync stack.** It is the task-list
|
CalDAV. **Agendula owns no database and no sync stack.** It is the task-list
|
||||||
sibling to [Calendula](https://codeberg.org/jlmakiola/calendula),
|
sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula),
|
||||||
which does the same thing for `CalendarContract`.
|
which does the same thing for `CalendarContract`.
|
||||||
|
|
||||||
The whole design hangs off one rule:
|
The whole design hangs off one rule:
|
||||||
@@ -244,8 +244,8 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
|
|||||||
| Other | DataStore, kotlinx-datetime, kotlinx-coroutines |
|
| Other | DataStore, kotlinx-datetime, kotlinx-coroutines |
|
||||||
| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam |
|
| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam |
|
||||||
| Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). |
|
| Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). |
|
||||||
| CI | Split by forge: `.forgejo/workflows/ci.yaml` on Codeberg (canonical, no secrets), `.gitea/workflows/release.yaml` on Gitea (all secrets). See [`RELEASING.md`](RELEASING.md). |
|
| CI | Gitea workflows (`.gitea/workflows/ci.yaml`, `release.yaml`) |
|
||||||
| Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs |
|
| Distribution | F-Droid (`fdroid-metadata/`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over
|
Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over
|
||||||
the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync
|
the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync
|
||||||
over CalDAV), with no own database or sync stack. Sibling to
|
over CalDAV), with no own database or sync stack. Sibling to
|
||||||
[Calendula](https://codeberg.org/jlmakiola/calendula). See the
|
[Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). See the
|
||||||
top-level [`../README.md`](../README.md) for the project pitch.
|
top-level [`../README.md`](../README.md) for the project pitch.
|
||||||
|
|
||||||
## Index
|
## Index
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
# Agendula — releasing
|
# Agendula — releasing
|
||||||
|
|
||||||
Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) and a
|
Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) with
|
||||||
**Codeberg release** per version carrying the signed APK as a direct download.
|
a human-readable **Gitea release** per version. Both are produced automatically
|
||||||
Both are produced automatically by `.gitea/workflows/release.yaml` when a
|
by `.gitea/workflows/release.yaml` when a **bumped `versionName` reaches `main`**
|
||||||
**bumped `versionName` reaches `main`** — the pipeline builds and publishes that
|
— the pipeline builds and publishes that version, then creates the matching
|
||||||
version, then creates the matching `vX.Y.Z` tag and the releases itself. The
|
`vX.Y.Z` tag and Gitea release itself. There are no APK assets on the Gitea
|
||||||
parallel **Gitea release** is the changelog of record on the build instance and
|
release: distribution lives in the F-Droid repo; the release is the changelog of
|
||||||
carries no APK assets.
|
record.
|
||||||
|
|
||||||
Codeberg is the canonical forge; Gitea is build infrastructure. See
|
While Agendula is pre-1.0 (`versionName` starts with `0.`), every Gitea release
|
||||||
[Two forges, one repo](#two-forges-one-repo) for how the two are wired.
|
is flagged as a **pre-release**. This happens automatically and graduates to a
|
||||||
|
|
||||||
While Agendula is pre-1.0 (`versionName` starts with `0.`), every release is
|
|
||||||
flagged as a **pre-release**. This happens automatically and graduates to a
|
|
||||||
stable release at `1.0.0` — no manual toggling.
|
stable release at `1.0.0` — no manual toggling.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -83,49 +80,35 @@ re-running the workflow safely retries).
|
|||||||
CI and release are split so a change is built once on its PR and only does
|
CI and release are split so a change is built once on its PR and only does
|
||||||
release work when a merge actually cuts a release:
|
release work when a merge actually cuts a release:
|
||||||
|
|
||||||
- **`ci.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**) — the
|
- **`ci.yaml`** (on `pull_request`) — the reproducible-release invariant guard
|
||||||
reproducible-release invariant guard
|
|
||||||
(`scripts/check_reproducible_release.sh`), then lint + unit tests + a debug
|
(`scripts/check_reproducible_release.sh`), then lint + unit tests + a debug
|
||||||
assemble and a Trivy scan, once per PR. Docs/metadata-only PRs skip the Android
|
assemble and a Trivy scan, once per PR. Docs/metadata-only PRs skip the Android
|
||||||
build but still report a green `CI` check.
|
build but still report a green `CI` check.
|
||||||
- **`translations.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**)
|
- **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap
|
||||||
— an SDK-free parity check (`scripts/check_translations.py`) over
|
`detect` job reads `versionName` and checks whether a tag for it already
|
||||||
`values-*/strings.xml`, so Weblate PRs get fast feedback. Runs on every PR
|
exists. Only when it doesn't does the `release` job run: unit tests on the
|
||||||
without a path filter, so the required `Translations / check` status is always
|
merged commit, pin `versionCode`, build & sign the release APK with the **app
|
||||||
reported.
|
key**, copy it into the F-Droid repo, generate the per-version changelog from
|
||||||
- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC plus
|
the fastlane tree, re-sign the index with the **repo key**, upload `repo/` +
|
||||||
`workflow_dispatch`, **Gitea**) — the dependency sweep. Runs the pinned
|
`metadata/`, then create the `vX.Y.Z` tag + Gitea release (CHANGELOG section as
|
||||||
Renovate image as its job container and targets Codeberg's API; the cadence
|
notes, flagged pre-release while `MAJOR` is 0), attach the R8 `mapping.txt`,
|
||||||
lives here, not in `renovate.json5`, so the two don't double-gate.
|
and mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
|
||||||
- **`release.yaml`** (`.gitea/workflows/`, on push to `main` plus
|
(both best-effort). Ordinary merges with no version bump fall through `detect`
|
||||||
`workflow_dispatch`, **Gitea**) — a cheap `detect` job reads `versionName` and
|
and do nothing.
|
||||||
checks **Codeberg** for a tag for it. Only when there isn't one does the
|
|
||||||
`release` job run: unit tests on the merged commit, pin `versionCode`, build &
|
|
||||||
sign the release APK with the **app key**, copy it into the F-Droid repo,
|
|
||||||
generate the per-version changelog from the fastlane tree, re-sign the index
|
|
||||||
with the **repo key**, upload `repo/` + `metadata/`, then create the `vX.Y.Z`
|
|
||||||
tag + Gitea release (CHANGELOG section as notes, flagged pre-release while
|
|
||||||
`MAJOR` is 0), attach the R8 `mapping.txt`, and publish the release on
|
|
||||||
**Codeberg** with the signed APK + a SHA-256 checksum. Ordinary merges with no
|
|
||||||
version bump fall through `detect` and do nothing.
|
|
||||||
|
|
||||||
### Codeberg direct-download channel
|
### Codeberg direct-download channel
|
||||||
|
|
||||||
Alongside F-Droid, each release is published on the Codeberg repo
|
Alongside F-Droid, each release is mirrored to the Codeberg repo
|
||||||
(`jlmakiola/agendula`) as a plain download for users who don't want F-Droid.
|
(`jlmakiola/agendula`) as a plain download for users who don't want F-Droid.
|
||||||
Releases aren't git objects and don't sync with the push mirror in either
|
Gitea already **push-mirrors** branches and tags to Codeberg, but releases
|
||||||
direction, so the pipeline pushes the `vX.Y.Z` tag straight to Codeberg, creates
|
aren't git objects and don't sync, so the pipeline creates the release over the
|
||||||
the release over the Codeberg API, and attaches `agendula_v<version>.apk` + its
|
Codeberg API and attaches `agendula_v<version>.apk` + its `.sha256`. It's the
|
||||||
`.sha256`. It's the same APK the F-Droid repo serves (same **app key**), so it
|
same APK the F-Droid repo serves (same **app key**), so it adds no trust surface.
|
||||||
adds no trust surface. It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset,
|
The step is best-effort: a Codeberg outage never fails an already-published
|
||||||
but it is **not** `continue-on-error`: through 0.2.1–0.3.2 this step reported
|
F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset.
|
||||||
green 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
|
|
||||||
fails the release loudly.
|
|
||||||
|
|
||||||
One-time setup: the Codeberg repo's **Releases** unit must be enabled and a
|
One-time setup: the Codeberg repo's **Releases** unit must be enabled and a
|
||||||
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope
|
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope)
|
||||||
— it pushes the tag as well as creating the release) added to Gitea Actions.
|
added to Gitea Actions.
|
||||||
|
|
||||||
### Manual re-sign / recovery
|
### Manual re-sign / recovery
|
||||||
|
|
||||||
@@ -137,51 +120,6 @@ rotation or repo recovery without publishing a new app version.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Two forges, one repo
|
|
||||||
|
|
||||||
**Codeberg (`jlmakiola/agendula`) is canonical** — git, issues, PRs, tags and
|
|
||||||
releases. The self-hosted Gitea instance is build infrastructure: it holds the
|
|
||||||
signing key, publishes the F-Droid repo, and runs the release pipeline. Codeberg
|
|
||||||
push-mirrors `main` and tags to Gitea, and a bumped `versionName` arriving there
|
|
||||||
triggers `release.yaml` exactly as before.
|
|
||||||
|
|
||||||
Workflows are separated by **directory**, not by conditionals. Forgejo looks in
|
|
||||||
`.forgejo/workflows` → `.gitea/workflows` → `.github/workflows` and stops at the
|
|
||||||
first that exists; Gitea doesn't know `.forgejo/` at all:
|
|
||||||
|
|
||||||
| Directory | Runs on | Contains | Secrets |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
|
|
||||||
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | signing key, F-Droid, Hetzner, bot tokens |
|
|
||||||
|
|
||||||
The line is drawn at **secrets, not at CI-vs-release**. That's what makes fork
|
|
||||||
PRs safe: everything a contributor can trigger lives in `.forgejo/` and can
|
|
||||||
reference no secret. Renovate stays on the Gitea runner *even though it opens
|
|
||||||
PRs on Codeberg* — it talks to Codeberg's API rather than moving its token onto
|
|
||||||
the contributor-facing runner. `detect` (and the Renovate job) additionally
|
|
||||||
carries a
|
|
||||||
`github.repository_owner == 'makiolaj'` guard, because the directory split only
|
|
||||||
holds while `.forgejo/` is non-empty — empty it and Codeberg would fall back to
|
|
||||||
`.gitea/` and run the release pipeline on the contributor-facing runner.
|
|
||||||
|
|
||||||
Two consequences worth remembering:
|
|
||||||
|
|
||||||
- **`detect` reads tags from Codeberg**, not from the Gitea instance it runs on.
|
|
||||||
Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by
|
|
||||||
the next sync until the Codeberg tag push propagates back. Asking Gitea inside
|
|
||||||
that window would re-cut a shipped release.
|
|
||||||
- **Any ref that exists only on Gitea gets deleted** by the mirror. That's
|
|
||||||
correct under Codeberg-canonical, but don't debug a "vanished" branch without
|
|
||||||
remembering it.
|
|
||||||
|
|
||||||
`floret-kit` is a submodule of this repo and follows the same move: `.gitmodules`
|
|
||||||
points at `https://codeberg.org/jlmakiola/floret-kit.git`, so a clone resolves
|
|
||||||
without reaching the personal Gitea instance. The Gitea copy is **kept** — every
|
|
||||||
existing tag records the old submodule URL, so rebuilds of past releases still
|
|
||||||
resolve.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Secrets (Gitea → repo Settings → Actions → Secrets)
|
## Secrets (Gitea → repo Settings → Actions → Secrets)
|
||||||
|
|
||||||
The workflow fails loudly if the F-Droid ones are missing — it will **never**
|
The workflow fails loudly if the F-Droid ones are missing — it will **never**
|
||||||
@@ -195,9 +133,7 @@ user's pinned repo).
|
|||||||
| `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. |
|
| `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. |
|
||||||
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. |
|
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. |
|
||||||
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
|
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
|
||||||
| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — pushes the tag to Codeberg, creates the release there and uploads the APK/checksum. If unset the step skips; if set and failing, the release fails. |
|
| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — creates the mirrored Codeberg release + uploads the APK/checksum. Best-effort; if unset the Codeberg step skips. |
|
||||||
| `RENOVATE_TOKEN` | Codeberg bot-account token — repo read/write + PR scope on `jlmakiola/agendula`. Used only by `renovate.yml`. |
|
|
||||||
| `GITHUB_COM_TOKEN` | Read-only github.com PAT (no scopes). Without it Renovate's changelog lookups hit the 60/h anonymous rate limit and PRs arrive with empty release notes. |
|
|
||||||
|
|
||||||
The app key signs APKs; the repo key signs the index (its fingerprint is what
|
The app key signs APKs; the repo key signs the index (its fingerprint is what
|
||||||
users pin). Neither key nor `config.yml` is ever uploaded to the server — they
|
users pin). Neither key nor `config.yml` is ever uploaded to the server — they
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ Summary: A modern Material 3 Expressive task app for Android.
|
|||||||
Categories:
|
Categories:
|
||||||
- Time
|
- Time
|
||||||
|
|
||||||
SourceCode: https://codeberg.org/jlmakiola/agendula
|
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/agendula
|
||||||
IssueTracker: https://codeberg.org/jlmakiola/agendula/issues
|
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues
|
||||||
|
|||||||
Submodule floret-kit updated: 5a576c4d28...396e538903
5
release-notes.md
Normal file
5
release-notes.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
### 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.
|
||||||
|
|
||||||
122
renovate.json5
122
renovate.json5
@@ -1,122 +0,0 @@
|
|||||||
{
|
|
||||||
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
|
|
||||||
extends: [
|
|
||||||
"config:recommended",
|
|
||||||
// chore(deps): … — match the repo's conventional-commit style.
|
|
||||||
":semanticCommits",
|
|
||||||
],
|
|
||||||
|
|
||||||
// `config:recommended` brings in mergeConfidence:age-confidence-badges, whose
|
|
||||||
// Age column is a Mend badge. Mend's Merge Confidence index only covers Maven
|
|
||||||
// Central: org.jetbrains.kotlin, junit, truth, turbine et al resolve, but
|
|
||||||
// every androidx/compose artifact lives on Google's Maven repo and comes back
|
|
||||||
// as a grey UNKNOWN — i.e. most of this project. Renovate already knows the
|
|
||||||
// real answer, since it derives release timestamps itself for the
|
|
||||||
// minimumReleaseAge rules below (Google Maven serves `last-modified` on its
|
|
||||||
// POMs), so take the age from there and leave Mend to the Confidence column,
|
|
||||||
// which still carries signal for the Maven Central half.
|
|
||||||
prBodyDefinitions: {
|
|
||||||
Age: "{{#if releaseTimestamp}}{{{newVersionAgeInDays}}} d{{else}}unknown{{/if}}",
|
|
||||||
},
|
|
||||||
// Default heading links to the Merge Confidence docs; this column is ours now.
|
|
||||||
prBodyHeadingDefinitions: {
|
|
||||||
Age: "Age",
|
|
||||||
},
|
|
||||||
|
|
||||||
// No automerge: a dependency bump goes through the same review (and, for
|
|
||||||
// anything touching the build, the same on-device check) as a feature
|
|
||||||
// before it can ride a release — see docs/RELEASING.md and the mandatory
|
|
||||||
// `scripts/verify-release.sh` gate.
|
|
||||||
automerge: false,
|
|
||||||
|
|
||||||
// One reviewable surface; the dashboard issue lists everything pending.
|
|
||||||
dependencyDashboard: true,
|
|
||||||
|
|
||||||
// The cooling-off periods below are advisory, not a gate. "flexible" still
|
|
||||||
// prefers a version that has cleared its window, but when every candidate is
|
|
||||||
// too young it opens the PR at the newest one anyway, so merging early stays
|
|
||||||
// a judgement call. (The default, "strict", would suppress the PR entirely
|
|
||||||
// until a release aged in.) A still-young branch carries a yellow
|
|
||||||
// `renovate/stability-days` check so it's visible which side of the line
|
|
||||||
// it's on; with automerge off, nothing acts on that check by itself.
|
|
||||||
//
|
|
||||||
// NOT "none": that short-circuits the candidate loop in filter-checks.ts, and
|
|
||||||
// that loop is what calls postprocessRelease — the only thing that fetches a
|
|
||||||
// Maven artifact's Last-Modified header. Skipping it leaves releaseTimestamp
|
|
||||||
// unset, which empties the Age column and quietly makes minimumReleaseAge and
|
|
||||||
// the stability check no-ops, since both need that timestamp to compare.
|
|
||||||
internalChecksFilter: "flexible",
|
|
||||||
|
|
||||||
labels: ["dependencies"],
|
|
||||||
prConcurrentLimit: 5,
|
|
||||||
prHourlyLimit: 0,
|
|
||||||
|
|
||||||
// Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml,
|
|
||||||
// Mondays) — no internal `schedule` here, so the two don't double-gate and
|
|
||||||
// silently skip a run.
|
|
||||||
|
|
||||||
// Workflows are split by forge and neither directory is .github: CI lives in
|
|
||||||
// .forgejo/workflows (Codeberg) and the release/renovate jobs in
|
|
||||||
// .gitea/workflows (Gitea). Extend the github-actions manager (same syntax)
|
|
||||||
// to watch both — otherwise the pinned Renovate image tag and the action
|
|
||||||
// versions in either file would never get bumped. See docs/RELEASING.md.
|
|
||||||
// `fileMatch` is deprecated; the replacement takes the regex delimited, and
|
|
||||||
// Renovate's config migration was already rewriting this on every run.
|
|
||||||
"github-actions": {
|
|
||||||
managerFilePatterns: ["/^\\.(gitea|forgejo)/workflows/[^/]+\\.ya?ml$/"],
|
|
||||||
},
|
|
||||||
|
|
||||||
packageRules: [
|
|
||||||
// Cooling-off period, scaled by blast radius: how long a release should
|
|
||||||
// have been out (and un-yanked, un-hotfixed) before it's considered
|
|
||||||
// settled. Advisory only — see `internalChecksFilter` above.
|
|
||||||
{
|
|
||||||
matchUpdateTypes: ["major"],
|
|
||||||
minimumReleaseAge: "30 days",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
matchUpdateTypes: ["minor"],
|
|
||||||
minimumReleaseAge: "20 days",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
matchUpdateTypes: ["patch", "digest", "pin", "rollback"],
|
|
||||||
minimumReleaseAge: "10 days",
|
|
||||||
},
|
|
||||||
// material3 is deliberately pinned to the 1.5 *alpha* line for the
|
|
||||||
// Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train
|
|
||||||
// but keep it in its own PR, reviewed in isolation; revisit the pin when
|
|
||||||
// 1.5.0 stable lands.
|
|
||||||
{
|
|
||||||
matchPackageNames: ["androidx.compose.material3:material3"],
|
|
||||||
ignoreUnstable: false,
|
|
||||||
groupName: "material3 (alpha)",
|
|
||||||
},
|
|
||||||
// Test-only deps: group into one low-noise PR.
|
|
||||||
{
|
|
||||||
matchPackageNames: [
|
|
||||||
"org.junit.jupiter:**",
|
|
||||||
"org.junit.platform:**",
|
|
||||||
"com.google.truth:**",
|
|
||||||
"app.cash.turbine:**",
|
|
||||||
"androidx.test:**",
|
|
||||||
"androidx.test.espresso:**",
|
|
||||||
"androidx.test.ext:**",
|
|
||||||
],
|
|
||||||
groupName: "test dependencies",
|
|
||||||
},
|
|
||||||
// Last word on the PR table. The merge-confidence preset sets prBodyColumns
|
|
||||||
// from inside a packageRule of its own, and only for the datasources Mend
|
|
||||||
// supports — so a plain top-level prBodyColumns would lose to it for maven
|
|
||||||
// deps, and the Gradle wrapper / Actions / container bumps would keep the
|
|
||||||
// default columns and show no age at all. A rule declared after it wins,
|
|
||||||
// and gives every PR the same table.
|
|
||||||
// "Pending" earns its place under a flexible filter: when the bump lands on
|
|
||||||
// a version that has cleared its window but a newer one hasn't, that newer
|
|
||||||
// version is named here rather than silently withheld.
|
|
||||||
{
|
|
||||||
matchPackageNames: ["*"],
|
|
||||||
prBodyColumns: ["Package", "Type", "Change", "Age", "Pending", "Confidence"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Validate Android translation resources against the base strings.xml.
|
|
||||||
|
|
||||||
Community translations live in ``app/src/main/res/values-<locale>/strings.xml``
|
|
||||||
and are produced via Weblate. This guard keeps incoming translation PRs honest:
|
|
||||||
|
|
||||||
* every translation file must be well-formed XML;
|
|
||||||
* a translation must not define keys absent from the base — those are stale
|
|
||||||
keys left behind after a rename/removal upstream;
|
|
||||||
* a translation must not translate strings marked ``translatable="false"`` in
|
|
||||||
the base (URLs, IDs and the like).
|
|
||||||
|
|
||||||
Missing keys are *allowed* and only reported as coverage: a missing string
|
|
||||||
falls back to the English base at runtime, so partial translations are fine
|
|
||||||
(this mirrors the lint config, which downgrades ``MissingTranslation``).
|
|
||||||
|
|
||||||
Exits non-zero if any error is found. Errors are emitted as Gitea/GitHub
|
|
||||||
Actions ``::error`` annotations so they surface inline on the PR.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
RES_DIR = Path("app/src/main/res")
|
|
||||||
BASE = RES_DIR / "values" / "strings.xml"
|
|
||||||
RESOURCE_TAGS = ("string", "plurals", "string-array")
|
|
||||||
|
|
||||||
|
|
||||||
def entries(path: Path) -> dict[str, bool]:
|
|
||||||
"""Map resource name -> is-translatable for every entry in ``path``."""
|
|
||||||
root = ET.parse(path).getroot()
|
|
||||||
return {
|
|
||||||
el.attrib["name"]: el.attrib.get("translatable", "true") != "false"
|
|
||||||
for el in root
|
|
||||||
if el.tag in RESOURCE_TAGS and "name" in el.attrib
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
if not BASE.exists():
|
|
||||||
print(f"::error::base resource file {BASE} not found", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
base = entries(BASE)
|
|
||||||
base_keys = set(base)
|
|
||||||
nontranslatable = {name for name, ok in base.items() if not ok}
|
|
||||||
translatable_total = len(base_keys - nontranslatable)
|
|
||||||
|
|
||||||
files = sorted(RES_DIR.glob("values-*/strings.xml"))
|
|
||||||
if not files:
|
|
||||||
print("No translation files found (values-*/strings.xml).")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
errors = 0
|
|
||||||
for path in files:
|
|
||||||
locale = path.parent.name[len("values-"):]
|
|
||||||
try:
|
|
||||||
translated = entries(path)
|
|
||||||
except ET.ParseError as exc:
|
|
||||||
print(f"::error file={path}::{locale}: malformed XML: {exc}")
|
|
||||||
errors += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
keys = set(translated)
|
|
||||||
stale = sorted(keys - base_keys)
|
|
||||||
translated_fixed = sorted(keys & nontranslatable)
|
|
||||||
missing = base_keys - nontranslatable - keys
|
|
||||||
|
|
||||||
for name in stale:
|
|
||||||
print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml")
|
|
||||||
errors += 1
|
|
||||||
for name in translated_fixed:
|
|
||||||
print(
|
|
||||||
f"::error file={path}::{locale}: key '{name}' is translatable=\"false\" "
|
|
||||||
"in the base and must not be translated"
|
|
||||||
)
|
|
||||||
errors += 1
|
|
||||||
|
|
||||||
covered = translatable_total - len(missing)
|
|
||||||
pct = covered * 100 // translatable_total if translatable_total else 100
|
|
||||||
verdict = "OK" if not (stale or translated_fixed) else "FAIL"
|
|
||||||
print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
print(f"\n{errors} translation error(s) found.", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
print("\nAll translation files are consistent with the base.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
Reference in New Issue
Block a user