Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89775e44a7 | ||
|
|
c0eb03e5df | ||
|
|
7bfda26de9 | ||
|
|
f65aed4fe2 | ||
|
|
84bb1116ec | ||
|
|
7fde7ae290 | ||
|
|
5d273e05c7 | ||
|
|
59582b3641 | ||
|
|
3540d2fb7c | ||
|
|
3812e950ca | ||
|
|
c6d62c36f4 | ||
|
|
a4fbbf43c6 | ||
|
|
d81e99084e | ||
|
|
9a87837917 | ||
|
|
9630bd104d | ||
|
|
7c3b62eb0d | ||
|
|
fe0c7d83a9 | ||
|
|
f92f9bcdad | ||
|
|
53d5e2c5aa | ||
|
|
6888965f5a | ||
|
|
f1ba21e7fa | ||
|
|
8abc44bbdf | ||
|
|
a6e3a35f04 | ||
|
|
a3de9f3d93 | ||
|
|
c823243e53 |
@@ -26,14 +26,14 @@ 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
|
||||
./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug
|
||||
python3 scripts/check_translations.py
|
||||
-->
|
||||
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally
|
||||
- [ ] `./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :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)
|
||||
|
||||
@@ -37,11 +37,29 @@ jobs:
|
||||
- name: Reproducible-release invariant
|
||||
run: bash scripts/check_reproducible_release.sh
|
||||
|
||||
# The committed versionName must parse (X.Y.Z or X.Y.Z-beta.N) and the
|
||||
# committed versionCode must be the one it derives: the official F-Droid
|
||||
# repo builds the tag as committed. And a beta must never reach main,
|
||||
# where the release pipeline would ship it to F-Droid and Play; betas are
|
||||
# cut from release/* branches (docs/RELEASING.md).
|
||||
- name: Committed version is well-formed
|
||||
env:
|
||||
BASE: ${{ github.base_ref }}
|
||||
run: |
|
||||
set -e
|
||||
bash scripts/version_info.sh --check
|
||||
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
|
||||
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
|
||||
echo "Set the stable version (and its versionCode) before merging into main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Also cheap, also always-on. Two failures in one: the script exits
|
||||
# non-zero if this version's changelog is over the 500-character limit,
|
||||
# and the porcelain check below catches a version with no committed
|
||||
# changelog, which would otherwise ship the CHANGELOG.md section instead
|
||||
# of a hand-written summary.
|
||||
# of a hand-written summary. A beta version passes both: it ships no
|
||||
# What's New, so the script writes nothing for it.
|
||||
- name: Changelog fits the stores, and is committed
|
||||
run: |
|
||||
set -e
|
||||
@@ -109,10 +127,12 @@ jobs:
|
||||
|
||||
- name: Setup Java
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
|
||||
# here keeps Gradle from downloading it via foojay.
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
distribution: 'jetbrains'
|
||||
java-version: '21'
|
||||
|
||||
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
|
||||
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
|
||||
@@ -160,18 +180,18 @@ jobs:
|
||||
|
||||
# No --no-daemon: the daemon lives only as long as this job container
|
||||
# and lets the following steps skip JVM startup + reconfiguration.
|
||||
- name: Lint (debug variant only)
|
||||
- name: Lint (debug variants only)
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew lintDebug
|
||||
run: ./gradlew lintFullDebug lintOfflineDebug
|
||||
|
||||
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
|
||||
# that task would compile the vendored suite and run none of it, which is
|
||||
# the whole safety argument in dav/PROVENANCE.md.
|
||||
- name: Unit tests
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew testDebugUnitTest :dav:test :caldav:test
|
||||
run: ./gradlew testFullDebugUnitTest testOfflineDebugUnitTest :dav:test :caldav:test
|
||||
|
||||
- name: Assemble debug APK
|
||||
- name: Assemble debug APKs
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ 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).
|
||||
# build. The deeper checks still run in CI via lintFullDebug (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
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
name: Beta — Codeberg pre-release
|
||||
|
||||
# A beta is cut by pushing a release branch whose committed versionName is
|
||||
# X.Y.Z-beta.N (see docs/RELEASING.md). Same model as release.yaml: the
|
||||
# committed version is the trigger and the vX.Y.Z-beta.N tag is an output. If
|
||||
# its Codeberg pre-release doesn't carry the APK yet, this runs the unit tests, builds and
|
||||
# signs the APK with the app key, records a Gitea pre-release (with the R8
|
||||
# mapping) and publishes a Codeberg pre-release with the APK + SHA-256.
|
||||
#
|
||||
# Deliberately nothing else. A beta never reaches the F-Droid repos or Play:
|
||||
# Obtainium hides pre-releases unless a user opts in, the official F-Droid
|
||||
# recipe only picks up `^v[0-9.]+$` tags, and Codeberg's "latest release"
|
||||
# skips pre-releases. Pushes of a release branch carrying a stable version
|
||||
# (the usual state) fall through `detect` and do nothing.
|
||||
#
|
||||
# Lives in .gitea/workflows next to release.yaml for the same reason: it needs
|
||||
# the app signing key, which only the self-hosted Gitea instance holds.
|
||||
on:
|
||||
push:
|
||||
branches: ['release/**']
|
||||
|
||||
concurrency:
|
||||
group: beta
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
# Gitea only; see the same guard in release.yaml.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
outputs:
|
||||
is_beta: ${{ steps.v.outputs.is_beta }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
version_code: ${{ steps.v.outputs.version_code }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version and whether it still needs publishing
|
||||
id: v
|
||||
run: |
|
||||
set -e
|
||||
INFO=$(bash scripts/version_info.sh)
|
||||
echo "$INFO"
|
||||
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||
if [ "$(bash scripts/version_info.sh channel)" != beta ]; then
|
||||
echo "Not a beta — nothing to do."
|
||||
echo "is_beta=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Done only once its Codeberg pre-release carries the APK, so a failed
|
||||
# publish is redone by the next push; refused unless newer than the
|
||||
# latest stable release.
|
||||
GATE=$(bash scripts/release_gate.sh)
|
||||
echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
beta:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_beta == 'true'
|
||||
runs-on: docker
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
VERSION: ${{ needs.detect.outputs.version }}
|
||||
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Java
|
||||
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
|
||||
# here keeps Gradle from downloading it via foojay.
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'jetbrains'
|
||||
java-version: '21'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
- name: Setup Android SDK cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /opt/android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-37.0" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Setup Gradle cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install jq
|
||||
run: |
|
||||
set -e
|
||||
SUDO=""
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y jq
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
$SUDO apk add --no-cache jq
|
||||
fi
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Pin versionCode to versionName
|
||||
run: |
|
||||
set -e
|
||||
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
||||
grep -E 'versionName|versionCode' app/build.gradle.kts
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testFullDebugUnitTest
|
||||
|
||||
# The real app key, same as a stable release: a beta has to update in
|
||||
# place to the next beta and to the stable version.
|
||||
- name: Setup Android Keystore
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
run: |
|
||||
mkdir -p app
|
||||
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
|
||||
cat > key.properties <<EOF
|
||||
storePassword=$KEY_PASSWORD
|
||||
keyPassword=$KEY_PASSWORD
|
||||
keyAlias=$KEY_ALIAS
|
||||
storeFile=upload-keystore.jks
|
||||
EOF
|
||||
|
||||
# Both flavors; the offline one goes to Codeberg only (issue #39).
|
||||
- name: Build release APKs
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
|
||||
# `## [Unreleased]`. Gitea creates the tag at this commit.
|
||||
- name: Create tag + Gitea pre-release
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||
cat release-notes.md
|
||||
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||
MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
|
||||
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
|
||||
bash scripts/publish_gitea_release.sh
|
||||
|
||||
# The point of the whole workflow, so NOT continue-on-error.
|
||||
- name: Publish pre-release to Codeberg
|
||||
env:
|
||||
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||
APK=app/build/outputs/apk/full/release/app-full-release.apk \
|
||||
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
|
||||
bash scripts/publish_codeberg_release.sh
|
||||
+53
-220
@@ -7,7 +7,9 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
||||
# the release on Codeberg with the signed APK + a SHA-256 checksum as a
|
||||
# direct-download channel — the tag is an output of the pipeline, not its
|
||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||
# nothing.
|
||||
# nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
|
||||
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
|
||||
# one that reaches main.
|
||||
#
|
||||
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
|
||||
# and separate because Play can reject a good build for reasons the pipeline
|
||||
@@ -60,60 +62,32 @@ jobs:
|
||||
|
||||
- name: Resolve version and whether it is a new release
|
||||
id: v
|
||||
env:
|
||||
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
||||
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
||||
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
||||
# source does not have. A tag minted here on Gitea is therefore wiped
|
||||
# by the next sync (Codeberg does not have it yet) and only reappears
|
||||
# once the tag push at the end of this workflow propagates back.
|
||||
# Asking Gitea inside that window would report "no tag" for a release
|
||||
# that already shipped, and cut it a second time.
|
||||
# Public repo, so this read needs no token.
|
||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||
run: |
|
||||
set -e
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
|
||||
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
|
||||
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
|
||||
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
|
||||
# versionName -> versionCode, channel and the pre-release flag (set
|
||||
# while MAJOR was 0) all come from the one script beta.yaml uses too.
|
||||
INFO=$(bash scripts/version_info.sh)
|
||||
echo "$INFO"
|
||||
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
|
||||
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "Manual dispatch — re-sign path, not a release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# A tag for this version already existing means the release shipped on
|
||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||
#
|
||||
# Anything other than a clean 200/404 is treated as fatal rather than
|
||||
# as "no tag". A Codeberg outage or a network blip would otherwise
|
||||
# read as absent and re-cut a release that has already shipped —
|
||||
# republishing to F-Droid. Failing here is recoverable; a duplicate
|
||||
# release is not.
|
||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
||||
case "$STATUS" in
|
||||
200)
|
||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
404)
|
||||
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
||||
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# Betas ship from release/* branches via beta.yaml and must never be
|
||||
# cut here: this path publishes to F-Droid and Play. CI blocks such a
|
||||
# PR into main; this is the backstop if one gets through anyway.
|
||||
if [ "$CHANNEL" != "stable" ]; then
|
||||
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Tags are read from Codeberg, the canonical forge, by exact name: its
|
||||
# git/refs/tags API matches by prefix, so v1.1.0-beta.1 would read as
|
||||
# v1.1.0. A lookup error is fatal rather than read as "no tag".
|
||||
GATE=$(bash scripts/release_gate.sh)
|
||||
echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Before a single Gradle task runs: F-Droid truncates the in-client
|
||||
# changelog, so an over-long one would reach users cut off mid-sentence.
|
||||
@@ -146,10 +120,12 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Java
|
||||
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
|
||||
# here keeps Gradle from downloading it via foojay.
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
distribution: 'jetbrains'
|
||||
java-version: '21'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
@@ -196,8 +172,8 @@ jobs:
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
# The committed versionName is the source of truth. Pin versionCode to the
|
||||
# value derived from it so the published APK's code is always
|
||||
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
||||
# value scripts/version_info.sh derives from it, so the published APK's
|
||||
# code follows the scheme even if the committed code was forgotten.
|
||||
- name: Pin versionCode to versionName
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: |
|
||||
@@ -208,7 +184,7 @@ jobs:
|
||||
# Test the exact commit being shipped (only on a real release).
|
||||
- name: Unit tests
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: ./gradlew testDebugUnitTest
|
||||
run: ./gradlew testFullDebugUnitTest
|
||||
|
||||
- name: Setup Android Keystore
|
||||
if: env.IS_RELEASE == 'true'
|
||||
@@ -226,7 +202,9 @@ jobs:
|
||||
storeFile=upload-keystore.jks
|
||||
EOF
|
||||
|
||||
- name: Build release APK
|
||||
# Both flavors. Only full goes to F-Droid and Play; the offline one
|
||||
# (issue #39) is a Codeberg release asset and nothing else.
|
||||
- name: Build release APKs
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
@@ -282,7 +260,7 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p fdroid/repo
|
||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
|
||||
cp app/build/outputs/apk/full/release/app-full-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
|
||||
|
||||
# Per-version "What's New": ensure this version's changelog exists in the
|
||||
# fastlane tree (committed at release-cut time for the official repo; this
|
||||
@@ -332,6 +310,8 @@ jobs:
|
||||
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
||||
# this commit, so the tag only ever marks a fully-shipped release (and a
|
||||
# failure before here leaves no tag, so re-running the workflow retries).
|
||||
# Also attaches the R8 mapping (best-effort) so user crash stacktraces
|
||||
# stay deobfuscatable. Notes = this version's CHANGELOG section.
|
||||
- name: Create tag + Gitea release
|
||||
if: env.IS_RELEASE == 'true'
|
||||
env:
|
||||
@@ -340,85 +320,18 @@ jobs:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
TAG="v$VERSION"
|
||||
# Notes = this version's CHANGELOG section.
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > release-notes.md
|
||||
sed -i -e '/./,$!d' release-notes.md
|
||||
if [ ! -s release-notes.md ]; then
|
||||
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
|
||||
fi
|
||||
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json
|
||||
import json, sys
|
||||
print(json.dumps({
|
||||
"tag_name": sys.argv[1],
|
||||
"target_commitish": sys.argv[2],
|
||||
"name": sys.argv[1],
|
||||
"body": open("release-notes.md").read(),
|
||||
"draft": False,
|
||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||
"prerelease": sys.argv[3] == "true",
|
||||
}))
|
||||
PY
|
||||
# Upsert (re-run safe): PATCH if a release for the tag already exists,
|
||||
# else POST a new one (which also creates the tag at target_commitish).
|
||||
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
||||
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
|
||||
if [ -n "$ID" ]; then
|
||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @payload.json "$API/releases/$ID")
|
||||
OK=200
|
||||
else
|
||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @payload.json "$API/releases")
|
||||
OK=201
|
||||
fi
|
||||
cat response.json
|
||||
if [ "$CODE" != "$OK" ]; then
|
||||
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Created/updated release $TAG at $SHA"
|
||||
|
||||
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
|
||||
# Attached to the release (it's not an APK, so it fits the no-binaries
|
||||
# rule). Best-effort: never fail a release over it.
|
||||
- name: Attach R8 mapping to Gitea release
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
run: |
|
||||
set -e
|
||||
MAP="app/build/outputs/mapping/release/mapping.txt"
|
||||
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
|
||||
TAG="v$VERSION"
|
||||
ASSET="mapping-${VERSION}.txt.gz"
|
||||
gzip -c "$MAP" > "/tmp/$ASSET"
|
||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
|
||||
# Replace any prior asset of the same name (re-run safe).
|
||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
|
||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$ASSET" \
|
||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
||||
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
||||
MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
|
||||
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
|
||||
bash scripts/publish_gitea_release.sh
|
||||
|
||||
# Publish the release on Codeberg, which is canonical for tags and
|
||||
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
|
||||
# to Gitea, but releases aren't git objects and don't sync in either
|
||||
# direction — so this step pushes the tag straight to Codeberg and creates
|
||||
# the release there over the API, attaching the signed APK plus a SHA-256
|
||||
# checksum as the direct-download channel for users who don't want
|
||||
# F-Droid. The APK is identical to the F-Droid one (same app key), so this
|
||||
# adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips
|
||||
# cleanly if unset.
|
||||
# releases (see docs/RELEASING.md): push the tag there and attach the
|
||||
# signed APK plus a SHA-256 checksum as the direct-download channel for
|
||||
# users who don't want F-Droid. The APK is identical to the F-Droid one
|
||||
# (same app key), so this adds no trust surface. Needs the
|
||||
# CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
||||
- name: Publish release to Codeberg
|
||||
if: env.IS_RELEASE == 'true'
|
||||
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
||||
@@ -431,91 +344,11 @@ jobs:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "${TOKEN:-}" ]; then
|
||||
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
||||
exit 0
|
||||
fi
|
||||
TAG="v$VERSION"
|
||||
APK="app/build/outputs/apk/release/app-release.apk"
|
||||
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
|
||||
ASSET_APK="agendula_v${VERSION}.apk"
|
||||
ASSET_SUM="${ASSET_APK}.sha256"
|
||||
cp "$APK" "/tmp/$ASSET_APK"
|
||||
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
||||
|
||||
# Release notes: reuse the section extracted for the Gitea release,
|
||||
# fall back to the CHANGELOG entry if that step's file is gone.
|
||||
if [ ! -s release-notes.md ]; then
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > release-notes.md
|
||||
sed -i -e '/./,$!d' release-notes.md
|
||||
fi
|
||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
||||
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the
|
||||
# mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
|
||||
# from Gitea (what 0.3.2 did) would wait forever. The tag this
|
||||
# pipeline minted on Gitea is in fact *deleted* by the next mirror
|
||||
# sync until Codeberg has it — so pushing it here is what makes it
|
||||
# durable on both forges.
|
||||
#
|
||||
# Pushing the ref first and attaching with NO target_commitish is
|
||||
# deliberate: a release POST carrying a target_commitish for a commit
|
||||
# or tag Codeberg hasn't received yet is what produced the
|
||||
# empty-bodied 500s. Attaching to a ref that already exists doesn't
|
||||
# need the API to write one.
|
||||
git tag -f "$TAG" "$SHA"
|
||||
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
|
||||
"refs/tags/$TAG"
|
||||
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
|
||||
import json, sys
|
||||
tag, pre = sys.argv[1:3]
|
||||
print(json.dumps({
|
||||
"tag_name": tag,
|
||||
"name": tag,
|
||||
"body": open("release-notes.md").read(),
|
||||
"draft": False,
|
||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||
"prerelease": pre == "true",
|
||||
}))
|
||||
PY
|
||||
# Create (or update) the release. Codeberg 500s on a POST/GET against a
|
||||
# tag it has only just received — the release request outruns the
|
||||
# indexing of the ref we pushed a moment ago — so a single attempt can
|
||||
# fail even though the very same call succeeds seconds later. Retry
|
||||
# with backoff, and PATCH in place if a release already exists (re-run
|
||||
# safe, so re-running never disturbs a published release). A 5xx body
|
||||
# still exits curl 0, so the loop, not `set -e`, controls the flow.
|
||||
ID=""
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
|
||||
if [ -n "$EXIST" ]; then
|
||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases/$EXIST"
|
||||
ID="$EXIST"; break
|
||||
fi
|
||||
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases")
|
||||
echo "release POST attempt $attempt HTTP $CODE"
|
||||
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
||||
[ -n "$ID" ] && break
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
|
||||
|
||||
# Attach APK + checksum, replacing any prior asset of the same name.
|
||||
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
|
||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$A" \
|
||||
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
||||
done
|
||||
echo "Published $TAG to Codeberg."
|
||||
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
||||
APK=app/build/outputs/apk/full/release/app-full-release.apk \
|
||||
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
|
||||
bash scripts/publish_codeberg_release.sh
|
||||
|
||||
# Play takes an App Bundle, not the APK: a second artifact from the same
|
||||
# source and signing config. Play treats the release key only as the
|
||||
@@ -529,7 +362,7 @@ jobs:
|
||||
- name: Build release AAB
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
run: ./gradlew bundleRelease
|
||||
run: ./gradlew bundleFullRelease
|
||||
|
||||
# NOT actions/upload-artifact@v4: its client refuses any non-github.com
|
||||
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
|
||||
@@ -541,7 +374,7 @@ jobs:
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
|
||||
with:
|
||||
name: release-aab-${{ needs.detect.outputs.version }}
|
||||
path: app/build/outputs/bundle/release/app-release.aab
|
||||
path: app/build/outputs/bundle/fullRelease/app-full-release.aab
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
@@ -635,7 +468,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Absolute: a lane body runs from fastlane/, not the workspace root.
|
||||
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
|
||||
AAB="$GITHUB_WORKSPACE/dist/app-full-release.aab"
|
||||
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
|
||||
bundle exec fastlane deploy \
|
||||
aab:"$AAB" \
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
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).
|
||||
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
|
||||
# same trigger release.yaml relies on), so a merged Renovate PR is followed
|
||||
# within minutes by a run that rebases the sibling PRs it just conflicted —
|
||||
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
|
||||
# `schedule` window such a run only maintains existing branches
|
||||
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
|
||||
# to renovate/* branches, not main, so this cannot loop.
|
||||
push:
|
||||
branches: [main]
|
||||
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
|
||||
# in renovate.json5 — keep the two in step.
|
||||
schedule:
|
||||
- cron: '0 5 * * 1'
|
||||
# Manual run for an on-demand sweep from the Actions tab.
|
||||
|
||||
@@ -7,6 +7,26 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Four new home-screen widgets: Today, Up next, Lists and Week.
|
||||
- A widget can show tasks from several lists at once, optionally grouped by
|
||||
list.
|
||||
- A setting under Settings → Task form pre-fills a new task's start with today.
|
||||
- An offline version of Agendula with no sync and no network access at all,
|
||||
published as a separate APK on Codeberg (#39).
|
||||
|
||||
### Changed
|
||||
- The Tasks widget has agenda-style rows, an optional row colour and an "All
|
||||
caught up" state. Ticking a task in any widget shows it as done for a moment
|
||||
before it disappears.
|
||||
- The "At a glance" widget is gone; the Tasks and Today widgets cover what it
|
||||
showed.
|
||||
- Synced lists can be renamed and deleted, not only device-only ones. A
|
||||
read-only share shows the edit button greyed out and explains why on tap.
|
||||
|
||||
### Fixed
|
||||
- The list editor's Where and smart-lists rows no longer have double padding.
|
||||
|
||||
## [1.0.0] - 2026-09-21
|
||||
|
||||
### Added
|
||||
|
||||
+3
-3
@@ -28,9 +28,9 @@ layer.
|
||||
## Build, test, lint
|
||||
|
||||
```sh
|
||||
./gradlew :app:assembleDebug # build the debug APK
|
||||
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
|
||||
./gradlew lintDebug # Android lint (CI runs this on every PR)
|
||||
./gradlew :app:assembleDebug # build the debug APKs (full + offline)
|
||||
./gradlew :app:testFullDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
|
||||
./gradlew lintFullDebug lintOfflineDebug # Android lint (CI runs this on every PR)
|
||||
```
|
||||
|
||||
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
|
||||
|
||||
@@ -20,6 +20,14 @@ required.</p>
|
||||
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" alt="Home screen: today's progress, overdue and upcoming tasks, and your lists" width="18%">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" alt="Onboarding: sync with your own CalDAV server" width="18%">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" alt="Upcoming list with a task expanded into its subtasks" width="18%">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" alt="Task detail: a monthly recurring task with a reminder and priority" width="18%">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" alt="Reminder notification with Done and Snooze actions" width="18%">
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
|
||||
@@ -111,6 +119,20 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository
|
||||
serves. For automatic updates from there, use
|
||||
**[Obtainium](https://github.com/ImranR98/Obtainium)** and
|
||||
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
|
||||
Each release carries two APKs (see [Offline version](#offline-version)), so set
|
||||
*Filter APKs by regular expression* to `^agendula_v` to always get the regular
|
||||
app.
|
||||
Betas of upcoming versions are published there too, as pre-releases; to test
|
||||
them, switch on *Include prereleases* for Agendula in Obtainium.
|
||||
|
||||
### Offline version
|
||||
|
||||
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
|
||||
without CalDAV sync and **without the network permission**, so it cannot
|
||||
connect to anything. It installs alongside the regular app rather than replacing
|
||||
it; move tasks between the two with Settings → Storage → Export / Import. In
|
||||
Obtainium, pick it with *Filter APKs by regular expression* set to
|
||||
`^agendula-offline_v`.
|
||||
|
||||
### Build from source
|
||||
|
||||
|
||||
+60
-11
@@ -1,3 +1,4 @@
|
||||
import com.android.build.api.artifact.SingleArtifact
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
@@ -26,12 +27,14 @@ android {
|
||||
// These committed values ARE the source of truth for a release: merging
|
||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 1.0.0 -> 10000). Releases were flagged as
|
||||
// pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the
|
||||
// pipeline graduates it on its own. See docs/RELEASING.md.
|
||||
versionCode = 10000
|
||||
versionName = "1.0.0"
|
||||
// release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
|
||||
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
|
||||
// versionCode is derived from versionName by scripts/version_info.sh
|
||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
||||
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 1010002
|
||||
versionName = "1.1.0-beta.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -44,6 +47,20 @@ android {
|
||||
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
|
||||
}
|
||||
|
||||
// `offline` (#39): no sync code, network libraries or network permission.
|
||||
flavorDimensions += "network"
|
||||
productFlavors {
|
||||
create("full") {
|
||||
dimension = "network"
|
||||
buildConfigField("boolean", "SYNC_ENABLED", "true")
|
||||
}
|
||||
create("offline") {
|
||||
dimension = "network"
|
||||
applicationIdSuffix = ".offline"
|
||||
buildConfigField("boolean", "SYNC_ENABLED", "false")
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
@@ -206,26 +223,26 @@ dependencies {
|
||||
// @HiltWorker plumbing.
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
// Custom Tabs: the Nextcloud login flow hands the browser an approval page.
|
||||
implementation(libs.androidx.browser)
|
||||
"fullImplementation"(libs.androidx.browser)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
|
||||
// Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages.
|
||||
implementation(libs.unifiedpush.connector)
|
||||
"fullImplementation"(libs.unifiedpush.connector)
|
||||
|
||||
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
|
||||
implementation(libs.dmfs.lib.recur)
|
||||
|
||||
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
|
||||
implementation(project(":dav"))
|
||||
"fullImplementation"(project(":dav"))
|
||||
// Discovery, auth and Nextcloud Login Flow v2.
|
||||
implementation(project(":caldav"))
|
||||
"fullImplementation"(project(":caldav"))
|
||||
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
|
||||
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
|
||||
// with no framework, and android.jar's stub factory returns null under
|
||||
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a
|
||||
// real implementation here.
|
||||
testImplementation(libs.xpp3)
|
||||
"testFullImplementation"(libs.xpp3)
|
||||
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
@@ -244,6 +261,7 @@ dependencies {
|
||||
implementation(libs.floret.core.crash)
|
||||
implementation(libs.floret.identity)
|
||||
implementation(libs.floret.components)
|
||||
implementation(libs.floret.glance)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
@@ -263,3 +281,34 @@ dependencies {
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
}
|
||||
|
||||
/** Fails the build if the offline flavor's merged manifest asks for the network. */
|
||||
abstract class VerifyNoNetworkPermissions : DefaultTask() {
|
||||
@get:InputFile
|
||||
@get:PathSensitive(PathSensitivity.NONE)
|
||||
abstract val manifest: RegularFileProperty
|
||||
|
||||
@TaskAction
|
||||
fun verify() {
|
||||
val text = manifest.get().asFile.readText()
|
||||
val found = listOf("INTERNET", "ACCESS_NETWORK_STATE", "ACCESS_WIFI_STATE", "CHANGE_NETWORK_STATE")
|
||||
.filter { "\"android.permission.$it\"" in text }
|
||||
if (found.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"The offline flavor's merged manifest declares ${found.joinToString()}. " +
|
||||
"Remove it in app/src/offline/AndroidManifest.xml with tools:node=\"remove\".",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withFlavor("network" to "offline")) { variant ->
|
||||
val name = variant.name.replaceFirstChar { it.uppercase() }
|
||||
val verify = tasks.register<VerifyNoNetworkPermissions>("verify${name}Manifest") {
|
||||
manifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST))
|
||||
}
|
||||
tasks.matching { it.name in setOf("package$name", "package${name}Bundle", "check") }
|
||||
.configureEach { dependsOn(verify) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- CalDAV sync and UnifiedPush; everything here needs the network. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
|
||||
but it shows in F-Droid's permission diff, so declare it deliberately
|
||||
rather than letting it appear from nowhere.
|
||||
|
||||
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
|
||||
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
|
||||
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
|
||||
(whose failure mode is a fatal RemoteServiceException) and a Play
|
||||
requirement for a video demo.
|
||||
|
||||
Two more permissions appear in the merged manifest without being
|
||||
declared here, and both come from work-runtime: WAKE_LOCK, and
|
||||
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
|
||||
API 31 WorkManager implements expedited work with a foreground service,
|
||||
and minSdk is 29, so it is load-bearing for the "Sync now" button.
|
||||
Removing it with tools:node="remove" would break expedited work on
|
||||
exactly the older devices that need it most. Noted because it shows in
|
||||
F-Droid's permission diff and would otherwise look unexplained. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||
|
||||
<queries>
|
||||
<!-- Custom Tabs provider detection. Without this entry it silently finds
|
||||
nothing on API 30+, and the Nextcloud login flow falls back to an
|
||||
external browser for no visible reason. -->
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<!-- Sync plumbing. The stub provider exists only to give the sync
|
||||
adapter an authority to register against: Agendula publishes no real
|
||||
ContentProvider since :provider was deleted, and without an authority
|
||||
ContentService.hasAuthorityAccess() makes every ContentResolver sync
|
||||
call a silent no-op at targetSdk >= 34. -->
|
||||
<provider
|
||||
android:name=".data.sync.SyncStubProvider"
|
||||
android:authorities="${applicationId}.sync"
|
||||
android:exported="false"
|
||||
android:syncable="true" />
|
||||
|
||||
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
|
||||
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
|
||||
<service
|
||||
android:name=".data.sync.AuthenticatorService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.ACCOUNT_MANAGER">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/authenticator" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".data.sync.SyncAdapterService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_SYNC_ADAPTER">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_adapter" />
|
||||
</service>
|
||||
|
||||
<!-- UnifiedPush: the connector binds this to deliver endpoints and
|
||||
WebDAV-Push messages. Not exported; the connector's own receiver is
|
||||
what distributors talk to. -->
|
||||
<service
|
||||
android:name=".data.sync.push.AgendulaPushService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,131 @@
|
||||
package de.jeanlucmakiola.agendula.data.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
||||
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
||||
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncRequests
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** See [CredentialsDataStore] for why this is a separate file. */
|
||||
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = CREDENTIALS_DATASTORE,
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/** See [SyncStateDataStore] for why this is a separate file. */
|
||||
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = SYNC_STATE_DATASTORE,
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
|
||||
*/
|
||||
const val CREDENTIALS_DATASTORE = "agendula_credentials"
|
||||
|
||||
/**
|
||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
|
||||
*/
|
||||
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
|
||||
|
||||
/** CalDAV sync: the `full` flavor's side of the seams `main` declares. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class SyncBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindRemoteLists(impl: RemoteListRepository): RemoteLists
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
|
||||
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object SyncProvideModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@CredentialsDataStore
|
||||
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.credentialsDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@SyncStateDataStore
|
||||
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.syncStateDataStore
|
||||
|
||||
/**
|
||||
* Sync hard on app open: the periodic worker's interval is a floor, and in
|
||||
* the `rare` and `restricted` App Standby buckets it may not have run at
|
||||
* all. `KEEP` makes rescheduling idempotent, so this also repairs a schedule
|
||||
* lost to "clear app data" or to a restore.
|
||||
*/
|
||||
@Provides
|
||||
@IntoSet
|
||||
fun syncOnOpenHook(
|
||||
accounts: Provider<AccountRepository>,
|
||||
syncTrigger: Provider<SyncTrigger>,
|
||||
pendingLoginFlows: Provider<PendingLoginFlowStore>,
|
||||
push: Provider<PushRegistrar>,
|
||||
): LaunchHook = LaunchHook {
|
||||
runCatching {
|
||||
accounts.get().rescheduleAll()
|
||||
accounts.get().syncable().forEach { syncTrigger.get().enqueue(it.displayName) }
|
||||
// A login flow the previous process died in the middle of.
|
||||
// Its password, if the user approved, exists nowhere else.
|
||||
pendingLoginFlows.get().reclaim()
|
||||
}
|
||||
// Last and on its own: it waits on the network, and must not hold up
|
||||
// the reclaim above. Re-registering on open is what the connector
|
||||
// recommends.
|
||||
runCatching { push.get().updateAll() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.jeanlucmakiola.agendula.data.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/**
|
||||
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
|
||||
*
|
||||
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
|
||||
* `datastore/`, and a restored ciphertext is permanently undecryptable because
|
||||
* Keystore keys are non-exportable. Its own file is what lets the backup rules
|
||||
* exclude the credentials and nothing else — excluding the whole database or
|
||||
* all of DataStore would trade a latent bug for a live one.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class CredentialsDataStore
|
||||
|
||||
/**
|
||||
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
|
||||
* counters and the full-reconciliation clock.
|
||||
*
|
||||
* Its own file for the same reason the credentials have one: Auto Backup
|
||||
* includes `datastore/`, and every value in here is a statement about *this*
|
||||
* device's conversation with a server. Restored onto a new install they are all
|
||||
* lies, and two of them are dangerous — a restored "reconciled recently" makes
|
||||
* the engine trust a sync token for another day, which is precisely the silently
|
||||
* pruned change log the full path exists to catch, and a restored quarantine
|
||||
* count silently skips resources that were never tried here.
|
||||
*
|
||||
* Not user data, so nothing is lost by excluding it.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class SyncStateDataStore
|
||||
+6
-36
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
@@ -53,38 +54,7 @@ class RemoteListRepository @Inject constructor(
|
||||
private val resolver: ProviderResolver,
|
||||
private val push: PushStore,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
/** Why a collection write did not happen, in a form the UI can translate. */
|
||||
sealed interface Outcome {
|
||||
data object Done : Outcome
|
||||
|
||||
/** The server said no, and will say no again. */
|
||||
data class Refused(val code: Int) : Outcome
|
||||
|
||||
/** The server could not be reached. Worth another try. */
|
||||
data object Unreachable : Outcome
|
||||
|
||||
/** This account cannot make collections at all — iCloud, Posteo, Google. */
|
||||
data object Unsupported : Outcome
|
||||
|
||||
/** Ours is a read-only share; the write belongs to whoever owns it. */
|
||||
data object ReadOnly : Outcome
|
||||
|
||||
/** The account is gone, stopped, or has no credential we can decrypt. */
|
||||
data object NoAccount : Outcome
|
||||
|
||||
/**
|
||||
* The server answered something this call cannot make sense of.
|
||||
*
|
||||
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
|
||||
* update and delete, so each of them has branches the other's method
|
||||
* can return and its own cannot — and mapping those to [Unreachable]
|
||||
* told someone sitting on wifi that they were offline. Unreachable is a
|
||||
* claim about the network, and this is not one.
|
||||
*/
|
||||
data object Unexpected : Outcome
|
||||
}
|
||||
) : RemoteLists {
|
||||
|
||||
/**
|
||||
* The accounts a new list may be created on, freshest answer first.
|
||||
@@ -95,7 +65,7 @@ class RemoteListRepository @Inject constructor(
|
||||
* able to say so, and the only moment that costs nothing is while the user
|
||||
* is looking at the picker.
|
||||
*/
|
||||
suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
|
||||
override suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
|
||||
// ⚠️ Empty in External mode, whatever the accounts table holds. The
|
||||
// lists on screen then come from a third-party provider, so a row
|
||||
// inserted into ours would exist, sync, and be visible to nobody.
|
||||
@@ -120,7 +90,7 @@ class RemoteListRepository @Inject constructor(
|
||||
*
|
||||
* @return the new list's local id, or why there is none.
|
||||
*/
|
||||
suspend fun create(
|
||||
override suspend fun create(
|
||||
accountId: Long,
|
||||
name: String,
|
||||
color: Int,
|
||||
@@ -189,7 +159,7 @@ class RemoteListRepository @Inject constructor(
|
||||
* a row that has already been renamed locally by then reads as a rename that
|
||||
* worked and then quietly reverted on the next sync.
|
||||
*/
|
||||
suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
|
||||
override suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
|
||||
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
|
||||
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
||||
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
||||
@@ -227,7 +197,7 @@ class RemoteListRepository @Inject constructor(
|
||||
* grades 404 and 410 that way — because otherwise a collection someone
|
||||
* removed from another client leaves a row here that nothing can get rid of.
|
||||
*/
|
||||
suspend fun delete(listId: Long): Outcome = withContext(io) {
|
||||
override suspend fun delete(listId: Long): Outcome = withContext(io) {
|
||||
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
|
||||
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
||||
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
||||
+4
-6
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -33,7 +34,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class SyncNoticeNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
) : ChannelRefresher {
|
||||
|
||||
fun canPost(): Boolean {
|
||||
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
@@ -123,7 +124,7 @@ class SyncNoticeNotifier @Inject constructor(
|
||||
* extra names the account for routing to Settings → Accounts → it.
|
||||
*/
|
||||
private fun signInIntent(accountId: Long): Intent =
|
||||
MainActivity.openIntent(context).putExtra(EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
|
||||
MainActivity.openIntent(context).putExtra(MainActivity.EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
|
||||
|
||||
private fun ensureSignInChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
@@ -170,16 +171,13 @@ class SyncNoticeNotifier @Inject constructor(
|
||||
}
|
||||
|
||||
/** Re-create the channel, if it exists, in the current language. */
|
||||
fun refreshChannel() {
|
||||
override fun refreshChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The account a sign-in notification is about, on the intent it opens. */
|
||||
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
|
||||
|
||||
private const val CHANNEL_ID = "sync_notices"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
|
||||
+5
-1
@@ -35,7 +35,11 @@ import kotlin.time.Duration.Companion.seconds
|
||||
@Singleton
|
||||
class SyncTrigger @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
) : SyncRequests {
|
||||
|
||||
override fun syncNow(accountName: String) {
|
||||
enqueue(accountName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a sync now.
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.jeanlucmakiola.agendula.ui.onboarding
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
||||
|
||||
/** The inline wizard's shortest length, before a provider is picked. */
|
||||
internal const val ACCOUNT_STEP_SLOTS = ADD_ACCOUNT_STEPS
|
||||
|
||||
/** [OnboardingStep.Account]: the add-account wizard, given the whole screen. */
|
||||
@Composable
|
||||
internal fun AccountStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
|
||||
// The same instance the wizard resolves for itself, so its length can be
|
||||
// read here without keeping a second copy of its state.
|
||||
val account: AddAccountViewModel = hiltViewModel()
|
||||
val accountState by account.state.collectAsStateWithLifecycle()
|
||||
// ⚠️ The wizard grows a step for a provider that needs an app password
|
||||
// minted first, and the outer bar has to grow with it — otherwise first
|
||||
// run counts one flow while the screen inside it counts another.
|
||||
LaunchedEffect(accountState.totalSteps) {
|
||||
viewModel.onAccountStepsChanged(accountState.totalSteps)
|
||||
}
|
||||
AddAccountScreen(
|
||||
onDone = { viewModel.onAccountFinished(added = true) },
|
||||
onBack = { viewModel.onAccountFinished(added = false) },
|
||||
stepOffset = state.position - 1,
|
||||
totalSteps = state.total,
|
||||
viewModel = account,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.jeanlucmakiola.agendula.ui.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
||||
|
||||
/** Settings → Accounts and the screens under it, layered over the hub. */
|
||||
@Composable
|
||||
internal fun AccountSections(
|
||||
section: SettingsSection?,
|
||||
initialAccountId: Long?,
|
||||
onSection: (SettingsSection?) -> Unit,
|
||||
) {
|
||||
// Hoisted so the add flow can refresh the list it returns to.
|
||||
val accountsViewModel: AccountsViewModel = hiltViewModel()
|
||||
// Shared with the add flow's own lookup, so "sign in again" can prefill it.
|
||||
val addAccountViewModel: AddAccountViewModel = hiltViewModel()
|
||||
val signInAgain: (AccountEntity) -> Unit = { account ->
|
||||
addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username)
|
||||
onSection(SettingsSection.AddAccount)
|
||||
}
|
||||
// Which account the detail screen is showing; the section alone cannot say.
|
||||
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
|
||||
|
||||
// Accounts stays composed under Add account, for the same reason Storage
|
||||
// stays composed under Export: the deeper screen slides over it.
|
||||
val accountsOpen = section == SettingsSection.Accounts ||
|
||||
section?.parent == SettingsSection.Accounts
|
||||
SlideInSection(visible = accountsOpen) {
|
||||
AccountsScreen(
|
||||
onAddAccount = {
|
||||
// A sign-in abandoned half-way must not turn this into one.
|
||||
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
|
||||
onSection(SettingsSection.AddAccount)
|
||||
},
|
||||
onSignInAgain = signInAgain,
|
||||
onOpenAccount = {
|
||||
openAccount = it
|
||||
onSection(SettingsSection.Account)
|
||||
},
|
||||
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||
onBack = { onSection(null) },
|
||||
viewModel = accountsViewModel,
|
||||
)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Account) {
|
||||
openAccount?.let { id ->
|
||||
AccountDetailScreen(
|
||||
accountId = id,
|
||||
onBack = { onSection(SettingsSection.Accounts) },
|
||||
onRemoved = { onSection(SettingsSection.Accounts) },
|
||||
onSignInAgain = signInAgain,
|
||||
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||
viewModel = accountsViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.AddAccount) {
|
||||
AddAccountScreen(
|
||||
// The list stays composed underneath, so nothing re-runs its
|
||||
// init and OnResume never fires on a section change. It no longer
|
||||
// needs to: the accounts come from an observed query, so a new
|
||||
// account — and every later sync — arrives on its own.
|
||||
onDone = { onSection(SettingsSection.Accounts) },
|
||||
onBack = { onSection(SettingsSection.Accounts) },
|
||||
viewModel = addAccountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,29 +16,6 @@
|
||||
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
||||
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
||||
|
||||
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
|
||||
but it shows in F-Droid's permission diff, so declare it deliberately
|
||||
rather than letting it appear from nowhere.
|
||||
|
||||
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
|
||||
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
|
||||
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
|
||||
(whose failure mode is a fatal RemoteServiceException) and a Play
|
||||
requirement for a video demo.
|
||||
|
||||
Two more permissions appear in the merged manifest without being
|
||||
declared here, and both come from work-runtime: WAKE_LOCK, and
|
||||
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
|
||||
API 31 WorkManager implements expedited work with a foreground service,
|
||||
and minSdk is 29, so it is load-bearing for the "Sync now" button.
|
||||
Removing it with tools:node="remove" would break expedited work on
|
||||
exactly the older devices that need it most. Noted because it shows in
|
||||
F-Droid's permission diff and would otherwise look unexplained. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for
|
||||
@@ -55,12 +32,6 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
<!-- Custom Tabs provider detection. Without this entry it silently finds
|
||||
nothing on API 30+, and the Nextcloud login flow falls back to an
|
||||
external browser for no visible reason. -->
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
@@ -71,7 +42,6 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Agendula"
|
||||
@@ -185,6 +155,58 @@
|
||||
android:resource="@xml/appwidget_info_tasks" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Today" home-screen widget (Glance): a progress ring for today's tasks. -->
|
||||
<receiver
|
||||
android:name=".widget.TodayWidgetReceiver"
|
||||
android:label="@string/widget_today_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_today" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Up next" home-screen widget (Glance): the single nearest due task. -->
|
||||
<receiver
|
||||
android:name=".widget.UpNextWidgetReceiver"
|
||||
android:label="@string/widget_up_next_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_up_next" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Lists" home-screen widget (Glance): one shortcut tile per list. -->
|
||||
<receiver
|
||||
android:name=".widget.ListsWidgetReceiver"
|
||||
android:label="@string/widget_lists_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_lists" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Week" home-screen widget (Glance): a seven-day strip of task counts. -->
|
||||
<receiver
|
||||
android:name=".widget.WeekWidgetReceiver"
|
||||
android:label="@string/widget_week_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_week" />
|
||||
</receiver>
|
||||
|
||||
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
|
||||
<activity
|
||||
android:name=".widget.TaskWidgetConfigActivity"
|
||||
@@ -211,54 +233,6 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Sync plumbing. The stub provider exists only to give the sync
|
||||
adapter an authority to register against: Agendula publishes no real
|
||||
ContentProvider since :provider was deleted, and without an authority
|
||||
ContentService.hasAuthorityAccess() makes every ContentResolver sync
|
||||
call a silent no-op at targetSdk >= 34. -->
|
||||
<provider
|
||||
android:name=".data.sync.SyncStubProvider"
|
||||
android:authorities="${applicationId}.sync"
|
||||
android:exported="false"
|
||||
android:syncable="true" />
|
||||
|
||||
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
|
||||
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
|
||||
<service
|
||||
android:name=".data.sync.AuthenticatorService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.ACCOUNT_MANAGER">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/authenticator" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".data.sync.SyncAdapterService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_SYNC_ADAPTER">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_adapter" />
|
||||
</service>
|
||||
|
||||
<!-- UnifiedPush: the connector binds this to deliver endpoints and
|
||||
WebDAV-Push messages. Not exported; the connector's own receiver is
|
||||
what distributors talk to. -->
|
||||
<service
|
||||
android:name=".data.sync.push.AgendulaPushService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- WorkManager's on-demand initialisation. Removing the default
|
||||
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
|
||||
@HiltWorker workers can take injected dependencies. -->
|
||||
|
||||
@@ -10,10 +10,10 @@ import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
||||
@@ -105,7 +105,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
||||
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
||||
runCatching {
|
||||
entryPoint.taskNotifier().refreshChannel()
|
||||
entryPoint.syncNoticeNotifier().refreshChannel()
|
||||
entryPoint.channelRefreshers().forEach { it.refreshChannel() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
||||
fun applicationScope(): CoroutineScope
|
||||
fun databaseCheckpoint(): DatabaseCheckpoint
|
||||
fun taskNotifier(): TaskNotifier
|
||||
fun syncNoticeNotifier(): SyncNoticeNotifier
|
||||
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
|
||||
fun taskWidgetUpdater(): TaskWidgetUpdater
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.agendula.data.di.LaunchHook
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
|
||||
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
||||
@@ -33,10 +32,6 @@ import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
|
||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
@@ -55,14 +50,6 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
|
||||
|
||||
@Inject lateinit var accounts: AccountRepository
|
||||
|
||||
@Inject lateinit var syncTrigger: SyncTrigger
|
||||
|
||||
@Inject lateinit var pendingLoginFlows: PendingLoginFlowStore
|
||||
|
||||
@Inject lateinit var push: PushRegistrar
|
||||
|
||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||
// over the app on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -94,34 +81,9 @@ class MainActivity : ComponentActivity() {
|
||||
// Surface a single captured crash as a dialog on the next launch.
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
|
||||
// Sync hard on app open: the periodic worker's interval is a floor, and
|
||||
// in the `rare` and `restricted` App Standby buckets it may not have run
|
||||
// at all. `KEEP` makes rescheduling idempotent, so this also repairs a
|
||||
// schedule lost to "clear app data" or to a restore.
|
||||
//
|
||||
// ⚠️ Only on a genuine open. This activity declares no `configChanges`,
|
||||
// so onCreate runs again on every rotation, theme switch, locale change
|
||||
// and font-scale change — each of which would otherwise start a fresh
|
||||
// network sync the moment the previous one finished.
|
||||
// Each hook on its own, so one waiting on the network holds up no other.
|
||||
if (savedInstanceState == null) {
|
||||
lifecycleScope.launch {
|
||||
runCatching {
|
||||
accounts.rescheduleAll()
|
||||
accounts.syncable().forEach { syncTrigger.enqueue(it.displayName) }
|
||||
// A login flow the previous process died in the middle of.
|
||||
// Its password, if the user approved, exists nowhere else.
|
||||
pendingLoginFlows.reclaim()
|
||||
}
|
||||
// Last and on its own: it waits on the network, and must not
|
||||
// hold up the reclaim above. Re-registering on open is what
|
||||
// the connector recommends.
|
||||
runCatching { push.updateAll() }
|
||||
}
|
||||
}
|
||||
|
||||
// Variant hooks: the demo seeder in debug builds, nothing in release.
|
||||
if (savedInstanceState == null) {
|
||||
lifecycleScope.launch { launchHooks.forEach { runCatching { it.onLaunch(intent) } } }
|
||||
launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } }
|
||||
}
|
||||
setContent {
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
@@ -180,12 +142,16 @@ class MainActivity : ComponentActivity() {
|
||||
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
|
||||
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
|
||||
|
||||
/** The account a sign-in notification is about, on the intent it opens. */
|
||||
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
|
||||
const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
|
||||
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
|
||||
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
|
||||
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
|
||||
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
||||
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
||||
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
||||
private const val SHARED_TITLE_LIMIT = 500
|
||||
private const val NO_OCCURRENCE = -1L
|
||||
|
||||
@@ -206,9 +172,14 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
/** The launcher shortcut's and Quick Settings tile's "New task". */
|
||||
fun newTaskIntent(context: Context): Intent =
|
||||
Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java)
|
||||
/**
|
||||
* The launcher shortcut's, Quick Settings tile's and a widget's "New
|
||||
* task". [listId] preset the list when it names exactly one; the data
|
||||
* URI keeps each widget's PendingIntent apart from the others.
|
||||
*/
|
||||
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
|
||||
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
|
||||
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
fun todayIntent(context: Context): Intent =
|
||||
@@ -229,11 +200,15 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
internal fun navRequestOf(intent: Intent?): NavRequest? {
|
||||
if (intent == null) return null
|
||||
intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
|
||||
?.let { return NavRequest.OpenAccount(it) }
|
||||
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
||||
if (BuildConfig.SYNC_ENABLED) {
|
||||
intent.getLongExtra(EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
|
||||
?.let { return NavRequest.OpenAccount(it) }
|
||||
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
||||
}
|
||||
when (intent.action) {
|
||||
ACTION_NEW_TASK -> return NavRequest.NewTask()
|
||||
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
||||
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
||||
)
|
||||
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
||||
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
||||
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.agendula.data.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.Multibinds
|
||||
|
||||
/**
|
||||
* A notification channel a build variant adds, re-created in the current
|
||||
* language on start and on a configuration change. The `full` flavor
|
||||
* contributes the sync notices; `offline` has none.
|
||||
*/
|
||||
fun interface ChannelRefresher {
|
||||
fun refreshChannel()
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ChannelRefresherModule {
|
||||
@Multibinds
|
||||
abstract fun channelRefreshers(): Set<ChannelRefresher>
|
||||
}
|
||||
@@ -12,13 +12,6 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
||||
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
|
||||
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
|
||||
@@ -27,7 +20,6 @@ import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
@@ -51,37 +43,13 @@ import javax.inject.Singleton
|
||||
* reconciled", and the credential store to accounts that ask to be signed in
|
||||
* again — all states the app already knows how to be in, unlike a launch loop.
|
||||
*/
|
||||
private fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
|
||||
internal fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
|
||||
|
||||
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "agendula_prefs",
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/** See [CredentialsDataStore] for why this is a separate file. */
|
||||
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = CREDENTIALS_DATASTORE,
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/** See [SyncStateDataStore] for why this is a separate file. */
|
||||
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = SYNC_STATE_DATASTORE,
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
|
||||
*/
|
||||
const val CREDENTIALS_DATASTORE = "agendula_credentials"
|
||||
|
||||
/**
|
||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
|
||||
*/
|
||||
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DataBindModule {
|
||||
@@ -94,22 +62,6 @@ abstract class DataBindModule {
|
||||
@Singleton
|
||||
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
|
||||
|
||||
// Deliberately unqualified-free of the routing above: this is the external
|
||||
// store itself, for the one caller that has to read it while another store is
|
||||
// the active one.
|
||||
@@ -128,18 +80,6 @@ object DataProvideModule {
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.agendulaDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@CredentialsDataStore
|
||||
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.credentialsDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@SyncStateDataStore
|
||||
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.syncStateDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
|
||||
|
||||
@@ -7,9 +7,8 @@ import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.Multibinds
|
||||
|
||||
/**
|
||||
* Something a build variant wants to run when the app is launched with [Intent].
|
||||
* Release builds contribute none; the debug source set adds the demo seeder, so
|
||||
* that class never ships.
|
||||
* Something a build variant runs on a genuine app open (not on a configuration
|
||||
* change): sync in the `full` flavor, the demo seeder in debug builds.
|
||||
*/
|
||||
fun interface LaunchHook {
|
||||
suspend fun onLaunch(intent: Intent)
|
||||
|
||||
@@ -17,37 +17,6 @@ annotation class IoDispatcher
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ApplicationScope
|
||||
|
||||
/**
|
||||
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
|
||||
*
|
||||
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
|
||||
* `datastore/`, and a restored ciphertext is permanently undecryptable because
|
||||
* Keystore keys are non-exportable. Its own file is what lets the backup rules
|
||||
* exclude the credentials and nothing else — excluding the whole database or
|
||||
* all of DataStore would trade a latent bug for a live one.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class CredentialsDataStore
|
||||
|
||||
/**
|
||||
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
|
||||
* counters and the full-reconciliation clock.
|
||||
*
|
||||
* Its own file for the same reason the credentials have one: Auto Backup
|
||||
* includes `datastore/`, and every value in here is a statement about *this*
|
||||
* device's conversation with a server. Restored onto a new install they are all
|
||||
* lies, and two of them are dangerous — a restored "reconciled recently" makes
|
||||
* the engine trust a sync token for another day, which is precisely the silently
|
||||
* pruned change log the full path exists to catch, and a restored quarantine
|
||||
* count silently skips resources that were never tried here.
|
||||
*
|
||||
* Not user data, so nothing is lost by excluding it.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class SyncStateDataStore
|
||||
|
||||
/**
|
||||
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
|
||||
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
|
||||
|
||||
@@ -95,6 +95,8 @@ data class Settings(
|
||||
val pushEnabled: Boolean = true,
|
||||
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
|
||||
val hiddenFromSmartLists: Set<Long> = emptySet(),
|
||||
/** Pre-fill a new task's start with today's date. */
|
||||
val defaultStartToday: Boolean = false,
|
||||
) {
|
||||
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
|
||||
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
||||
@@ -153,6 +155,7 @@ class SettingsPrefs @Inject constructor(
|
||||
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
|
||||
.toSet()
|
||||
},
|
||||
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,6 +169,8 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
|
||||
|
||||
suspend fun setDefaultStartToday(enabled: Boolean) = dataStore.edit { it[DEFAULT_START_TODAY] = enabled }
|
||||
|
||||
suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
|
||||
|
||||
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
|
||||
@@ -282,6 +287,7 @@ class SettingsPrefs @Inject constructor(
|
||||
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
|
||||
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
||||
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
||||
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
|
||||
/**
|
||||
* Task lists that live on a server: making, renaming, recolouring and deleting
|
||||
* them. The `full` flavor talks CalDAV; the `offline` one has no accounts, so
|
||||
* every list is device-only and none of this is ever reached with a real one.
|
||||
*/
|
||||
interface RemoteLists {
|
||||
|
||||
/** Why a collection write did not happen, in a form the UI can translate. */
|
||||
sealed interface Outcome {
|
||||
data object Done : Outcome
|
||||
|
||||
/** The server said no, and will say no again. */
|
||||
data class Refused(val code: Int) : Outcome
|
||||
|
||||
/** The server could not be reached. Worth another try. */
|
||||
data object Unreachable : Outcome
|
||||
|
||||
/** This account cannot make collections at all — iCloud, Posteo, Google. */
|
||||
data object Unsupported : Outcome
|
||||
|
||||
/** Ours is a read-only share; the write belongs to whoever owns it. */
|
||||
data object ReadOnly : Outcome
|
||||
|
||||
/** The account is gone, stopped, or has no credential we can decrypt. */
|
||||
data object NoAccount : Outcome
|
||||
|
||||
/**
|
||||
* The server answered something this call cannot make sense of.
|
||||
*
|
||||
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
|
||||
* update and delete, so each of them has branches the other's method
|
||||
* can return and its own cannot — and mapping those to [Unreachable]
|
||||
* told someone sitting on wifi that they were offline. Unreachable is a
|
||||
* claim about the network, and this is not one.
|
||||
*/
|
||||
data object Unexpected : Outcome
|
||||
}
|
||||
|
||||
/** The accounts a new list may be created on. */
|
||||
suspend fun creatableAccounts(): List<AccountEntity>
|
||||
|
||||
/** Makes a collection on [accountId]'s home set and a row pointing at it. */
|
||||
suspend fun create(accountId: Long, name: String, color: Int): Outcome
|
||||
|
||||
suspend fun rename(listId: Long, name: String, color: Int): Outcome
|
||||
|
||||
suspend fun delete(listId: Long): Outcome
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
|
||||
fun interface SyncRequests {
|
||||
fun syncNow(accountName: String)
|
||||
}
|
||||
@@ -63,6 +63,9 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
// parent's subtask-progress reflects ALL its children — completed ones
|
||||
// included — even on smart lists that hide completed tasks themselves.
|
||||
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
|
||||
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
|
||||
// several lists are read in full and membership is filtered in-memory.
|
||||
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
|
||||
}
|
||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||
val all = SeriesCollapse.visible(dataSource.tasks(query))
|
||||
|
||||
@@ -71,7 +71,7 @@ data class TaskListEntity(
|
||||
*
|
||||
* ⚠️ It was set by a rename and read by nobody. A collection's name and
|
||||
* colour are now written server-first by
|
||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], so there is
|
||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteLists], so there is
|
||||
* no local edit left waiting to be pushed — and a flag that means "owed to
|
||||
* the server" while nothing ever pays it is worse than no flag at all.
|
||||
*/
|
||||
|
||||
@@ -520,7 +520,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
|
||||
/**
|
||||
* ⚠️ Device-only lists. An account-backed collection is renamed through
|
||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], which sends
|
||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteLists], which sends
|
||||
* the PROPPATCH first and writes here only once the server has taken it.
|
||||
*
|
||||
* This used to set `is_dirty` for an account list instead, on the theory
|
||||
|
||||
@@ -8,6 +8,8 @@ enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
|
||||
/** What a task screen is showing: one real list, or a smart list. */
|
||||
sealed interface TaskFilter {
|
||||
data class OfList(val listId: Long) : TaskFilter
|
||||
/** Several lists shown together, e.g. one widget covering more than one list. */
|
||||
data class OfLists(val listIds: Set<Long>) : TaskFilter
|
||||
data class Smart(val list: SmartList) : TaskFilter
|
||||
}
|
||||
|
||||
@@ -21,6 +23,7 @@ object TaskFiltering {
|
||||
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
|
||||
when (filter) {
|
||||
is TaskFilter.OfList -> task.listId == filter.listId
|
||||
is TaskFilter.OfLists -> task.listId in filter.listIds
|
||||
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -122,10 +123,11 @@ class TaskEditViewModel @Inject constructor(
|
||||
val settings = settingsPrefs.settings.first()
|
||||
defaultFields = settings.defaultEditFields
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
// A read-only or vanished default would give the task a list it
|
||||
// A read-only or vanished default — or a widget's preset list, if it was
|
||||
// since deleted or turned read-only — would give the task a list it
|
||||
// cannot be saved to.
|
||||
val writable = lists.filter { it.acceptsWrites }
|
||||
val defaultList = presetListId
|
||||
val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } }
|
||||
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } }
|
||||
?: writable.firstOrNull { !it.isLocal }?.id
|
||||
?: writable.firstOrNull()?.id
|
||||
@@ -135,6 +137,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
isNew = true,
|
||||
autofocusTitle = settings.autofocusTitle,
|
||||
listId = defaultList,
|
||||
start = if (settings.defaultStartToday) Clock.System.now() else null,
|
||||
parentId = parentId,
|
||||
lists = lists,
|
||||
parentCandidates = loadParents(defaultList, selfId = null),
|
||||
|
||||
@@ -4,7 +4,7 @@ import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncRequests
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
@@ -76,7 +76,7 @@ class ImportViewModel @Inject constructor(
|
||||
repository: TasksRepository,
|
||||
private val resolver: ProviderResolver,
|
||||
private val importer: IcsImport,
|
||||
private val syncTrigger: SyncTrigger,
|
||||
private val syncRequests: SyncRequests,
|
||||
) : ViewModel() {
|
||||
|
||||
private val ownStore = MutableStateFlow(resolver.mode() == StorageMode.OWN)
|
||||
@@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor(
|
||||
val result = importer.import(ready.parsed, target)
|
||||
(chosen as? ImportChoice.Existing)?.list
|
||||
?.takeIf { it.accountId != null && result.imported > 0 }
|
||||
?.let { runCatching { syncTrigger.enqueue(it.accountName) } }
|
||||
?.let { runCatching { syncRequests.syncNow(it.accountName) } }
|
||||
ImportStep.Done(
|
||||
listName = listName,
|
||||
imported = result.imported,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.agendula.ui.licences
|
||||
|
||||
import de.jeanlucmakiola.agendula.BuildConfig
|
||||
|
||||
/**
|
||||
* The third-party code Agendula ships, and what each licence obliges us to say.
|
||||
*
|
||||
@@ -30,6 +32,8 @@ data class Attribution(
|
||||
val licence: Licence,
|
||||
/** Where the source can actually be obtained — MPL-2.0 §3.2(a)'s requirement. */
|
||||
val sourceUrl: String,
|
||||
/** Only in the `full` flavor, which carries CalDAV sync and push. */
|
||||
val syncOnly: Boolean = false,
|
||||
)
|
||||
|
||||
enum class Licence(val spdxId: String, val url: String) {
|
||||
@@ -54,30 +58,35 @@ object OpenSourceLicences {
|
||||
copyright = "© bitfire web engineering (Ricki Hirner, Bernhard Stockmann)",
|
||||
licence = Licence.MPL_2,
|
||||
sourceUrl = "https://github.com/bitfireAT/dav4jvm",
|
||||
syncOnly = true,
|
||||
),
|
||||
Attribution(
|
||||
name = "dnsjava",
|
||||
copyright = "© Brian Wellington and the dnsjava contributors",
|
||||
licence = Licence.BSD_3,
|
||||
sourceUrl = "https://github.com/dnsjava/dnsjava",
|
||||
syncOnly = true,
|
||||
),
|
||||
Attribution(
|
||||
name = "UnifiedPush Android connector",
|
||||
copyright = "© UnifiedPush contributors",
|
||||
licence = Licence.APACHE_2,
|
||||
sourceUrl = "https://codeberg.org/UnifiedPush/android-connector",
|
||||
syncOnly = true,
|
||||
),
|
||||
Attribution(
|
||||
name = "Tink",
|
||||
copyright = "© Google LLC",
|
||||
licence = Licence.APACHE_2,
|
||||
sourceUrl = "https://github.com/tink-crypto/tink-java",
|
||||
syncOnly = true,
|
||||
),
|
||||
Attribution(
|
||||
name = "OkHttp",
|
||||
copyright = "© Square, Inc.",
|
||||
licence = Licence.APACHE_2,
|
||||
sourceUrl = "https://github.com/square/okhttp",
|
||||
syncOnly = true,
|
||||
),
|
||||
Attribution(
|
||||
name = "Okio",
|
||||
@@ -121,5 +130,5 @@ object OpenSourceLicences {
|
||||
licence = Licence.MIT,
|
||||
sourceUrl = "https://codeberg.org/jlmakiola/floret-kit",
|
||||
),
|
||||
)
|
||||
).filter { BuildConfig.SYNC_ENABLED || !it.syncOnly }
|
||||
}
|
||||
|
||||
@@ -153,7 +153,6 @@ fun ListEditorSheet(
|
||||
GroupedRow(
|
||||
title = destination.label,
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
leading = { Icon(destinationIcon(destination), contentDescription = null) },
|
||||
trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) },
|
||||
onClick = { choosingDestination = true },
|
||||
@@ -176,7 +175,6 @@ fun ListEditorSheet(
|
||||
title = stringResource(R.string.list_in_smart_lists),
|
||||
summary = stringResource(R.string.list_in_smart_lists_hint),
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = !hiddenFromSmartLists,
|
||||
|
||||
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
@@ -54,19 +54,19 @@ enum class ListWriteFailure {
|
||||
}
|
||||
|
||||
/** Maps a remote outcome onto the wording the screens already know how to show. */
|
||||
internal fun RemoteListRepository.Outcome.asFailure(local: ListWriteFailure): ListWriteFailure? =
|
||||
internal fun RemoteLists.Outcome.asFailure(local: ListWriteFailure): ListWriteFailure? =
|
||||
when (this) {
|
||||
RemoteListRepository.Outcome.Done -> null
|
||||
is RemoteListRepository.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED
|
||||
RemoteListRepository.Outcome.Unreachable -> ListWriteFailure.OFFLINE
|
||||
RemoteListRepository.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED
|
||||
RemoteListRepository.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY
|
||||
RemoteLists.Outcome.Done -> null
|
||||
is RemoteLists.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED
|
||||
RemoteLists.Outcome.Unreachable -> ListWriteFailure.OFFLINE
|
||||
RemoteLists.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED
|
||||
RemoteLists.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY
|
||||
// The account is gone, stopped or undecryptable, or the server said
|
||||
// something this call cannot place. Nothing specific to say beyond
|
||||
// "that did not save", which is what [local] is — and specifically not
|
||||
// "you are offline", which neither of them is a claim about.
|
||||
RemoteListRepository.Outcome.NoAccount,
|
||||
RemoteListRepository.Outcome.Unexpected,
|
||||
RemoteLists.Outcome.NoAccount,
|
||||
RemoteLists.Outcome.Unexpected,
|
||||
-> local
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ private const val UPCOMING_PREVIEW = 3
|
||||
@HiltViewModel
|
||||
class ListsViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val remoteLists: RemoteListRepository,
|
||||
private val remoteLists: RemoteLists,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -215,7 +215,7 @@ class ListsViewModel @Inject constructor(
|
||||
// ⚠️ Server first. A row written before the MKCALENDAR would be a list
|
||||
// that exists on the phone and nowhere else, with nothing to say so.
|
||||
val outcome = runCatching { remoteLists.create(accountId, name.trim(), color) }
|
||||
.getOrElse { RemoteListRepository.Outcome.Unreachable }
|
||||
.getOrElse { RemoteLists.Outcome.Unreachable }
|
||||
_writeFailure.value = outcome.asFailure(ListWriteFailure.SAVE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ fun AgendulaNavHost(
|
||||
nav.navigate(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)) { launchSingleTop = true }
|
||||
is NavRequest.OpenAccount ->
|
||||
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
|
||||
is NavRequest.NewTask -> nav.navigate(Dest.TaskEdit.buildNew(title = navRequest.title))
|
||||
is NavRequest.NewTask ->
|
||||
nav.navigate(Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title))
|
||||
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ object Dest {
|
||||
fun build(filter: TaskFilter): String = when (filter) {
|
||||
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
|
||||
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
|
||||
// Several lists at once only exists for the widget, which never routes here.
|
||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||
}
|
||||
|
||||
/** Reconstruct the filter from a destination's arguments. */
|
||||
|
||||
@@ -18,8 +18,11 @@ sealed interface NavRequest {
|
||||
/** One account's detail — where "sign in again" lives. */
|
||||
data class OpenAccount(val accountId: Long) : NavRequest
|
||||
|
||||
/** A new task, optionally titled with text shared from another app. */
|
||||
data class NewTask(val title: String? = null) : NavRequest
|
||||
/**
|
||||
* A new task, optionally titled with text shared from another app, or
|
||||
* preset to a list — a widget's own add button, when it shows one list.
|
||||
*/
|
||||
data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
|
||||
|
||||
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
|
||||
data class OpenSmart(val list: SmartList) : NavRequest
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -24,8 +23,6 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
||||
import de.jeanlucmakiola.floret.components.OnboardingProgress
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
@@ -47,23 +44,7 @@ fun OnboardingFlow(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
if (state.step == OnboardingStep.Account) {
|
||||
// The same instance the wizard resolves for itself, so its length can be
|
||||
// read here without keeping a second copy of its state.
|
||||
val account: AddAccountViewModel = hiltViewModel()
|
||||
val accountState by account.state.collectAsStateWithLifecycle()
|
||||
// ⚠️ The wizard grows a step for a provider that needs an app password
|
||||
// minted first, and the outer bar has to grow with it — otherwise first
|
||||
// run counts one flow while the screen inside it counts another.
|
||||
LaunchedEffect(accountState.totalSteps) {
|
||||
viewModel.onAccountStepsChanged(accountState.totalSteps)
|
||||
}
|
||||
AddAccountScreen(
|
||||
onDone = { viewModel.onAccountFinished(added = true) },
|
||||
onBack = { viewModel.onAccountFinished(added = false) },
|
||||
stepOffset = state.position - 1,
|
||||
totalSteps = state.total,
|
||||
viewModel = account,
|
||||
)
|
||||
AccountStep(state, viewModel)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.BuildConfig
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS
|
||||
import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -93,7 +93,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
* provider is chosen, so the bar is honest from the first frame and stays
|
||||
* honest when the choice makes the flow longer.
|
||||
*/
|
||||
private val accountSlots = MutableStateFlow(ADD_ACCOUNT_STEPS)
|
||||
private val accountSlots = MutableStateFlow(ACCOUNT_STEP_SLOTS)
|
||||
|
||||
fun onAccountStepsChanged(count: Int) {
|
||||
accountSlots.value = count
|
||||
@@ -145,11 +145,11 @@ class OnboardingViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(enabled)
|
||||
needsExactAlarms.value = enabled && !context.canScheduleExactAlarms()
|
||||
advanceTo(if (needsExactAlarms.value) OnboardingStep.ExactAlarms else OnboardingStep.SyncOffer)
|
||||
advanceTo(if (needsExactAlarms.value) OnboardingStep.ExactAlarms else afterReminders())
|
||||
}
|
||||
}
|
||||
|
||||
fun onExactAlarmsDone() = advanceTo(OnboardingStep.SyncOffer)
|
||||
fun onExactAlarmsDone() = advanceTo(afterReminders())
|
||||
|
||||
fun onSyncAnswered(connect: Boolean) {
|
||||
connectsAccount.value = connect
|
||||
@@ -192,11 +192,9 @@ class OnboardingViewModel @Inject constructor(
|
||||
step.value = when (step.value) {
|
||||
OnboardingStep.Reminders -> OnboardingStep.Welcome
|
||||
OnboardingStep.ExactAlarms -> OnboardingStep.Reminders
|
||||
// Once granted there is nothing left to ask, so back skips the step.
|
||||
OnboardingStep.SyncOffer ->
|
||||
if (needsExactAlarms.value && !context.canScheduleExactAlarms()) OnboardingStep.ExactAlarms
|
||||
else OnboardingStep.Reminders
|
||||
OnboardingStep.FirstList -> OnboardingStep.SyncOffer
|
||||
OnboardingStep.SyncOffer -> beforeSyncOffer()
|
||||
OnboardingStep.FirstList ->
|
||||
if (BuildConfig.SYNC_ENABLED) OnboardingStep.SyncOffer else beforeSyncOffer()
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
@@ -230,6 +228,15 @@ class OnboardingViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Without sync built in there is nothing to offer, and reminders lead straight on. */
|
||||
private fun afterReminders(): OnboardingStep =
|
||||
if (BuildConfig.SYNC_ENABLED) OnboardingStep.SyncOffer else OnboardingStep.FirstList
|
||||
|
||||
/** Once granted there is nothing left to ask, so back skips the exact-alarm step. */
|
||||
private fun beforeSyncOffer(): OnboardingStep =
|
||||
if (needsExactAlarms.value && !context.canScheduleExactAlarms()) OnboardingStep.ExactAlarms
|
||||
else OnboardingStep.Reminders
|
||||
|
||||
private fun afterFirstList() {
|
||||
step.value = stepAfterFirstList()
|
||||
}
|
||||
@@ -241,7 +248,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
add(OnboardingStep.Welcome)
|
||||
add(OnboardingStep.Reminders)
|
||||
if (needsExact) add(OnboardingStep.ExactAlarms)
|
||||
add(OnboardingStep.SyncOffer)
|
||||
if (BuildConfig.SYNC_ENABLED) add(OnboardingStep.SyncOffer)
|
||||
if (sync) add(OnboardingStep.Account)
|
||||
if (needsList) add(OnboardingStep.FirstList)
|
||||
if (!sync) add(OnboardingStep.Backup)
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import androidx.compose.material.icons.rounded.CloudOff
|
||||
import androidx.compose.material.icons.rounded.CloudSync
|
||||
import androidx.compose.material.icons.rounded.NotificationsActive
|
||||
import androidx.compose.material3.Button
|
||||
@@ -14,6 +15,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.BuildConfig
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
@@ -56,10 +58,18 @@ internal fun WelcomeStep(chrome: StepChrome, onContinue: () -> Unit) {
|
||||
body = stringResource(R.string.onboarding_welcome_reminders_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Rounded.CloudSync,
|
||||
title = stringResource(R.string.onboarding_welcome_sync_title),
|
||||
body = stringResource(R.string.onboarding_welcome_sync_body),
|
||||
)
|
||||
if (BuildConfig.SYNC_ENABLED) {
|
||||
BenefitRow(
|
||||
icon = Icons.Rounded.CloudSync,
|
||||
title = stringResource(R.string.onboarding_welcome_sync_title),
|
||||
body = stringResource(R.string.onboarding_welcome_sync_body),
|
||||
)
|
||||
} else {
|
||||
BenefitRow(
|
||||
icon = Icons.Rounded.CloudOff,
|
||||
title = stringResource(R.string.onboarding_welcome_offline_title),
|
||||
body = stringResource(R.string.onboarding_welcome_offline_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,13 +40,8 @@ import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.BuildConfig
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.export.ExportScreen
|
||||
import de.jeanlucmakiola.agendula.ui.imports.ImportScreen
|
||||
import de.jeanlucmakiola.agendula.ui.licences.LicencesScreen
|
||||
@@ -64,7 +59,7 @@ import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
private enum class SettingsSection {
|
||||
internal enum class SettingsSection {
|
||||
Appearance,
|
||||
TaskForm,
|
||||
Reminders,
|
||||
@@ -111,16 +106,6 @@ fun SettingsScreen(
|
||||
else SettingsSection.entries.firstOrNull { it.name == initialSection },
|
||||
)
|
||||
}
|
||||
// Hoisted so the add flow can refresh the list it returns to.
|
||||
val accountsViewModel: AccountsViewModel = hiltViewModel()
|
||||
// Shared with the add flow's own lookup, so "sign in again" can prefill it.
|
||||
val addAccountViewModel: AddAccountViewModel = hiltViewModel()
|
||||
val signInAgain: (AccountEntity) -> Unit = { account ->
|
||||
addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username)
|
||||
section = SettingsSection.AddAccount
|
||||
}
|
||||
// Which account the detail screen is showing; the section alone cannot say.
|
||||
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
|
||||
|
||||
// Inside a sub-screen, system back (button or gesture) returns to the hub
|
||||
// rather than popping the whole Settings destination to the lists overview.
|
||||
@@ -159,58 +144,19 @@ fun SettingsScreen(
|
||||
SlideInSection(visible = section == SettingsSection.Import) {
|
||||
ImportScreen(onBack = { section = SettingsSection.Storage })
|
||||
}
|
||||
// Accounts stays composed under Add account, for the same reason Storage
|
||||
// stays composed under Export: the deeper screen slides over it.
|
||||
val accountsOpen = section == SettingsSection.Accounts ||
|
||||
section?.parent == SettingsSection.Accounts
|
||||
SlideInSection(visible = accountsOpen) {
|
||||
AccountsScreen(
|
||||
onAddAccount = {
|
||||
// A sign-in abandoned half-way must not turn this into one.
|
||||
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
|
||||
section = SettingsSection.AddAccount
|
||||
},
|
||||
onSignInAgain = signInAgain,
|
||||
onOpenAccount = {
|
||||
openAccount = it
|
||||
section = SettingsSection.Account
|
||||
},
|
||||
onOpenStorage = { section = SettingsSection.Storage },
|
||||
onBack = { section = null },
|
||||
viewModel = accountsViewModel,
|
||||
)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Licences) {
|
||||
LicencesScreen(onBack = { section = null })
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Account) {
|
||||
openAccount?.let { id ->
|
||||
AccountDetailScreen(
|
||||
accountId = id,
|
||||
onBack = { section = SettingsSection.Accounts },
|
||||
onRemoved = { section = SettingsSection.Accounts },
|
||||
onSignInAgain = signInAgain,
|
||||
onOpenStorage = { section = SettingsSection.Storage },
|
||||
viewModel = accountsViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.AddAccount) {
|
||||
AddAccountScreen(
|
||||
// The list stays composed underneath, so nothing re-runs its
|
||||
// init and OnResume never fires on a section change. It no longer
|
||||
// needs to: the accounts come from an observed query, so a new
|
||||
// account — and every later sync — arrives on its own.
|
||||
onDone = { section = SettingsSection.Accounts },
|
||||
onBack = { section = SettingsSection.Accounts },
|
||||
viewModel = addAccountViewModel,
|
||||
)
|
||||
}
|
||||
AccountSections(
|
||||
section = section,
|
||||
initialAccountId = initialAccountId,
|
||||
onSection = { section = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
|
||||
internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInHorizontally { it } + fadeIn(),
|
||||
@@ -267,17 +213,19 @@ private fun SettingsHub(
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_group_data))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_accounts),
|
||||
summary = stringResource(R.string.settings_accounts_subtitle),
|
||||
position = Position.Top,
|
||||
leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) },
|
||||
onClick = { onOpenSection(SettingsSection.Accounts) },
|
||||
)
|
||||
if (BuildConfig.SYNC_ENABLED) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_accounts),
|
||||
summary = stringResource(R.string.settings_accounts_subtitle),
|
||||
position = Position.Top,
|
||||
leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) },
|
||||
onClick = { onOpenSection(SettingsSection.Accounts) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_storage),
|
||||
summary = stringResource(R.string.settings_storage_subtitle),
|
||||
position = Position.Bottom,
|
||||
position = if (BuildConfig.SYNC_ENABLED) Position.Bottom else Position.Alone,
|
||||
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) },
|
||||
onClick = { onOpenSection(SettingsSection.Storage) },
|
||||
)
|
||||
|
||||
@@ -241,6 +241,7 @@ class SettingsViewModel @Inject constructor(
|
||||
fun setSnoozeMinutes(minutes: Int) = viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
|
||||
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
|
||||
fun setDefaultStartToday(enabled: Boolean) = viewModelScope.launch { prefs.setDefaultStartToday(enabled) }
|
||||
|
||||
/** Toggle whether [field] shows by default on a new task's edit form. */
|
||||
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
|
||||
|
||||
@@ -99,6 +99,18 @@ internal fun TaskFormScreen(
|
||||
},
|
||||
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_start_today),
|
||||
summary = stringResource(R.string.settings_default_start_today_hint),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.settings.defaultStartToday,
|
||||
onCheckedChange = viewModel::setDefaultStartToday,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setDefaultStartToday(!state.settings.defaultStartToday) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_bottom_add_bar),
|
||||
summary = stringResource(R.string.settings_bottom_add_bar_hint),
|
||||
|
||||
@@ -52,6 +52,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -139,9 +140,12 @@ fun TaskListScreen(
|
||||
val list = content?.list
|
||||
val listName = list?.name
|
||||
val listId = (filter as? TaskFilter.OfList)?.listId
|
||||
// Editing is offered for a device-only list. A collection that belongs to an
|
||||
// account is the server's to rename or remove, not ours.
|
||||
// Editing is offered for any writable list, device or synced — renaming and
|
||||
// recolouring a synced collection is a server write the same sheet already
|
||||
// handles. A read-only share keeps the icon but disables it: tapping it
|
||||
// explains why rather than the button silently not being there.
|
||||
var editingList by rememberSaveable { mutableStateOf(false) }
|
||||
var readOnlyHint by rememberSaveable { mutableStateOf(false) }
|
||||
val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle()
|
||||
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
|
||||
// The list this screen is about is gone; there is nothing left to show.
|
||||
@@ -152,6 +156,12 @@ fun TaskListScreen(
|
||||
viewModel.clearListWriteFailure()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(readOnlyHint) {
|
||||
if (readOnlyHint) {
|
||||
delay(4_000)
|
||||
readOnlyHint = false
|
||||
}
|
||||
}
|
||||
// One add affordance, never two: a real list with the setting on gets a pinned
|
||||
// bottom quick-add bar; everything else (incl. smart lists, which have no single
|
||||
// target list) gets the floating "New task" button.
|
||||
@@ -181,11 +191,20 @@ fun TaskListScreen(
|
||||
IconButton(onClick = { showSort = true }, enabled = content != null) {
|
||||
Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title))
|
||||
}
|
||||
if (list != null && list.isLocal) {
|
||||
IconButton(onClick = { editingList = true }) {
|
||||
if (list != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (list.isReadOnly) readOnlyHint = true else editingList = true
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Edit,
|
||||
contentDescription = stringResource(R.string.list_edit_title),
|
||||
tint = if (list.isReadOnly) {
|
||||
LocalContentColor.current.copy(alpha = 0.38f)
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -230,23 +249,28 @@ fun TaskListScreen(
|
||||
.height(SnackChipHeight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
// One chip, one anchor: the undo receipt takes precedence, and a
|
||||
// refused list write reports itself once the undo window is clear.
|
||||
// One chip, one anchor, in priority order: the undo receipt first,
|
||||
// then a refused list write once the undo window is clear, then the
|
||||
// read-only edit hint — the quietest of the three, so it never
|
||||
// outranks something the other two are actually reporting.
|
||||
val failure = listWriteFailure
|
||||
if (undo != null || failure == null) {
|
||||
SnackChip(
|
||||
visible = undo != null,
|
||||
when {
|
||||
undo != null -> SnackChip(
|
||||
visible = true,
|
||||
message = stringResource(
|
||||
if (undo is UndoReceipt.Completed) R.string.task_completed else R.string.task_deleted,
|
||||
),
|
||||
actionLabel = stringResource(R.string.undo),
|
||||
onAction = viewModel::undo,
|
||||
)
|
||||
} else {
|
||||
SnackChip(
|
||||
failure != null -> SnackChip(
|
||||
visible = true,
|
||||
message = stringResource(listWriteFailureMessage(failure)),
|
||||
)
|
||||
else -> SnackChip(
|
||||
visible = readOnlyHint,
|
||||
message = stringResource(R.string.list_read_only),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1148,6 +1172,8 @@ private fun CenteredMessage(text: String, inner: PaddingValues) {
|
||||
@Composable
|
||||
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
|
||||
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
|
||||
// Several lists at once only exists for the widget, which never routes here.
|
||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||
is TaskFilter.Smart -> stringResource(
|
||||
when (filter.list) {
|
||||
SmartList.TODAY -> R.string.smart_today
|
||||
|
||||
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
@@ -77,7 +77,7 @@ private const val UNDO_WINDOW_MS = 4_000L
|
||||
class TaskListViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val remoteLists: RemoteListRepository,
|
||||
private val remoteLists: RemoteLists,
|
||||
@ApplicationScope private val appScope: CoroutineScope,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -103,6 +103,8 @@ class TaskListViewModel @Inject constructor(
|
||||
}
|
||||
is TaskFilter.Smart ->
|
||||
tasks.map { TaskListUiState.Content(it) }
|
||||
// Several lists at once only exists for the widget, which never routes here.
|
||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||
}
|
||||
// Drop rows pending an undoable delete so the row vanishes on swipe
|
||||
// while the actual provider delete waits for the snackbar to commit,
|
||||
@@ -298,7 +300,7 @@ class TaskListViewModel @Inject constructor(
|
||||
return@launch
|
||||
}
|
||||
val outcome = runCatching { remoteLists.rename(listId, name.trim(), color) }
|
||||
.getOrElse { RemoteListRepository.Outcome.Unreachable }
|
||||
.getOrElse { RemoteLists.Outcome.Unreachable }
|
||||
_listWriteFailure.value = outcome.asFailure(ListWriteFailure.SAVE)
|
||||
}
|
||||
|
||||
@@ -320,7 +322,7 @@ class TaskListViewModel @Inject constructor(
|
||||
// existing for every other client, and could only be got back by
|
||||
// re-adding the whole account.
|
||||
val outcome = runCatching { remoteLists.delete(listId) }
|
||||
.getOrElse { RemoteListRepository.Outcome.Unreachable }
|
||||
.getOrElse { RemoteLists.Outcome.Unreachable }
|
||||
val failure = outcome.asFailure(ListWriteFailure.DELETE)
|
||||
if (failure == null) _listDeleted.value = true else _listWriteFailure.value = failure
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.color.ColorProvider
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.unit.ColorProvider as GlanceColorProvider
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxHeight
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionButton
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.glance.WidgetTile
|
||||
import de.jeanlucmakiola.floret.glance.R as GlanceR
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* "Lists" home-screen widget: one shortcut tile per list, in the list's own
|
||||
* colour, showing its open count — navigation to a list rather than a
|
||||
* preview of its tasks, which is what [TaskWidget] already does.
|
||||
*/
|
||||
class ListsWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
// Exact, not Responsive: the grid is sized to the real placement so every
|
||||
// tile fits without scrolling.
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
|
||||
provideContent {
|
||||
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
|
||||
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
|
||||
val listIds = parseWidgetListIds(currentState(WIDGET_LIST_IDS_KEY))
|
||||
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
|
||||
val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
|
||||
FloretGlanceTheme(
|
||||
lightScheme = AgendulaLightFallback,
|
||||
darkScheme = AgendulaDarkFallback,
|
||||
dynamicColor = ready?.snapshot?.dynamicColor ?: true,
|
||||
mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
|
||||
) {
|
||||
ListsWidgetBody(data, background, listIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListsWidgetBody(data: WidgetStatsData?, background: WidgetBackground, listIds: Set<Long>?) {
|
||||
val context = LocalContext.current
|
||||
val ready = data as? WidgetStatsData.Ready
|
||||
val tiles = ready?.snapshot?.lists?.let { lists -> listIds?.let { ids -> lists.filter { it.id in ids } } ?: lists }
|
||||
val size = LocalSize.current
|
||||
WidgetSurface(background = background) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize().padding(Padding - Gap / 2)) {
|
||||
when {
|
||||
data == null -> Unit
|
||||
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable))
|
||||
tiles.isNullOrEmpty() -> WidgetMessage(context.getString(R.string.widget_lists_empty))
|
||||
else -> {
|
||||
val grid = listGrid(tiles.size, size.width - Padding * 2, size.height - Padding * 2)
|
||||
val rows = tiles.take(grid.columns * grid.rows).chunked(grid.columns)
|
||||
// Gaps are each tile's padding rather than Spacers, which would
|
||||
// eat into Glance's 10-children-per-Row/Column cap.
|
||||
rows.forEach { rowTiles ->
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
|
||||
rowTiles.forEach { tile ->
|
||||
// Glance has no margin — padding on the tile itself lands inside its fill.
|
||||
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(Gap / 2)) {
|
||||
ListTileCell(tile, grid, GlanceModifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ListGrid(val columns: Int, val rows: Int, val tileWidth: Dp, val tileHeight: Dp)
|
||||
|
||||
/**
|
||||
* Picks the column count whose tiles come closest to [TileAspect] at the
|
||||
* largest size, among those that fit [count] tiles into [width] x [height].
|
||||
* Tiles too narrow for the one-line layout must be tall enough to stack, so a
|
||||
* narrow widget shows fewer, readable tiles. Lists that don't fit are dropped
|
||||
* rather than scrolled.
|
||||
*/
|
||||
internal fun listGrid(count: Int, width: Dp, height: Dp): ListGrid {
|
||||
fun span(total: Dp, cells: Int) = (total - Gap * (cells - 1)) / cells
|
||||
fun maxRows(columns: Int): Int {
|
||||
val minHeight = if (span(width, columns) < MinOneLineTileWidth) MinNarrowTileHeight else MinTileHeight
|
||||
return ((height + Gap) / (minHeight + Gap)).toInt().coerceIn(1, MaxCells)
|
||||
}
|
||||
val maxColumns = ((width + Gap) / (MinTileWidth + Gap)).toInt().coerceIn(1, MaxCells)
|
||||
val best = (1..min(count, maxColumns))
|
||||
.map { columns -> columns to (count + columns - 1) / columns }
|
||||
.filter { (columns, rows) -> rows <= maxRows(columns) }
|
||||
.maxByOrNull { (columns, rows) -> min(span(width, columns).value / TileAspect, span(height, rows).value) }
|
||||
val (columns, rows) = best ?: (maxColumns to maxRows(maxColumns))
|
||||
return ListGrid(columns, rows, span(width, columns), span(height, rows))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListTileCell(tile: ListTile, grid: ListGrid, modifier: GlanceModifier) {
|
||||
val context = LocalContext.current
|
||||
val tint = ColorProvider(day = pastelize(tile.color, false), night = pastelize(tile.color, true))
|
||||
val onTint = GlanceColorProvider(Color.White)
|
||||
val showAdd = tile.acceptsWrites && grid.tileWidth >= MinTileWidthWithAdd
|
||||
WidgetTile(
|
||||
color = tint,
|
||||
modifier = modifier,
|
||||
onClick = actionStartActivity(MainActivity.listIntent(context, tile.id)),
|
||||
) {
|
||||
if (grid.tileWidth < MinOneLineTileWidth && grid.tileHeight >= MinNarrowTileHeight) {
|
||||
// Narrow: the name gets the full width and wraps; count and + share the bottom row.
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = tile.name,
|
||||
maxLines = if (grid.tileHeight >= MinTwoLineNameHeight) 2 else 1,
|
||||
style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "${tile.openCount}",
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
style = TextStyle(color = onTint, fontSize = 18.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
if (showAdd) ListAddButton(tile)
|
||||
}
|
||||
}
|
||||
} else if (grid.tileHeight >= MinStackedTileHeight) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = tile.name,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
if (showAdd) ListAddButton(tile)
|
||||
}
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
Text(
|
||||
text = "${tile.openCount}",
|
||||
style = TextStyle(color = onTint, fontSize = 18.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = tile.name,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
Text(
|
||||
text = "${tile.openCount}",
|
||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||
style = TextStyle(color = onTint, fontSize = 15.sp, fontWeight = FontWeight.Bold),
|
||||
)
|
||||
if (showAdd) ListAddButton(tile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListAddButton(tile: ListTile) {
|
||||
val context = LocalContext.current
|
||||
WidgetActionButton(
|
||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
action = actionStartActivity(MainActivity.newTaskIntent(context, tile.id)),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
size = 20.dp,
|
||||
)
|
||||
}
|
||||
|
||||
private val Padding = 8.dp
|
||||
private val Gap = 8.dp
|
||||
private val MinTileWidth = 72.dp
|
||||
private val MinTileHeight = 36.dp
|
||||
private val MinTileWidthWithAdd = 64.dp
|
||||
private val MinStackedTileHeight = 60.dp
|
||||
private val MinNarrowTileHeight = 52.dp
|
||||
private val MinTwoLineNameHeight = 70.dp
|
||||
private val MinOneLineTileWidth = 120.dp
|
||||
private const val TileAspect = 1.5f
|
||||
|
||||
/** Glance's per-Row/Column child cap. */
|
||||
private const val MaxCells = 10
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Host-facing receiver for [ListsWidget]; also keeps the midnight rollover armed. */
|
||||
class ListsWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = ListsWidget()
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.ActionParameters
|
||||
@@ -22,12 +23,10 @@ import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.background
|
||||
import androidx.glance.color.ColorProvider
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
@@ -44,20 +43,40 @@ import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextDecoration
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider as GlanceColorProvider
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetCheckButton
|
||||
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
||||
import de.jeanlucmakiola.floret.glance.WidgetDoneState
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
|
||||
import de.jeanlucmakiola.floret.time.formatTime
|
||||
import de.jeanlucmakiola.floret.glance.R as GlanceR
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* "Tasks" home-screen widget: one smart list (Today by default) or one real
|
||||
* list per instance, with a checkbox to complete each task in place.
|
||||
* "Tasks" home-screen widget: one smart list (Today by default) or one or more
|
||||
* real lists per instance, with a Calendar-agenda look — a plain section label
|
||||
* per group (by list, or by due bucket: Overdue / Today / Tomorrow / Later /
|
||||
* No date) above a run of standalone, fully-rounded task pills, each with a
|
||||
* trailing check to complete it in place.
|
||||
*
|
||||
* The data is collected live inside the composition rather than loaded once in
|
||||
* the `provideGlance` preamble: an `update` on a running session only
|
||||
@@ -68,200 +87,295 @@ class TaskWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override val sizeMode = SizeMode.Single
|
||||
override val sizeMode = SizeMode.Responsive(setOf(CompactSize, RegularSize))
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val stored = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[WIDGET_FILTER_KEY]
|
||||
val initialFilter = parseWidgetFilter(stored)
|
||||
val initial = runCatching { context.taskWidgetData(initialFilter).first() }
|
||||
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
|
||||
val initialFilter = parseWidgetFilter(state[WIDGET_FILTER_KEY])
|
||||
val initialGroupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
|
||||
val initial = runCatching { context.taskWidgetData(initialFilter, initialGroupByList).first() }
|
||||
.getOrDefault(TaskWidgetData.Unavailable)
|
||||
provideContent {
|
||||
val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY))
|
||||
val data by remember(filter) { context.taskWidgetData(filter) }
|
||||
.collectAsState(initial = if (filter == initialFilter) initial else null)
|
||||
val groupByList = currentState(WIDGET_GROUP_BY_LIST_KEY) ?: false
|
||||
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
|
||||
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
|
||||
val rowColor = parseWidgetRowColor(currentState(WIDGET_ROW_COLOR_KEY))
|
||||
val data by remember(filter, groupByList) { context.taskWidgetData(filter, groupByList) }
|
||||
.collectAsState(initial = if (filter == initialFilter && groupByList == initialGroupByList) initial else null)
|
||||
val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready
|
||||
AgendulaGlanceTheme(
|
||||
FloretGlanceTheme(
|
||||
lightScheme = AgendulaLightFallback,
|
||||
darkScheme = AgendulaDarkFallback,
|
||||
dynamicColor = ready?.dynamicColor ?: true,
|
||||
themeMode = ready?.themeMode ?: ThemeMode.SYSTEM,
|
||||
mode = ready?.effectiveThemeMode(themeOverride) ?: WidgetThemeMode.SYSTEM,
|
||||
) {
|
||||
TaskWidgetBody(filter, data)
|
||||
TaskWidgetBody(filter, data, background, rowColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val CompactSize = DpSize(110.dp, 110.dp)
|
||||
val RegularSize = DpSize(250.dp, 110.dp)
|
||||
}
|
||||
}
|
||||
|
||||
/** Completes the tapped task — just the one occurrence for a recurring series. */
|
||||
class CompleteTaskAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val taskId = parameters[TaskIdKey] ?: return
|
||||
val occurrence = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE }
|
||||
?.let { Instant.fromEpochMilliseconds(it) }
|
||||
val occurrenceMillis = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE }
|
||||
val key = WidgetCompletions.key(taskId, occurrenceMillis)
|
||||
if (!WidgetCompletions.start(key)) return
|
||||
val app = context.applicationContext
|
||||
// This callback is shared by every widget's rows, and glanceId only
|
||||
// tells us the tapped widget's instance, not which provider class it
|
||||
// belongs to — updating the wrong one by id would push this class's
|
||||
// RemoteViews into another widget's AppWidget slot. updateAll is
|
||||
// class-scoped, so redrawing every widget is the immediate, correct
|
||||
// option; it also covers every other open instance showing this task.
|
||||
// First the ticked frame, then the real completion a beat later.
|
||||
updateAllWidgets(app)
|
||||
delay(TICK_FRAME_MILLIS)
|
||||
runCatching {
|
||||
context.widgetEntryPoint().tasksRepository().setCompleted(taskId, occurrence, true)
|
||||
context.widgetEntryPoint().tasksRepository()
|
||||
.setCompleted(taskId, occurrenceMillis?.let { Instant.fromEpochMilliseconds(it) }, true)
|
||||
}
|
||||
TaskWidget().update(context.applicationContext, glanceId)
|
||||
updateAllWidgets(app)
|
||||
// Hold the ticked look until the store's change has redrawn the row away.
|
||||
delay(TICK_RELEASE_MILLIS)
|
||||
WidgetCompletions.finish(key)
|
||||
updateAllWidgets(app)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TaskIdKey = ActionParameters.Key<Long>("taskId")
|
||||
val OccurrenceKey = ActionParameters.Key<Long>("occurrenceStart")
|
||||
const val NO_OCCURRENCE = Long.MIN_VALUE
|
||||
private const val TICK_FRAME_MILLIS = 600L
|
||||
private const val TICK_RELEASE_MILLIS = 1500L
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) {
|
||||
private fun TaskWidgetBody(
|
||||
filter: TaskFilter,
|
||||
data: TaskWidgetData?,
|
||||
background: WidgetBackground,
|
||||
rowColor: WidgetRowColor,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val ready = data as? TaskWidgetData.Ready
|
||||
val title = when (filter) {
|
||||
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
|
||||
is TaskFilter.OfList -> (data as? TaskWidgetData.Ready)?.listName
|
||||
is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
|
||||
?: context.getString(R.string.widget_tasks_label)
|
||||
}
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.widgetBackground)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
Header(title, filter)
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
when (data) {
|
||||
null -> Unit
|
||||
TaskWidgetData.Unavailable -> Message(context.getString(R.string.widget_unavailable))
|
||||
is TaskWidgetData.Ready ->
|
||||
if (data.rows.isEmpty()) {
|
||||
Message(context.getString(R.string.widget_empty))
|
||||
} else {
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(data.rows.size) { index -> TaskRow(data.rows[index], data.use24Hour) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(title: String, filter: TaskFilter) {
|
||||
val context = LocalContext.current
|
||||
val open = when (filter) {
|
||||
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
|
||||
is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId)
|
||||
}
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(start = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier.defaultWeight().clickable(actionStartActivity(open)),
|
||||
)
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(40.dp)
|
||||
.cornerRadius(20.dp)
|
||||
.background(GlanceTheme.colors.primaryContainer)
|
||||
.clickable(actionStartActivity(MainActivity.newTaskIntent(context))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimaryContainer),
|
||||
modifier = GlanceModifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
||||
val open = actionStartActivity(
|
||||
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
|
||||
)
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(4.dp)
|
||||
.height(32.dp)
|
||||
.cornerRadius(2.dp)
|
||||
.background(ColorProvider(day = pastelize(row.color, false), night = pastelize(row.color, true))),
|
||||
) {}
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(40.dp)
|
||||
.clickable(
|
||||
actionRunCallback<CompleteTaskAction>(
|
||||
actionParametersOf(
|
||||
CompleteTaskAction.TaskIdKey to row.taskId,
|
||||
CompleteTaskAction.OccurrenceKey to
|
||||
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
|
||||
),
|
||||
),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_widget_checkbox),
|
||||
contentDescription = context.getString(R.string.widget_complete_task, title),
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
WidgetSurface(background = background) {
|
||||
Column(
|
||||
modifier = GlanceModifier.defaultWeight().padding(end = 8.dp, top = 4.dp, bottom = 4.dp)
|
||||
.clickable(open),
|
||||
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||
)
|
||||
dueText(context, row, use24Hour)?.let { due ->
|
||||
Text(
|
||||
text = due,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (row.isOverdue) GlanceTheme.colors.error
|
||||
else GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
)
|
||||
TaskWidgetHeader(title, filter, ready?.singleListId)
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
when (data) {
|
||||
null -> Unit
|
||||
TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable))
|
||||
is TaskWidgetData.Ready ->
|
||||
if (data.rows.isEmpty()) {
|
||||
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
|
||||
} else {
|
||||
val allSections = widgetSections(
|
||||
data.rows,
|
||||
data.groupByList,
|
||||
data.listNames,
|
||||
) { kind -> context.getString(dueBucketLabel(kind)) }
|
||||
// A lone group whose bucket repeats the widget's own smart list
|
||||
// (the "Today" smart list showing one "Today" bucket) earns no
|
||||
// place of its own. A lone group for any other bucket (a
|
||||
// real-list widget whose only bucket is "Tomorrow") still has to
|
||||
// say so — it's the one thing telling the rows apart from today.
|
||||
// Compared by due kind, not by rendered label text, since the two
|
||||
// labels come from separately translated string resources and
|
||||
// aren't guaranteed to read identically in every locale.
|
||||
val onlyHeader = allSections.singleOrNull { it is WidgetSection.Header } as? WidgetSection.Header
|
||||
val redundantDueKind = when ((filter as? TaskFilter.Smart)?.list) {
|
||||
SmartList.TODAY -> DueKind.TODAY
|
||||
SmartList.OVERDUE -> DueKind.PAST
|
||||
else -> null
|
||||
}
|
||||
val redundantHeader = onlyHeader != null && redundantDueKind != null &&
|
||||
onlyHeader.key == "due:${redundantDueKind.name}"
|
||||
val sections = if (redundantHeader) allSections.filterNot { it is WidgetSection.Header } else allSections
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(sections.size) { index ->
|
||||
when (val section = sections[index]) {
|
||||
is WidgetSection.Header -> SectionHeader(section.name)
|
||||
is WidgetSection.TaskItem -> {
|
||||
val container = if (rowColor == WidgetRowColor.BY_LIST) {
|
||||
ColorProvider(
|
||||
day = pastelize(section.row.color, false),
|
||||
night = pastelize(section.row.color, true),
|
||||
)
|
||||
} else {
|
||||
GlanceTheme.colors.surfaceVariant
|
||||
}
|
||||
// A LazyColumn item's host view isn't a linear layout on
|
||||
// its own, so the row and its gap need an explicit Column
|
||||
// to stack rather than overlap.
|
||||
Column {
|
||||
AgendaTaskRow(
|
||||
row = section.row,
|
||||
use24Hour = data.use24Hour,
|
||||
color = container,
|
||||
colored = rowColor == WidgetRowColor.BY_LIST,
|
||||
// The due-bucket header above already names the day
|
||||
// (Today / Tomorrow) whenever one is shown — and when
|
||||
// it isn't, that's only because it would have repeated
|
||||
// the title, which names the same day just as well.
|
||||
suppressDayLabel = !data.groupByList,
|
||||
)
|
||||
Spacer(GlanceModifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dueBucketLabel(kind: DueKind?): Int = when (kind) {
|
||||
DueKind.PAST -> R.string.widget_section_overdue
|
||||
DueKind.TODAY -> R.string.home_due_today
|
||||
DueKind.TOMORROW -> R.string.home_due_tomorrow
|
||||
DueKind.LATER -> R.string.widget_section_later
|
||||
null -> R.string.widget_section_no_date
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Message(text: String) {
|
||||
Box(
|
||||
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
private fun SectionHeader(name: String) {
|
||||
Text(
|
||||
text = name,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Long?) {
|
||||
val context = LocalContext.current
|
||||
val open = when (filter) {
|
||||
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
|
||||
is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId)
|
||||
// Several lists have no single list screen to open; a single selection
|
||||
// still opens straight to it, otherwise this just raises the app.
|
||||
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
|
||||
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
WidgetHeader(
|
||||
title = title,
|
||||
titleAction = actionStartActivity(open),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Today · 14:00", "Tomorrow", or the compact date; `null` without a due date. */
|
||||
private fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String? {
|
||||
/**
|
||||
* A Calendar-agenda-style task chip: its own fully-rounded pill (list colour
|
||||
* or a neutral tone, per [WidgetRowColor]), a leading check to complete it,
|
||||
* title and due to its right. [colored] pills use fixed light text/icon —
|
||||
* [pastelize][de.jeanlucmakiola.floret.components.pastelize] keeps them
|
||||
* mid-bright rather than pale, so the theme's own `onSurface` (tuned for the
|
||||
* neutral tonal fill) reads too dark against them.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaTaskRow(
|
||||
row: WidgetTaskRow,
|
||||
use24Hour: Boolean,
|
||||
color: GlanceColorProvider,
|
||||
colored: Boolean,
|
||||
suppressDayLabel: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
||||
val open = actionStartActivity(
|
||||
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
|
||||
)
|
||||
val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface
|
||||
val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant
|
||||
val onColorError = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.error
|
||||
val completing = WidgetCompletions.isCompleting(row)
|
||||
WidgetGroupedRow(position = Position.Alone, color = color) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
WidgetCheckButton(
|
||||
contentDescription = context.getString(R.string.widget_complete_task, title),
|
||||
action = actionRunCallback<CompleteTaskAction>(
|
||||
actionParametersOf(
|
||||
CompleteTaskAction.TaskIdKey to row.taskId,
|
||||
CompleteTaskAction.OccurrenceKey to
|
||||
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
|
||||
),
|
||||
),
|
||||
tint = onColorVariant,
|
||||
checked = completing,
|
||||
)
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight().clickable(open)) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (completing) onColorVariant else onColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = if (completing) TextDecoration.LineThrough else TextDecoration.None,
|
||||
),
|
||||
)
|
||||
dueText(context, row, use24Hour, suppressDayLabel)?.let { due ->
|
||||
Text(
|
||||
text = due,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (row.isOverdue) onColorError else onColorVariant,
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip's own due line — bare "14:00" when [suppressDayLabel] (the due-bucket
|
||||
* header above already says "Today"/"Tomorrow", Calendar-agenda style: the day
|
||||
* is the group, the chip only needs the time), otherwise "Today · 14:00" /
|
||||
* "Tomorrow", or the compact date for anything else. `null` without a due date,
|
||||
* or for an all-day task once its day is already said by the header.
|
||||
*/
|
||||
internal fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean, suppressDayLabel: Boolean): String? {
|
||||
val due = row.due ?: return null
|
||||
val relative = when (row.dueKind) {
|
||||
DueKind.TODAY -> context.getString(R.string.home_due_today)
|
||||
DueKind.TOMORROW -> context.getString(R.string.home_due_tomorrow)
|
||||
else -> null
|
||||
} ?: return due.formatDateTimeCompact(row.isAllDay, use24Hour)
|
||||
return if (row.isAllDay) relative else "$relative · ${due.formatTime(use24Hour)}"
|
||||
if (row.isAllDay) return if (suppressDayLabel) null else relative
|
||||
val time = due.formatTime(use24Hour)
|
||||
return if (suppressDayLabel) time else "$relative · $time"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Circle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -22,6 +28,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
@@ -40,17 +48,50 @@ import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Which widget provider a configure session is for — decides which sections [WidgetFilterPicker] shows. */
|
||||
internal enum class WidgetKind { TASKS, TODAY, UP_NEXT, LISTS, WEEK }
|
||||
|
||||
/** Reads [appWidgetId]'s provider class from the host, since that's the only thing naming which widget this is. */
|
||||
private fun widgetKindOf(context: Context, appWidgetId: Int): WidgetKind? {
|
||||
val provider = AppWidgetManager.getInstance(context).getAppWidgetInfo(appWidgetId)?.provider ?: return null
|
||||
return when (provider.className) {
|
||||
TaskWidgetReceiver::class.java.name -> WidgetKind.TASKS
|
||||
TodayWidgetReceiver::class.java.name -> WidgetKind.TODAY
|
||||
UpNextWidgetReceiver::class.java.name -> WidgetKind.UP_NEXT
|
||||
ListsWidgetReceiver::class.java.name -> WidgetKind.LISTS
|
||||
WeekWidgetReceiver::class.java.name -> WidgetKind.WEEK
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun widgetFor(kind: WidgetKind): GlanceAppWidget = when (kind) {
|
||||
WidgetKind.TASKS -> TaskWidget()
|
||||
WidgetKind.TODAY -> TodayWidget()
|
||||
WidgetKind.UP_NEXT -> UpNextWidget()
|
||||
WidgetKind.LISTS -> ListsWidget()
|
||||
WidgetKind.WEEK -> WeekWidget()
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks what one widget instance shows: a smart list or a real list. Runs when
|
||||
* the widget is placed (below API 31) and on "reconfigure" from the launcher.
|
||||
* Picks what one widget instance shows. [WidgetKind.TASKS] picks a smart
|
||||
* list or one or more real lists (with an optional "group by list" sort);
|
||||
* [WidgetKind.LISTS] picks which lists get a tile; [WidgetKind.UP_NEXT] can be
|
||||
* narrowed to one list; every other kind only
|
||||
* offers background and theme, since they're always the smart-list scope.
|
||||
* Runs when the widget is placed (below API 31) and on "reconfigure" from
|
||||
* the launcher.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
@@ -68,6 +109,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val kind = widgetKindOf(this, appWidgetId) ?: WidgetKind.TASKS
|
||||
enableEdgeToEdge()
|
||||
val glanceId = GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId)
|
||||
val listsFlow = runCatching { repository.taskLists() }.getOrDefault(emptyFlow())
|
||||
@@ -77,10 +119,19 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
val settings by settingsPrefs.settings.collectAsState(initial = Settings())
|
||||
val lists by remember { listsFlow }.collectAsState(initial = emptyList())
|
||||
var current by remember { mutableStateOf<TaskFilter?>(null) }
|
||||
var groupByList by remember { mutableStateOf(false) }
|
||||
var background by remember { mutableStateOf(WidgetBackground.SOLID) }
|
||||
var themeOverride by remember { mutableStateOf<WidgetThemeMode?>(null) }
|
||||
var rowColor by remember { mutableStateOf(WidgetRowColor.NEUTRAL) }
|
||||
var selectedListIds by remember { mutableStateOf<Set<Long>?>(null) }
|
||||
LaunchedEffect(glanceId) {
|
||||
current = parseWidgetFilter(
|
||||
getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)[WIDGET_FILTER_KEY],
|
||||
)
|
||||
val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
|
||||
current = parseWidgetFilter(state[WIDGET_FILTER_KEY])
|
||||
groupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
|
||||
background = parseWidgetBackground(state[WIDGET_BACKGROUND_KEY], kind)
|
||||
themeOverride = parseWidgetThemeOverride(state[WIDGET_THEME_KEY])
|
||||
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
|
||||
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
|
||||
}
|
||||
val dark = when (settings.themeMode) {
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
@@ -90,15 +141,40 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||
WidgetFilterPicker(
|
||||
kind = kind,
|
||||
lists = lists.filter { it.isVisible },
|
||||
dark = dark,
|
||||
selected = current ?: DEFAULT_WIDGET_FILTER,
|
||||
onSelect = { filter ->
|
||||
groupByList = groupByList,
|
||||
onGroupByListChange = { groupByList = it },
|
||||
background = background,
|
||||
onBackgroundChange = { background = it },
|
||||
themeOverride = themeOverride,
|
||||
onThemeOverrideChange = { themeOverride = it },
|
||||
rowColor = rowColor,
|
||||
onRowColorChange = { rowColor = it },
|
||||
selectedListIds = selectedListIds,
|
||||
onSelectedListIdsChange = { selectedListIds = it },
|
||||
onSave = { filter ->
|
||||
lifecycleScope.launch {
|
||||
updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) {
|
||||
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
|
||||
when (kind) {
|
||||
WidgetKind.TASKS -> {
|
||||
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
|
||||
it[WIDGET_GROUP_BY_LIST_KEY] = groupByList
|
||||
it[WIDGET_ROW_COLOR_KEY] = rowColor.name
|
||||
}
|
||||
WidgetKind.LISTS, WidgetKind.UP_NEXT -> {
|
||||
selectedListIds?.let { ids -> it[WIDGET_LIST_IDS_KEY] = ids.widgetListIdsStorageValue() }
|
||||
?: it.remove(WIDGET_LIST_IDS_KEY)
|
||||
}
|
||||
WidgetKind.TODAY, WidgetKind.WEEK -> Unit
|
||||
}
|
||||
it[WIDGET_BACKGROUND_KEY] = background.name
|
||||
themeOverride?.let { mode -> it[WIDGET_THEME_KEY] = mode.name }
|
||||
?: it.remove(WIDGET_THEME_KEY)
|
||||
}
|
||||
TaskWidget().update(this@TaskWidgetConfigActivity, glanceId)
|
||||
widgetFor(kind).update(this@TaskWidgetConfigActivity, glanceId)
|
||||
setResult(RESULT_OK, resultIntent(appWidgetId))
|
||||
finish()
|
||||
}
|
||||
@@ -115,40 +191,202 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
|
||||
@Composable
|
||||
private fun WidgetFilterPicker(
|
||||
kind: WidgetKind,
|
||||
lists: List<TaskList>,
|
||||
selected: TaskFilter,
|
||||
dark: Boolean,
|
||||
onSelect: (TaskFilter) -> Unit,
|
||||
groupByList: Boolean,
|
||||
onGroupByListChange: (Boolean) -> Unit,
|
||||
background: WidgetBackground,
|
||||
onBackgroundChange: (WidgetBackground) -> Unit,
|
||||
themeOverride: WidgetThemeMode?,
|
||||
onThemeOverrideChange: (WidgetThemeMode?) -> Unit,
|
||||
rowColor: WidgetRowColor,
|
||||
onRowColorChange: (WidgetRowColor) -> Unit,
|
||||
selectedListIds: Set<Long>?,
|
||||
onSelectedListIdsChange: (Set<Long>?) -> Unit,
|
||||
onSave: (TaskFilter) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
FullScreenPicker(title = stringResource(R.string.widget_config_title), onDismiss = onDismiss) {
|
||||
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
|
||||
WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
|
||||
val filter = TaskFilter.Smart(smart)
|
||||
val isSelected = filter == selected
|
||||
GroupedRow(
|
||||
title = stringResource(smartListLabel(smart)),
|
||||
position = positionOf(index, WIDGET_SMART_LISTS.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = { onSelect(filter) },
|
||||
)
|
||||
}
|
||||
if (lists.isNotEmpty()) {
|
||||
var showBackgroundPicker by remember { mutableStateOf(false) }
|
||||
var showThemePicker by remember { mutableStateOf(false) }
|
||||
var showRowColorPicker by remember { mutableStateOf(false) }
|
||||
// Smart lists and real lists are mutually exclusive; only real lists support
|
||||
// picking more than one. `selectedLists` empty + `selectedSmart` non-null is
|
||||
// a smart-list pick; the reverse (or both empty, before the stored filter
|
||||
// loads) is a list pick.
|
||||
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
|
||||
var selectedLists by remember(selected) {
|
||||
mutableStateOf(
|
||||
when (selected) {
|
||||
is TaskFilter.OfList -> setOf(selected.listId)
|
||||
is TaskFilter.OfLists -> selected.listIds
|
||||
is TaskFilter.Smart -> emptySet()
|
||||
},
|
||||
)
|
||||
}
|
||||
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || selectedLists.isNotEmpty()
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
|
||||
onDismiss = onDismiss,
|
||||
actions = {
|
||||
TextButton(
|
||||
enabled = canSave,
|
||||
onClick = {
|
||||
// The toggle row only shows (and so can only be turned off) with
|
||||
// more than one list selected; narrowing back down to one, or to
|
||||
// a smart list, has to clear it here instead; otherwise it's
|
||||
// stuck on with no way left to see or undo it.
|
||||
if (selectedLists.size <= 1 && groupByList) onGroupByListChange(false)
|
||||
selectedSmart?.let { onSave(TaskFilter.Smart(it)) }
|
||||
?: onSave(TaskFilter.OfLists(selectedLists))
|
||||
},
|
||||
) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
) {
|
||||
if (kind == WidgetKind.TASKS) {
|
||||
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
|
||||
WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
|
||||
val isSelected = smart == selectedSmart
|
||||
GroupedRow(
|
||||
title = stringResource(smartListLabel(smart)),
|
||||
position = positionOf(index, WIDGET_SMART_LISTS.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = { selectedSmart = smart; selectedLists = emptySet() },
|
||||
)
|
||||
}
|
||||
if (lists.isNotEmpty()) {
|
||||
GroupedSectionHeader(stringResource(R.string.lists_header))
|
||||
lists.forEachIndexed { index, list ->
|
||||
val isSelected = list.id in selectedLists
|
||||
GroupedRow(
|
||||
title = list.name,
|
||||
position = positionOf(index, lists.size),
|
||||
selected = isSelected,
|
||||
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = {
|
||||
selectedSmart = null
|
||||
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
|
||||
},
|
||||
)
|
||||
}
|
||||
if (selectedLists.size > 1) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_group_by_list),
|
||||
summary = stringResource(R.string.widget_config_group_by_list_hint),
|
||||
position = Position.Alone,
|
||||
trailing = { Switch(checked = groupByList, onCheckedChange = onGroupByListChange) },
|
||||
onClick = { onGroupByListChange(!groupByList) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
} else if (kind == WidgetKind.LISTS && lists.isNotEmpty()) {
|
||||
GroupedSectionHeader(stringResource(R.string.lists_header))
|
||||
lists.forEachIndexed { index, list ->
|
||||
val filter = TaskFilter.OfList(list.id)
|
||||
val isSelected = filter == selected
|
||||
// `null` means "every list" (the default) until the user narrows it down.
|
||||
val isSelected = selectedListIds?.let { list.id in it } ?: true
|
||||
GroupedRow(
|
||||
title = list.name,
|
||||
position = positionOf(index, lists.size),
|
||||
selected = isSelected,
|
||||
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = { onSelect(filter) },
|
||||
onClick = {
|
||||
val current = selectedListIds ?: lists.map { it.id }.toSet()
|
||||
onSelectedListIdsChange(if (isSelected) current - list.id else current + list.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
} else if (kind == WidgetKind.UP_NEXT && lists.isNotEmpty()) {
|
||||
// Single choice: `null` is every list, otherwise a one-element set.
|
||||
val chosen = selectedListIds?.singleOrNull()
|
||||
GroupedSectionHeader(stringResource(R.string.lists_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_up_next_any_list),
|
||||
position = positionOf(0, lists.size + 1),
|
||||
selected = chosen == null,
|
||||
trailing = if (chosen == null) ({ SelectedCheck() }) else null,
|
||||
onClick = { onSelectedListIdsChange(null) },
|
||||
)
|
||||
lists.forEachIndexed { index, list ->
|
||||
val isSelected = list.id == chosen
|
||||
GroupedRow(
|
||||
title = list.name,
|
||||
position = positionOf(index + 1, lists.size + 1),
|
||||
selected = isSelected,
|
||||
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = { onSelectedListIdsChange(setOf(list.id)) },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_background),
|
||||
summary = stringResource(backgroundLabel(background)),
|
||||
position = Position.Top,
|
||||
onClick = { showBackgroundPicker = true },
|
||||
)
|
||||
if (kind == WidgetKind.TASKS) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_theme),
|
||||
summary = stringResource(themeOverrideLabel(themeOverride)),
|
||||
position = Position.Middle,
|
||||
onClick = { showThemePicker = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_row_color),
|
||||
summary = stringResource(rowColorLabel(rowColor)),
|
||||
position = Position.Bottom,
|
||||
onClick = { showRowColorPicker = true },
|
||||
)
|
||||
} else {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_theme),
|
||||
summary = stringResource(themeOverrideLabel(themeOverride)),
|
||||
position = Position.Bottom,
|
||||
onClick = { showThemePicker = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showBackgroundPicker) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.widget_config_background),
|
||||
options = kind.backgrounds(),
|
||||
selected = background,
|
||||
label = { stringResource(backgroundLabel(it)) },
|
||||
summary = { stringResource(backgroundHint(it)) },
|
||||
onSelect = onBackgroundChange,
|
||||
onDismiss = { showBackgroundPicker = false },
|
||||
)
|
||||
}
|
||||
if (showThemePicker) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.widget_config_theme),
|
||||
options = listOf<WidgetThemeMode?>(null, WidgetThemeMode.LIGHT, WidgetThemeMode.DARK),
|
||||
selected = themeOverride,
|
||||
label = { stringResource(themeOverrideLabel(it)) },
|
||||
onSelect = onThemeOverrideChange,
|
||||
onDismiss = { showThemePicker = false },
|
||||
)
|
||||
}
|
||||
if (showRowColorPicker) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.widget_config_row_color),
|
||||
options = WidgetRowColor.entries,
|
||||
selected = rowColor,
|
||||
label = { stringResource(rowColorLabel(it)) },
|
||||
summary = { stringResource(rowColorHint(it)) },
|
||||
onSelect = onRowColorChange,
|
||||
onDismiss = { showRowColorPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,3 +396,32 @@ internal fun smartListLabel(list: SmartList): Int = when (list) {
|
||||
SmartList.OVERDUE -> R.string.smart_overdue
|
||||
else -> R.string.smart_all
|
||||
}
|
||||
|
||||
private fun backgroundLabel(background: WidgetBackground): Int = when (background) {
|
||||
WidgetBackground.SOLID -> R.string.widget_config_background_solid
|
||||
WidgetBackground.TINTED -> R.string.widget_config_background_tinted
|
||||
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
|
||||
}
|
||||
|
||||
private fun backgroundHint(background: WidgetBackground): Int = when (background) {
|
||||
WidgetBackground.SOLID -> R.string.widget_config_background_solid_hint
|
||||
WidgetBackground.TINTED -> R.string.widget_config_background_tinted_hint
|
||||
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent_hint
|
||||
}
|
||||
|
||||
private fun themeOverrideLabel(mode: WidgetThemeMode?): Int = when (mode) {
|
||||
null -> R.string.widget_config_theme_follow_app
|
||||
WidgetThemeMode.LIGHT -> R.string.widget_config_theme_light
|
||||
WidgetThemeMode.DARK -> R.string.widget_config_theme_dark
|
||||
WidgetThemeMode.SYSTEM -> R.string.widget_config_theme_follow_app
|
||||
}
|
||||
|
||||
private fun rowColorLabel(rowColor: WidgetRowColor): Int = when (rowColor) {
|
||||
WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral
|
||||
WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list
|
||||
}
|
||||
|
||||
private fun rowColorHint(rowColor: WidgetRowColor): Int = when (rowColor) {
|
||||
WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral_hint
|
||||
WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list_hint
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user