Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9e29ad46c |
@@ -37,29 +37,11 @@ jobs:
|
|||||||
- name: Reproducible-release invariant
|
- name: Reproducible-release invariant
|
||||||
run: bash scripts/check_reproducible_release.sh
|
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
|
# 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,
|
# non-zero if this version's changelog is over the 500-character limit,
|
||||||
# and the porcelain check below catches a version with no committed
|
# and the porcelain check below catches a version with no committed
|
||||||
# changelog, which would otherwise ship the CHANGELOG.md section instead
|
# changelog, which would otherwise ship the CHANGELOG.md section instead
|
||||||
# of a hand-written summary. A beta version passes both: it ships no
|
# of a hand-written summary.
|
||||||
# What's New, so the script writes nothing for it.
|
|
||||||
- name: Changelog fits the stores, and is committed
|
- name: Changelog fits the stores, and is committed
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
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
|
|
||||||
# no tag exists for that version 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 is a new beta
|
|
||||||
id: v
|
|
||||||
env:
|
|
||||||
# Codeberg, not Gitea: see the same lookup in release.yaml.
|
|
||||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
INFO=$(bash scripts/version_info.sh)
|
|
||||||
echo "$INFO"
|
|
||||||
echo "$INFO" >> "$GITHUB_OUTPUT"
|
|
||||||
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
|
|
||||||
BASE=$(echo "$INFO" | sed -n 's/^base_version=//p')
|
|
||||||
if [ "$(echo "$INFO" | sed -n 's/^channel=//p')" != "beta" ]; then
|
|
||||||
echo "versionName $VERSION is not a beta — nothing to do."
|
|
||||||
echo "is_beta=false" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
# Fatal on anything but a clean 200/404, as in release.yaml: guessing
|
|
||||||
# "no tag" during an outage would re-cut a published beta.
|
|
||||||
tag_status() {
|
|
||||||
curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/$1" || echo 000
|
|
||||||
}
|
|
||||||
# A beta of a version that already shipped stable would carry a lower
|
|
||||||
# versionCode than the stable one: nobody could install it over it.
|
|
||||||
case "$(tag_status "v$BASE")" in
|
|
||||||
200)
|
|
||||||
echo "v$BASE already shipped as stable; a beta of it is pointless. Bump the version." >&2
|
|
||||||
exit 1 ;;
|
|
||||||
404) ;;
|
|
||||||
*) echo "Codeberg tag lookup for v$BASE failed — refusing to guess." >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
STATUS=$(tag_status "v$VERSION")
|
|
||||||
case "$STATUS" in
|
|
||||||
200)
|
|
||||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
|
||||||
echo "is_beta=false" >> "$GITHUB_OUTPUT"
|
|
||||||
;;
|
|
||||||
404)
|
|
||||||
echo "No tag for v$VERSION on Codeberg yet — cutting the beta."
|
|
||||||
echo "is_beta=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 published beta." >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
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
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: 'zulu'
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- 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 testDebugUnitTest
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
- name: Build release APK
|
|
||||||
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/release/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/release/app-release.apk \
|
|
||||||
bash scripts/publish_codeberg_release.sh
|
|
||||||
+177
-34
@@ -7,9 +7,7 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
|||||||
# the release on Codeberg with the signed APK + a SHA-256 checksum as a
|
# the release on Codeberg with the signed APK + a SHA-256 checksum as a
|
||||||
# direct-download channel — the tag is an output of the pipeline, not its
|
# direct-download channel — the tag is an output of the pipeline, not its
|
||||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||||
# nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
|
# nothing.
|
||||||
# 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
|
# 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
|
# and separate because Play can reject a good build for reasons the pipeline
|
||||||
@@ -75,25 +73,23 @@ jobs:
|
|||||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
# versionName -> versionCode, channel and the pre-release flag (set
|
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||||
# while MAJOR was 0) all come from the one script beta.yaml uses too.
|
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
||||||
INFO=$(bash scripts/version_info.sh)
|
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||||
echo "$INFO"
|
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||||
echo "$INFO" >> "$GITHUB_OUTPUT"
|
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||||
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
|
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||||
|
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
|
||||||
|
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
|
||||||
|
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
|
||||||
|
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
echo "Manual dispatch — re-sign path, not a release."
|
echo "Manual dispatch — re-sign path, not a release."
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# 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
|
|
||||||
# A tag for this version already existing means the release shipped on
|
# A tag for this version already existing means the release shipped on
|
||||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||||
#
|
#
|
||||||
@@ -200,8 +196,8 @@ jobs:
|
|||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
# The committed versionName is the source of truth. Pin versionCode to the
|
# The committed versionName is the source of truth. Pin versionCode to the
|
||||||
# value scripts/version_info.sh derives from it, so the published APK's
|
# value derived from it so the published APK's code is always
|
||||||
# code follows the scheme even if the committed code was forgotten.
|
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
||||||
- name: Pin versionCode to versionName
|
- name: Pin versionCode to versionName
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
run: |
|
run: |
|
||||||
@@ -336,8 +332,6 @@ jobs:
|
|||||||
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
# 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
|
# 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).
|
# 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
|
- name: Create tag + Gitea release
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -346,17 +340,85 @@ jobs:
|
|||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
TAG="v$VERSION"
|
||||||
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
# Notes = this version's CHANGELOG section.
|
||||||
MAPPING=app/build/outputs/mapping/release/mapping.txt \
|
awk -v ver="$VERSION" '
|
||||||
bash scripts/publish_gitea_release.sh
|
$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"
|
||||||
|
|
||||||
# Publish the release on Codeberg, which is canonical for tags and
|
# Publish the release on Codeberg, which is canonical for tags and
|
||||||
# releases (see docs/RELEASING.md): push the tag there and attach the
|
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
|
||||||
# signed APK plus a SHA-256 checksum as the direct-download channel for
|
# to Gitea, but releases aren't git objects and don't sync in either
|
||||||
# users who don't want F-Droid. The APK is identical to the F-Droid one
|
# direction — so this step pushes the tag straight to Codeberg and creates
|
||||||
# (same app key), so this adds no trust surface. Needs the
|
# the release there over the API, attaching the signed APK plus a SHA-256
|
||||||
# CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
# 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
|
- name: Publish release to Codeberg
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
||||||
@@ -369,10 +431,91 @@ jobs:
|
|||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
if [ -z "${TOKEN:-}" ]; then
|
||||||
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
||||||
APK=app/build/outputs/apk/release/app-release.apk \
|
exit 0
|
||||||
bash scripts/publish_codeberg_release.sh
|
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."
|
||||||
|
|
||||||
# Play takes an App Bundle, not the APK: a second artifact from the same
|
# 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
|
# source and signing config. Play treats the release key only as the
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
name: Renovate
|
name: Renovate
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
|
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
|
||||||
# same trigger release.yaml relies on), so a merged Renovate PR is followed
|
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
|
||||||
# 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:
|
schedule:
|
||||||
- cron: '0 5 * * 1'
|
- cron: '0 5 * * 1'
|
||||||
# Manual run for an on-demand sweep from the Actions tab.
|
# Manual run for an on-demand sweep from the Actions tab.
|
||||||
|
|||||||
+2
-15
@@ -7,23 +7,10 @@ All notable changes to this project are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
## [1.0.1] - 2026-09-25
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- The Tasks widget has agenda-style rows, an optional row colour and an "All
|
- First release on Google Play. No changes to the app since 1.0.0.
|
||||||
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
|
## [1.0.0] - 2026-09-21
|
||||||
|
|
||||||
|
|||||||
@@ -20,14 +20,6 @@ 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>
|
<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>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
|
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
|
||||||
@@ -119,8 +111,6 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository
|
|||||||
serves. For automatic updates from there, use
|
serves. For automatic updates from there, use
|
||||||
**[Obtainium](https://github.com/ImranR98/Obtainium)** and
|
**[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)**.
|
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
|
||||||
Betas of upcoming versions are published there too, as pre-releases; to test
|
|
||||||
them, switch on *Include prereleases* for Agendula in Obtainium.
|
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
|
|||||||
@@ -26,14 +26,12 @@ android {
|
|||||||
// These committed values ARE the source of truth for a release: merging
|
// These committed values ARE the source of truth for a release: merging
|
||||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
|
// PATCH from versionName, e.g. 1.0.0 -> 10000). Releases were flagged as
|
||||||
// versionCode is derived from versionName by scripts/version_info.sh
|
// pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the
|
||||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
// pipeline graduates it on its own. See docs/RELEASING.md.
|
||||||
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
versionCode = 10001
|
||||||
// See docs/RELEASING.md.
|
versionName = "1.0.1"
|
||||||
versionCode = 1010001
|
|
||||||
versionName = "1.1.0-beta.1"
|
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@@ -246,7 +244,6 @@ dependencies {
|
|||||||
implementation(libs.floret.core.crash)
|
implementation(libs.floret.core.crash)
|
||||||
implementation(libs.floret.identity)
|
implementation(libs.floret.identity)
|
||||||
implementation(libs.floret.components)
|
implementation(libs.floret.components)
|
||||||
implementation(libs.floret.glance)
|
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
|
|||||||
@@ -185,58 +185,6 @@
|
|||||||
android:resource="@xml/appwidget_info_tasks" />
|
android:resource="@xml/appwidget_info_tasks" />
|
||||||
</receiver>
|
</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. -->
|
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".widget.TaskWidgetConfigActivity"
|
android:name=".widget.TaskWidgetConfigActivity"
|
||||||
|
|||||||
@@ -186,7 +186,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
|
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_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
||||||
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
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 SHARED_TITLE_LIMIT = 500
|
||||||
private const val NO_OCCURRENCE = -1L
|
private const val NO_OCCURRENCE = -1L
|
||||||
|
|
||||||
@@ -207,14 +206,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
|
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
/**
|
/** The launcher shortcut's and Quick Settings tile's "New task". */
|
||||||
* The launcher shortcut's, Quick Settings tile's and a widget's "New
|
fun newTaskIntent(context: Context): Intent =
|
||||||
* task". [listId] preset the list when it names exactly one; the data
|
Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java)
|
||||||
* 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)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
fun todayIntent(context: Context): Intent =
|
fun todayIntent(context: Context): Intent =
|
||||||
@@ -239,9 +233,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
?.let { return NavRequest.OpenAccount(it) }
|
?.let { return NavRequest.OpenAccount(it) }
|
||||||
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
||||||
when (intent.action) {
|
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_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
||||||
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
||||||
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
|
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
|
||||||
|
|||||||
@@ -95,8 +95,6 @@ data class Settings(
|
|||||||
val pushEnabled: Boolean = true,
|
val pushEnabled: Boolean = true,
|
||||||
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
|
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
|
||||||
val hiddenFromSmartLists: Set<Long> = emptySet(),
|
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. */
|
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
|
||||||
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
||||||
@@ -155,7 +153,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
|
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
|
||||||
.toSet()
|
.toSet()
|
||||||
},
|
},
|
||||||
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,8 +166,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
|
|
||||||
suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
|
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 setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
|
||||||
|
|
||||||
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
|
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
|
||||||
@@ -287,7 +282,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
|
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
|
||||||
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
||||||
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
||||||
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,9 +63,6 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
// parent's subtask-progress reflects ALL its children — completed ones
|
// parent's subtask-progress reflects ALL its children — completed ones
|
||||||
// included — even on smart lists that hide completed tasks themselves.
|
// included — even on smart lists that hide completed tasks themselves.
|
||||||
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
|
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 (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||||
val all = SeriesCollapse.visible(dataSource.tasks(query))
|
val all = SeriesCollapse.visible(dataSource.tasks(query))
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
|
|||||||
/** What a task screen is showing: one real list, or a smart list. */
|
/** What a task screen is showing: one real list, or a smart list. */
|
||||||
sealed interface TaskFilter {
|
sealed interface TaskFilter {
|
||||||
data class OfList(val listId: Long) : 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
|
data class Smart(val list: SmartList) : TaskFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +21,6 @@ object TaskFiltering {
|
|||||||
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
|
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
|
||||||
when (filter) {
|
when (filter) {
|
||||||
is TaskFilter.OfList -> task.listId == filter.listId
|
is TaskFilter.OfList -> task.listId == filter.listId
|
||||||
is TaskFilter.OfLists -> task.listId in filter.listIds
|
|
||||||
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
|
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,11 +122,10 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
val settings = settingsPrefs.settings.first()
|
val settings = settingsPrefs.settings.first()
|
||||||
defaultFields = settings.defaultEditFields
|
defaultFields = settings.defaultEditFields
|
||||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||||
// A read-only or vanished default — or a widget's preset list, if it was
|
// A read-only or vanished default would give the task a list it
|
||||||
// since deleted or turned read-only — would give the task a list it
|
|
||||||
// cannot be saved to.
|
// cannot be saved to.
|
||||||
val writable = lists.filter { it.acceptsWrites }
|
val writable = lists.filter { it.acceptsWrites }
|
||||||
val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } }
|
val defaultList = presetListId
|
||||||
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } }
|
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } }
|
||||||
?: writable.firstOrNull { !it.isLocal }?.id
|
?: writable.firstOrNull { !it.isLocal }?.id
|
||||||
?: writable.firstOrNull()?.id
|
?: writable.firstOrNull()?.id
|
||||||
@@ -137,7 +135,6 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
isNew = true,
|
isNew = true,
|
||||||
autofocusTitle = settings.autofocusTitle,
|
autofocusTitle = settings.autofocusTitle,
|
||||||
listId = defaultList,
|
listId = defaultList,
|
||||||
start = if (settings.defaultStartToday) Clock.System.now() else null,
|
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
lists = lists,
|
lists = lists,
|
||||||
parentCandidates = loadParents(defaultList, selfId = null),
|
parentCandidates = loadParents(defaultList, selfId = null),
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ fun ListEditorSheet(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = destination.label,
|
title = destination.label,
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
leading = { Icon(destinationIcon(destination), contentDescription = null) },
|
leading = { Icon(destinationIcon(destination), contentDescription = null) },
|
||||||
trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) },
|
trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) },
|
||||||
onClick = { choosingDestination = true },
|
onClick = { choosingDestination = true },
|
||||||
@@ -175,6 +176,7 @@ fun ListEditorSheet(
|
|||||||
title = stringResource(R.string.list_in_smart_lists),
|
title = stringResource(R.string.list_in_smart_lists),
|
||||||
summary = stringResource(R.string.list_in_smart_lists_hint),
|
summary = stringResource(R.string.list_in_smart_lists_hint),
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = !hiddenFromSmartLists,
|
checked = !hiddenFromSmartLists,
|
||||||
|
|||||||
@@ -52,8 +52,7 @@ fun AgendulaNavHost(
|
|||||||
nav.navigate(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)) { launchSingleTop = true }
|
nav.navigate(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)) { launchSingleTop = true }
|
||||||
is NavRequest.OpenAccount ->
|
is NavRequest.OpenAccount ->
|
||||||
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
|
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
|
||||||
is NavRequest.NewTask ->
|
is NavRequest.NewTask -> nav.navigate(Dest.TaskEdit.buildNew(title = navRequest.title))
|
||||||
nav.navigate(Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title))
|
|
||||||
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
|
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,8 +69,6 @@ object Dest {
|
|||||||
fun build(filter: TaskFilter): String = when (filter) {
|
fun build(filter: TaskFilter): String = when (filter) {
|
||||||
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
|
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
|
||||||
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
|
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. */
|
/** Reconstruct the filter from a destination's arguments. */
|
||||||
|
|||||||
@@ -18,11 +18,8 @@ sealed interface NavRequest {
|
|||||||
/** One account's detail — where "sign in again" lives. */
|
/** One account's detail — where "sign in again" lives. */
|
||||||
data class OpenAccount(val accountId: Long) : NavRequest
|
data class OpenAccount(val accountId: Long) : NavRequest
|
||||||
|
|
||||||
/**
|
/** A new task, optionally titled with text shared from another app. */
|
||||||
* A new task, optionally titled with text shared from another app, or
|
data class NewTask(val title: String? = null) : NavRequest
|
||||||
* 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. */
|
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
|
||||||
data class OpenSmart(val list: SmartList) : NavRequest
|
data class OpenSmart(val list: SmartList) : NavRequest
|
||||||
|
|||||||
@@ -241,7 +241,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setSnoozeMinutes(minutes: Int) = viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
fun setSnoozeMinutes(minutes: Int) = viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||||
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
|
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
|
||||||
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
|
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. */
|
/** Toggle whether [field] shows by default on a new task's edit form. */
|
||||||
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
|
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
|
||||||
|
|||||||
@@ -99,18 +99,6 @@ internal fun TaskFormScreen(
|
|||||||
},
|
},
|
||||||
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
|
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(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_bottom_add_bar),
|
title = stringResource(R.string.settings_bottom_add_bar),
|
||||||
summary = stringResource(R.string.settings_bottom_add_bar_hint),
|
summary = stringResource(R.string.settings_bottom_add_bar_hint),
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LocalContentColor
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MediumTopAppBar
|
import androidx.compose.material3.MediumTopAppBar
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -140,12 +139,9 @@ fun TaskListScreen(
|
|||||||
val list = content?.list
|
val list = content?.list
|
||||||
val listName = list?.name
|
val listName = list?.name
|
||||||
val listId = (filter as? TaskFilter.OfList)?.listId
|
val listId = (filter as? TaskFilter.OfList)?.listId
|
||||||
// Editing is offered for any writable list, device or synced — renaming and
|
// Editing is offered for a device-only list. A collection that belongs to an
|
||||||
// recolouring a synced collection is a server write the same sheet already
|
// account is the server's to rename or remove, not ours.
|
||||||
// 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 editingList by rememberSaveable { mutableStateOf(false) }
|
||||||
var readOnlyHint by rememberSaveable { mutableStateOf(false) }
|
|
||||||
val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle()
|
val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle()
|
||||||
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
|
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
|
||||||
// The list this screen is about is gone; there is nothing left to show.
|
// The list this screen is about is gone; there is nothing left to show.
|
||||||
@@ -156,12 +152,6 @@ fun TaskListScreen(
|
|||||||
viewModel.clearListWriteFailure()
|
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
|
// One add affordance, never two: a real list with the setting on gets a pinned
|
||||||
// bottom quick-add bar; everything else (incl. smart lists, which have no single
|
// bottom quick-add bar; everything else (incl. smart lists, which have no single
|
||||||
// target list) gets the floating "New task" button.
|
// target list) gets the floating "New task" button.
|
||||||
@@ -191,20 +181,11 @@ fun TaskListScreen(
|
|||||||
IconButton(onClick = { showSort = true }, enabled = content != null) {
|
IconButton(onClick = { showSort = true }, enabled = content != null) {
|
||||||
Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title))
|
Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title))
|
||||||
}
|
}
|
||||||
if (list != null) {
|
if (list != null && list.isLocal) {
|
||||||
IconButton(
|
IconButton(onClick = { editingList = true }) {
|
||||||
onClick = {
|
|
||||||
if (list.isReadOnly) readOnlyHint = true else editingList = true
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Rounded.Edit,
|
Icons.Rounded.Edit,
|
||||||
contentDescription = stringResource(R.string.list_edit_title),
|
contentDescription = stringResource(R.string.list_edit_title),
|
||||||
tint = if (list.isReadOnly) {
|
|
||||||
LocalContentColor.current.copy(alpha = 0.38f)
|
|
||||||
} else {
|
|
||||||
LocalContentColor.current
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,28 +230,23 @@ fun TaskListScreen(
|
|||||||
.height(SnackChipHeight),
|
.height(SnackChipHeight),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
// One chip, one anchor, in priority order: the undo receipt first,
|
// One chip, one anchor: the undo receipt takes precedence, and a
|
||||||
// then a refused list write once the undo window is clear, then the
|
// refused list write reports itself once the undo window is clear.
|
||||||
// read-only edit hint — the quietest of the three, so it never
|
|
||||||
// outranks something the other two are actually reporting.
|
|
||||||
val failure = listWriteFailure
|
val failure = listWriteFailure
|
||||||
when {
|
if (undo != null || failure == null) {
|
||||||
undo != null -> SnackChip(
|
SnackChip(
|
||||||
visible = true,
|
visible = undo != null,
|
||||||
message = stringResource(
|
message = stringResource(
|
||||||
if (undo is UndoReceipt.Completed) R.string.task_completed else R.string.task_deleted,
|
if (undo is UndoReceipt.Completed) R.string.task_completed else R.string.task_deleted,
|
||||||
),
|
),
|
||||||
actionLabel = stringResource(R.string.undo),
|
actionLabel = stringResource(R.string.undo),
|
||||||
onAction = viewModel::undo,
|
onAction = viewModel::undo,
|
||||||
)
|
)
|
||||||
failure != null -> SnackChip(
|
} else {
|
||||||
|
SnackChip(
|
||||||
visible = true,
|
visible = true,
|
||||||
message = stringResource(listWriteFailureMessage(failure)),
|
message = stringResource(listWriteFailureMessage(failure)),
|
||||||
)
|
)
|
||||||
else -> SnackChip(
|
|
||||||
visible = readOnlyHint,
|
|
||||||
message = stringResource(R.string.list_read_only),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1172,8 +1148,6 @@ private fun CenteredMessage(text: String, inner: PaddingValues) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
|
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
|
||||||
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
|
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(
|
is TaskFilter.Smart -> stringResource(
|
||||||
when (filter.list) {
|
when (filter.list) {
|
||||||
SmartList.TODAY -> R.string.smart_today
|
SmartList.TODAY -> R.string.smart_today
|
||||||
|
|||||||
@@ -103,8 +103,6 @@ class TaskListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
is TaskFilter.Smart ->
|
is TaskFilter.Smart ->
|
||||||
tasks.map { TaskListUiState.Content(it) }
|
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
|
// Drop rows pending an undoable delete so the row vanishes on swipe
|
||||||
// while the actual provider delete waits for the snackbar to commit,
|
// while the actual provider delete waits for the snackbar to commit,
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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,18 +1,17 @@
|
|||||||
package de.jeanlucmakiola.agendula.widget
|
package de.jeanlucmakiola.agendula.widget
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.glance.ColorFilter
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.GlanceModifier
|
import androidx.glance.GlanceModifier
|
||||||
import androidx.glance.GlanceTheme
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.Image
|
||||||
import androidx.glance.ImageProvider
|
import androidx.glance.ImageProvider
|
||||||
import androidx.glance.LocalContext
|
import androidx.glance.LocalContext
|
||||||
import androidx.glance.action.ActionParameters
|
import androidx.glance.action.ActionParameters
|
||||||
@@ -23,10 +22,12 @@ import androidx.glance.appwidget.SizeMode
|
|||||||
import androidx.glance.appwidget.action.ActionCallback
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
import androidx.glance.appwidget.action.actionRunCallback
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
import androidx.glance.appwidget.action.actionStartActivity
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
|
import androidx.glance.appwidget.cornerRadius
|
||||||
import androidx.glance.appwidget.lazy.LazyColumn
|
import androidx.glance.appwidget.lazy.LazyColumn
|
||||||
import androidx.glance.appwidget.lazy.items
|
import androidx.glance.appwidget.lazy.items
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.appwidget.state.getAppWidgetState
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
|
import androidx.glance.background
|
||||||
import androidx.glance.color.ColorProvider
|
import androidx.glance.color.ColorProvider
|
||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
@@ -43,40 +44,20 @@ import androidx.glance.layout.width
|
|||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import androidx.glance.text.FontWeight
|
import androidx.glance.text.FontWeight
|
||||||
import androidx.glance.text.Text
|
import androidx.glance.text.Text
|
||||||
import androidx.glance.text.TextDecoration
|
|
||||||
import androidx.glance.text.TextStyle
|
import androidx.glance.text.TextStyle
|
||||||
import androidx.glance.unit.ColorProvider as GlanceColorProvider
|
|
||||||
import de.jeanlucmakiola.agendula.MainActivity
|
import de.jeanlucmakiola.agendula.MainActivity
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
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.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.formatDateTimeCompact
|
||||||
import de.jeanlucmakiola.floret.time.formatTime
|
import de.jeanlucmakiola.floret.time.formatTime
|
||||||
import de.jeanlucmakiola.floret.glance.R as GlanceR
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Tasks" home-screen widget: one smart list (Today by default) or one or more
|
* "Tasks" home-screen widget: one smart list (Today by default) or one real
|
||||||
* real lists per instance, with a Calendar-agenda look — a plain section label
|
* list per instance, with a checkbox to complete each task in place.
|
||||||
* 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 data is collected live inside the composition rather than loaded once in
|
||||||
* the `provideGlance` preamble: an `update` on a running session only
|
* the `provideGlance` preamble: an `update` on a running session only
|
||||||
@@ -87,295 +68,200 @@ class TaskWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
override val sizeMode = SizeMode.Responsive(setOf(CompactSize, RegularSize))
|
override val sizeMode = SizeMode.Single
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
|
val stored = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[WIDGET_FILTER_KEY]
|
||||||
val initialFilter = parseWidgetFilter(state[WIDGET_FILTER_KEY])
|
val initialFilter = parseWidgetFilter(stored)
|
||||||
val initialGroupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
|
val initial = runCatching { context.taskWidgetData(initialFilter).first() }
|
||||||
val initial = runCatching { context.taskWidgetData(initialFilter, initialGroupByList).first() }
|
|
||||||
.getOrDefault(TaskWidgetData.Unavailable)
|
.getOrDefault(TaskWidgetData.Unavailable)
|
||||||
provideContent {
|
provideContent {
|
||||||
val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY))
|
val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY))
|
||||||
val groupByList = currentState(WIDGET_GROUP_BY_LIST_KEY) ?: false
|
val data by remember(filter) { context.taskWidgetData(filter) }
|
||||||
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
|
.collectAsState(initial = if (filter == initialFilter) initial else null)
|
||||||
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
|
val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready
|
||||||
FloretGlanceTheme(
|
AgendulaGlanceTheme(
|
||||||
lightScheme = AgendulaLightFallback,
|
|
||||||
darkScheme = AgendulaDarkFallback,
|
|
||||||
dynamicColor = ready?.dynamicColor ?: true,
|
dynamicColor = ready?.dynamicColor ?: true,
|
||||||
mode = ready?.effectiveThemeMode(themeOverride) ?: WidgetThemeMode.SYSTEM,
|
themeMode = ready?.themeMode ?: ThemeMode.SYSTEM,
|
||||||
) {
|
) {
|
||||||
TaskWidgetBody(filter, data, background, rowColor)
|
TaskWidgetBody(filter, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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. */
|
/** Completes the tapped task — just the one occurrence for a recurring series. */
|
||||||
class CompleteTaskAction : ActionCallback {
|
class CompleteTaskAction : ActionCallback {
|
||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
val taskId = parameters[TaskIdKey] ?: return
|
val taskId = parameters[TaskIdKey] ?: return
|
||||||
val occurrenceMillis = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE }
|
val occurrence = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE }
|
||||||
val key = WidgetCompletions.key(taskId, occurrenceMillis)
|
?.let { Instant.fromEpochMilliseconds(it) }
|
||||||
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 {
|
runCatching {
|
||||||
context.widgetEntryPoint().tasksRepository()
|
context.widgetEntryPoint().tasksRepository().setCompleted(taskId, occurrence, true)
|
||||||
.setCompleted(taskId, occurrenceMillis?.let { Instant.fromEpochMilliseconds(it) }, true)
|
|
||||||
}
|
}
|
||||||
updateAllWidgets(app)
|
TaskWidget().update(context.applicationContext, glanceId)
|
||||||
// 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 {
|
companion object {
|
||||||
val TaskIdKey = ActionParameters.Key<Long>("taskId")
|
val TaskIdKey = ActionParameters.Key<Long>("taskId")
|
||||||
val OccurrenceKey = ActionParameters.Key<Long>("occurrenceStart")
|
val OccurrenceKey = ActionParameters.Key<Long>("occurrenceStart")
|
||||||
const val NO_OCCURRENCE = Long.MIN_VALUE
|
const val NO_OCCURRENCE = Long.MIN_VALUE
|
||||||
private const val TICK_FRAME_MILLIS = 600L
|
|
||||||
private const val TICK_RELEASE_MILLIS = 1500L
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TaskWidgetBody(
|
private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) {
|
||||||
filter: TaskFilter,
|
|
||||||
data: TaskWidgetData?,
|
|
||||||
background: WidgetBackground,
|
|
||||||
rowColor: WidgetRowColor,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val ready = data as? TaskWidgetData.Ready
|
|
||||||
val title = when (filter) {
|
val title = when (filter) {
|
||||||
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
|
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
|
||||||
is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
|
is TaskFilter.OfList -> (data as? TaskWidgetData.Ready)?.listName
|
||||||
?: context.getString(R.string.widget_tasks_label)
|
?: context.getString(R.string.widget_tasks_label)
|
||||||
}
|
}
|
||||||
WidgetSurface(background = background) {
|
Column(
|
||||||
Column(
|
modifier = GlanceModifier
|
||||||
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),
|
.fillMaxSize()
|
||||||
) {
|
.background(GlanceTheme.colors.widgetBackground)
|
||||||
TaskWidgetHeader(title, filter, ready?.singleListId)
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
) {
|
||||||
when (data) {
|
Header(title, filter)
|
||||||
null -> Unit
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable))
|
when (data) {
|
||||||
is TaskWidgetData.Ready ->
|
null -> Unit
|
||||||
if (data.rows.isEmpty()) {
|
TaskWidgetData.Unavailable -> Message(context.getString(R.string.widget_unavailable))
|
||||||
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
|
is TaskWidgetData.Ready ->
|
||||||
} else {
|
if (data.rows.isEmpty()) {
|
||||||
val allSections = widgetSections(
|
Message(context.getString(R.string.widget_empty))
|
||||||
data.rows,
|
} else {
|
||||||
data.groupByList,
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
data.listNames,
|
items(data.rows.size) { index -> TaskRow(data.rows[index], data.use24Hour) }
|
||||||
) { 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
|
@Composable
|
||||||
private fun SectionHeader(name: String) {
|
private fun Header(title: String, filter: TaskFilter) {
|
||||||
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 context = LocalContext.current
|
||||||
val open = when (filter) {
|
val open = when (filter) {
|
||||||
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
|
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
|
||||||
is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId)
|
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(
|
Row(
|
||||||
title = title,
|
modifier = GlanceModifier.fillMaxWidth().padding(start = 8.dp),
|
||||||
titleAction = actionStartActivity(open),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
WidgetHeaderAction(
|
Text(
|
||||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
text = title,
|
||||||
contentDescription = context.getString(R.string.new_task),
|
maxLines = 1,
|
||||||
action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)),
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
@Composable
|
||||||
internal fun AgendaTaskRow(
|
private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) {
|
||||||
row: WidgetTaskRow,
|
|
||||||
use24Hour: Boolean,
|
|
||||||
color: GlanceColorProvider,
|
|
||||||
colored: Boolean,
|
|
||||||
suppressDayLabel: Boolean,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
||||||
val open = actionStartActivity(
|
val open = actionStartActivity(
|
||||||
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
|
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
|
||||||
)
|
)
|
||||||
val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface
|
Row(
|
||||||
val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant
|
modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
val onColorError = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.error
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
val completing = WidgetCompletions.isCompleting(row)
|
) {
|
||||||
WidgetGroupedRow(position = Position.Alone, color = color) {
|
Box(
|
||||||
Row(
|
modifier = GlanceModifier
|
||||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
.width(4.dp)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
.height(32.dp)
|
||||||
) {
|
.cornerRadius(2.dp)
|
||||||
WidgetCheckButton(
|
.background(ColorProvider(day = pastelize(row.color, false), night = pastelize(row.color, true))),
|
||||||
contentDescription = context.getString(R.string.widget_complete_task, title),
|
) {}
|
||||||
action = actionRunCallback<CompleteTaskAction>(
|
Box(
|
||||||
actionParametersOf(
|
modifier = GlanceModifier
|
||||||
CompleteTaskAction.TaskIdKey to row.taskId,
|
.size(40.dp)
|
||||||
CompleteTaskAction.OccurrenceKey to
|
.clickable(
|
||||||
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
|
actionRunCallback<CompleteTaskAction>(
|
||||||
|
actionParametersOf(
|
||||||
|
CompleteTaskAction.TaskIdKey to row.taskId,
|
||||||
|
CompleteTaskAction.OccurrenceKey to
|
||||||
|
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
tint = onColorVariant,
|
contentAlignment = Alignment.Center,
|
||||||
checked = completing,
|
) {
|
||||||
|
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),
|
||||||
)
|
)
|
||||||
Spacer(GlanceModifier.width(8.dp))
|
}
|
||||||
Column(modifier = GlanceModifier.defaultWeight().clickable(open)) {
|
Column(
|
||||||
|
modifier = GlanceModifier.defaultWeight().padding(end = 8.dp, top = 4.dp, bottom = 4.dp)
|
||||||
|
.clickable(open),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||||
|
)
|
||||||
|
dueText(context, row, use24Hour)?.let { due ->
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = due,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = if (completing) onColorVariant else onColor,
|
color = if (row.isOverdue) GlanceTheme.colors.error
|
||||||
fontSize = 14.sp,
|
else GlanceTheme.colors.onSurfaceVariant,
|
||||||
fontWeight = FontWeight.Medium,
|
fontSize = 12.sp,
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Composable
|
||||||
* The chip's own due line — bare "14:00" when [suppressDayLabel] (the due-bucket
|
private fun Message(text: String) {
|
||||||
* header above already says "Today"/"Tomorrow", Calendar-agenda style: the day
|
Box(
|
||||||
* is the group, the chip only needs the time), otherwise "Today · 14:00" /
|
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||||
* "Tomorrow", or the compact date for anything else. `null` without a due date,
|
contentAlignment = Alignment.Center,
|
||||||
* or for an all-day task once its day is already said by the header.
|
) {
|
||||||
*/
|
Text(
|
||||||
internal fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean, suppressDayLabel: Boolean): String? {
|
text = text,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Today · 14:00", "Tomorrow", or the compact date; `null` without a due date. */
|
||||||
|
private fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String? {
|
||||||
val due = row.due ?: return null
|
val due = row.due ?: return null
|
||||||
val relative = when (row.dueKind) {
|
val relative = when (row.dueKind) {
|
||||||
DueKind.TODAY -> context.getString(R.string.home_due_today)
|
DueKind.TODAY -> context.getString(R.string.home_due_today)
|
||||||
DueKind.TOMORROW -> context.getString(R.string.home_due_tomorrow)
|
DueKind.TOMORROW -> context.getString(R.string.home_due_tomorrow)
|
||||||
else -> null
|
else -> null
|
||||||
} ?: return due.formatDateTimeCompact(row.isAllDay, use24Hour)
|
} ?: return due.formatDateTimeCompact(row.isAllDay, use24Hour)
|
||||||
if (row.isAllDay) return if (suppressDayLabel) null else relative
|
return if (row.isAllDay) relative else "$relative · ${due.formatTime(use24Hour)}"
|
||||||
val time = due.formatTime(use24Hour)
|
|
||||||
return if (suppressDayLabel) time else "$relative · $time"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
package de.jeanlucmakiola.agendula.widget
|
package de.jeanlucmakiola.agendula.widget
|
||||||
|
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Circle
|
import androidx.compose.material.icons.rounded.Circle
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
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.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -28,8 +22,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
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.GlanceAppWidgetManager
|
||||||
import androidx.glance.appwidget.state.getAppWidgetState
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
@@ -48,50 +40,17 @@ import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
|||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
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.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
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. [WidgetKind.TASKS] picks a smart
|
* Picks what one widget instance shows: a smart list or a real list. Runs when
|
||||||
* list or one or more real lists (with an optional "group by list" sort);
|
* the widget is placed (below API 31) and on "reconfigure" from the launcher.
|
||||||
* [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
|
@AndroidEntryPoint
|
||||||
class TaskWidgetConfigActivity : ComponentActivity() {
|
class TaskWidgetConfigActivity : ComponentActivity() {
|
||||||
@@ -109,7 +68,6 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
|||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val kind = widgetKindOf(this, appWidgetId) ?: WidgetKind.TASKS
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
val glanceId = GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId)
|
val glanceId = GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId)
|
||||||
val listsFlow = runCatching { repository.taskLists() }.getOrDefault(emptyFlow())
|
val listsFlow = runCatching { repository.taskLists() }.getOrDefault(emptyFlow())
|
||||||
@@ -119,19 +77,10 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
|||||||
val settings by settingsPrefs.settings.collectAsState(initial = Settings())
|
val settings by settingsPrefs.settings.collectAsState(initial = Settings())
|
||||||
val lists by remember { listsFlow }.collectAsState(initial = emptyList())
|
val lists by remember { listsFlow }.collectAsState(initial = emptyList())
|
||||||
var current by remember { mutableStateOf<TaskFilter?>(null) }
|
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) {
|
LaunchedEffect(glanceId) {
|
||||||
val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
|
current = parseWidgetFilter(
|
||||||
current = parseWidgetFilter(state[WIDGET_FILTER_KEY])
|
getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)[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) {
|
val dark = when (settings.themeMode) {
|
||||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||||
@@ -141,40 +90,15 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
|||||||
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
|
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
|
||||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||||
WidgetFilterPicker(
|
WidgetFilterPicker(
|
||||||
kind = kind,
|
|
||||||
lists = lists.filter { it.isVisible },
|
lists = lists.filter { it.isVisible },
|
||||||
dark = dark,
|
dark = dark,
|
||||||
selected = current ?: DEFAULT_WIDGET_FILTER,
|
selected = current ?: DEFAULT_WIDGET_FILTER,
|
||||||
groupByList = groupByList,
|
onSelect = { filter ->
|
||||||
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 {
|
lifecycleScope.launch {
|
||||||
updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) {
|
updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) {
|
||||||
when (kind) {
|
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
widgetFor(kind).update(this@TaskWidgetConfigActivity, glanceId)
|
TaskWidget().update(this@TaskWidgetConfigActivity, glanceId)
|
||||||
setResult(RESULT_OK, resultIntent(appWidgetId))
|
setResult(RESULT_OK, resultIntent(appWidgetId))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
@@ -191,202 +115,40 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WidgetFilterPicker(
|
private fun WidgetFilterPicker(
|
||||||
kind: WidgetKind,
|
|
||||||
lists: List<TaskList>,
|
lists: List<TaskList>,
|
||||||
selected: TaskFilter,
|
selected: TaskFilter,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
groupByList: Boolean,
|
onSelect: (TaskFilter) -> Unit,
|
||||||
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,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var showBackgroundPicker by remember { mutableStateOf(false) }
|
FullScreenPicker(title = stringResource(R.string.widget_config_title), onDismiss = onDismiss) {
|
||||||
var showThemePicker by remember { mutableStateOf(false) }
|
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
|
||||||
var showRowColorPicker by remember { mutableStateOf(false) }
|
WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
|
||||||
// Smart lists and real lists are mutually exclusive; only real lists support
|
val filter = TaskFilter.Smart(smart)
|
||||||
// picking more than one. `selectedLists` empty + `selectedSmart` non-null is
|
val isSelected = filter == selected
|
||||||
// a smart-list pick; the reverse (or both empty, before the stored filter
|
GroupedRow(
|
||||||
// loads) is a list pick.
|
title = stringResource(smartListLabel(smart)),
|
||||||
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
|
position = positionOf(index, WIDGET_SMART_LISTS.size),
|
||||||
var selectedLists by remember(selected) {
|
selected = isSelected,
|
||||||
mutableStateOf(
|
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||||
when (selected) {
|
onClick = { onSelect(filter) },
|
||||||
is TaskFilter.OfList -> setOf(selected.listId)
|
)
|
||||||
is TaskFilter.OfLists -> selected.listIds
|
}
|
||||||
is TaskFilter.Smart -> emptySet()
|
if (lists.isNotEmpty()) {
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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))
|
GroupedSectionHeader(stringResource(R.string.lists_header))
|
||||||
lists.forEachIndexed { index, list ->
|
lists.forEachIndexed { index, list ->
|
||||||
// `null` means "every list" (the default) until the user narrows it down.
|
val filter = TaskFilter.OfList(list.id)
|
||||||
val isSelected = selectedListIds?.let { list.id in it } ?: true
|
val isSelected = filter == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = list.name,
|
title = list.name,
|
||||||
position = positionOf(index, lists.size),
|
position = positionOf(index, lists.size),
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
|
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
|
||||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||||
onClick = {
|
onClick = { onSelect(filter) },
|
||||||
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 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,32 +158,3 @@ internal fun smartListLabel(list: SmartList): Int = when (list) {
|
|||||||
SmartList.OVERDUE -> R.string.smart_overdue
|
SmartList.OVERDUE -> R.string.smart_overdue
|
||||||
else -> R.string.smart_all
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.appwidget.AppWidgetManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
|
||||||
|
|
||||||
/** Host-facing receiver for [TaskWidget]; also keeps the midnight rollover armed. */
|
/** Host-facing receiver for [TaskWidget]; also keeps the midnight rollover armed. */
|
||||||
class TaskWidgetReceiver : GlanceAppWidgetReceiver() {
|
class TaskWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
@@ -12,17 +11,17 @@ class TaskWidgetReceiver : GlanceAppWidgetReceiver() {
|
|||||||
|
|
||||||
override fun onEnabled(context: Context) {
|
override fun onEnabled(context: Context) {
|
||||||
super.onEnabled(context)
|
super.onEnabled(context)
|
||||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
WidgetRolloverScheduler.sync(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDisabled(context: Context) {
|
override fun onDisabled(context: Context) {
|
||||||
super.onDisabled(context)
|
super.onDisabled(context)
|
||||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
WidgetRolloverScheduler.sync(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Self-heal on the system's `updatePeriodMillis` wake-up, should the alarm have been dropped. */
|
/** Self-heal on the system's `updatePeriodMillis` wake-up, should the alarm have been dropped. */
|
||||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
WidgetRolloverScheduler.sync(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package de.jeanlucmakiola.agendula.widget
|
package de.jeanlucmakiola.agendula.widget
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
@@ -36,9 +36,7 @@ class TaskWidgetUpdater @Inject constructor(
|
|||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() }
|
requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() }
|
||||||
}
|
}
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) { runCatching { WidgetRolloverScheduler.sync(context) } }
|
||||||
runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) }
|
|
||||||
}
|
|
||||||
// The process may have been started by a change it never heard about.
|
// The process may have been started by a change it never heard about.
|
||||||
requestRefresh()
|
requestRefresh()
|
||||||
}
|
}
|
||||||
@@ -50,8 +48,7 @@ class TaskWidgetUpdater @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun redraw() {
|
private suspend fun redraw() {
|
||||||
runCatching {
|
runCatching {
|
||||||
if (!WidgetRollover.hasPlacedWidgets(context, WIDGET_RECEIVER_CLASSES)) return@runCatching
|
if (WidgetRolloverScheduler.hasPlacedWidgets(context)) TaskWidget().updateAll(context)
|
||||||
updateAllWidgets(context)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
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.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.GlanceTheme
|
|
||||||
import androidx.glance.ImageProvider
|
|
||||||
import androidx.glance.LocalContext
|
|
||||||
import androidx.glance.LocalSize
|
|
||||||
import androidx.glance.action.clickable
|
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
|
||||||
import androidx.glance.appwidget.SizeMode
|
|
||||||
import androidx.glance.appwidget.action.actionStartActivity
|
|
||||||
import androidx.glance.appwidget.lazy.LazyColumn
|
|
||||||
import androidx.glance.appwidget.lazy.items
|
|
||||||
import androidx.glance.appwidget.provideContent
|
|
||||||
import androidx.glance.currentState
|
|
||||||
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.TextAlign
|
|
||||||
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.glance.FloretGlanceTheme
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetProgressRing
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
|
||||||
import de.jeanlucmakiola.floret.glance.R as GlanceR
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Today" home-screen widget: how far through today you are, as a ring —
|
|
||||||
* done ÷ (done + still open today/overdue) — with today's open tasks listed
|
|
||||||
* below (or beside, when wide) once the widget has room for them.
|
|
||||||
*/
|
|
||||||
class TodayWidget : GlanceAppWidget() {
|
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
|
||||||
|
|
||||||
// Exact, so the ring can grow with the real placement rather than a breakpoint.
|
|
||||||
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), WidgetKind.TODAY)
|
|
||||||
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_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,
|
|
||||||
) {
|
|
||||||
TodayWidgetBody(data, background)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val ready = data as? WidgetStatsData.Ready
|
|
||||||
val size = LocalSize.current
|
|
||||||
val contentWidth = size.width - Padding * 2
|
|
||||||
val contentHeight = size.height - Padding * 2 - HeaderHeight
|
|
||||||
WidgetSurface(background = background) {
|
|
||||||
Column(modifier = GlanceModifier.fillMaxSize().padding(Padding)) {
|
|
||||||
WidgetHeader(
|
|
||||||
title = context.getString(R.string.smart_today),
|
|
||||||
titleAction = actionStartActivity(MainActivity.todayIntent(context)),
|
|
||||||
) {
|
|
||||||
WidgetHeaderAction(
|
|
||||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
|
||||||
contentDescription = context.getString(R.string.new_task),
|
|
||||||
action = actionStartActivity(MainActivity.newTaskIntent(context)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(GlanceModifier.height(HeaderGap))
|
|
||||||
when {
|
|
||||||
data == null -> Unit
|
|
||||||
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable))
|
|
||||||
else -> {
|
|
||||||
val tasks = ready.snapshot.todayTasks
|
|
||||||
val openToday = actionStartActivity(MainActivity.todayIntent(context))
|
|
||||||
when {
|
|
||||||
tasks.isNotEmpty() && contentWidth >= MinSideBySideWidth -> {
|
|
||||||
val ring = minOf(contentHeight, contentWidth * 0.4f, MaxRing)
|
|
||||||
Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier.width(ring).fillMaxHeight().clickable(openToday),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
TodayRing(ready, ring)
|
|
||||||
}
|
|
||||||
Spacer(GlanceModifier.width(12.dp))
|
|
||||||
TodayTaskList(ready, GlanceModifier.defaultWeight().fillMaxHeight())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tasks.isNotEmpty() && contentHeight >= MinRing + ListGap + RowHeight -> {
|
|
||||||
val ring = minOf(contentWidth, MaxRing, maxOf(MinRing, contentHeight - ListGap - RowHeight * 2))
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier.fillMaxWidth().height(ring).clickable(openToday),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
TodayRing(ready, ring)
|
|
||||||
}
|
|
||||||
Spacer(GlanceModifier.height(ListGap))
|
|
||||||
TodayTaskList(ready, GlanceModifier.fillMaxWidth().defaultWeight())
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val ring = minOf(contentWidth, contentHeight, MaxRing)
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier.fillMaxSize().clickable(openToday),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
TodayRing(ready, ring)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TodayTaskList(ready: WidgetStatsData.Ready, modifier: GlanceModifier) {
|
|
||||||
val tasks = ready.snapshot.todayTasks
|
|
||||||
LazyColumn(modifier = modifier) {
|
|
||||||
items(tasks.size) { index ->
|
|
||||||
// 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.
|
|
||||||
Column {
|
|
||||||
AgendaTaskRow(
|
|
||||||
row = tasks[index],
|
|
||||||
use24Hour = ready.snapshot.use24Hour,
|
|
||||||
color = GlanceTheme.colors.surfaceVariant,
|
|
||||||
colored = false,
|
|
||||||
suppressDayLabel = true,
|
|
||||||
)
|
|
||||||
if (index < tasks.lastIndex) Spacer(GlanceModifier.height(6.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TodayRing(ready: WidgetStatsData.Ready, ringSize: Dp) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val snapshot = ready.snapshot
|
|
||||||
val total = snapshot.todayDone + snapshot.todayRemaining
|
|
||||||
val allDone = total > 0 && snapshot.todayRemaining == 0
|
|
||||||
val progress = if (total == 0) 0f else snapshot.todayDone.toFloat() / total
|
|
||||||
val indicator = if (allDone) GlanceTheme.colors.tertiary else GlanceTheme.colors.primary
|
|
||||||
// Label sizes scale with the ring so a large ring doesn't hold a tiny label.
|
|
||||||
val scale = (ringSize / MinRing).coerceIn(1f, 2f)
|
|
||||||
WidgetProgressRing(
|
|
||||||
progress = progress,
|
|
||||||
size = ringSize,
|
|
||||||
trackColor = GlanceTheme.colors.surfaceVariant,
|
|
||||||
indicatorColor = indicator,
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = GlanceModifier.padding(horizontal = ringSize * 0.15f)) {
|
|
||||||
if (total == 0) {
|
|
||||||
Text(
|
|
||||||
text = context.getString(R.string.widget_today_nothing_due),
|
|
||||||
maxLines = 2,
|
|
||||||
style = TextStyle(
|
|
||||||
color = GlanceTheme.colors.onSurfaceVariant,
|
|
||||||
fontSize = (11 * scale).sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else if (allDone) {
|
|
||||||
Text(
|
|
||||||
text = context.getString(R.string.widget_today_all_done),
|
|
||||||
maxLines = 2,
|
|
||||||
style = TextStyle(
|
|
||||||
color = GlanceTheme.colors.tertiary,
|
|
||||||
fontSize = (13 * scale).sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "${snapshot.todayDone}/$total",
|
|
||||||
style = TextStyle(
|
|
||||||
color = GlanceTheme.colors.onSurface,
|
|
||||||
fontSize = (18 * scale).sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val Padding = 8.dp
|
|
||||||
private val HeaderHeight = 36.dp
|
|
||||||
private val HeaderGap = 4.dp
|
|
||||||
private val ListGap = 8.dp
|
|
||||||
private val RowHeight = 48.dp
|
|
||||||
private val MinRing = 72.dp
|
|
||||||
private val MaxRing = 160.dp
|
|
||||||
private val MinSideBySideWidth = 230.dp
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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 [TodayWidget]; also keeps the midnight rollover armed. */
|
|
||||||
class TodayWidgetReceiver : GlanceAppWidgetReceiver() {
|
|
||||||
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
|
|
||||||
|
|
||||||
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,410 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.widget
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.text.format.DateFormat
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
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.GlanceTheme
|
|
||||||
import androidx.glance.ImageProvider
|
|
||||||
import androidx.glance.LocalContext
|
|
||||||
import androidx.glance.LocalSize
|
|
||||||
import androidx.glance.action.Action
|
|
||||||
import androidx.glance.action.actionParametersOf
|
|
||||||
import androidx.glance.action.clickable
|
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
|
||||||
import androidx.glance.appwidget.SizeMode
|
|
||||||
import androidx.glance.appwidget.action.actionRunCallback
|
|
||||||
import androidx.glance.appwidget.action.actionStartActivity
|
|
||||||
import androidx.glance.appwidget.cornerRadius
|
|
||||||
import androidx.glance.appwidget.provideContent
|
|
||||||
import androidx.glance.background
|
|
||||||
import androidx.glance.color.ColorProvider
|
|
||||||
import androidx.glance.currentState
|
|
||||||
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.size
|
|
||||||
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 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.WidgetHeaderAction
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetShape
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetShapedIconButton
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
|
||||||
import de.jeanlucmakiola.floret.time.formatTime
|
|
||||||
import de.jeanlucmakiola.floret.glance.R as GlanceR
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Instant
|
|
||||||
import kotlin.time.toJavaInstant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Up next" home-screen widget: the single open task with the nearest due
|
|
||||||
* moment, big, with a check to complete it in place — a countdown rather
|
|
||||||
* than a list, for someone who wants to see one thing at a time. Optionally
|
|
||||||
* narrowed to one list; when tall, the tasks after it are listed below.
|
|
||||||
*/
|
|
||||||
class UpNextWidget : GlanceAppWidget() {
|
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
|
||||||
|
|
||||||
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), WidgetKind.UP_NEXT)
|
|
||||||
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
|
|
||||||
val listId = parseWidgetListIds(currentState(WIDGET_LIST_IDS_KEY))?.singleOrNull()
|
|
||||||
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,
|
|
||||||
) {
|
|
||||||
UpNextWidgetBody(data, background, listId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun UpNextWidgetBody(data: WidgetStatsData?, background: WidgetBackground, listId: Long?) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val ready = data as? WidgetStatsData.Ready
|
|
||||||
val size = LocalSize.current
|
|
||||||
val rows = ready?.snapshot?.upcoming?.let { all -> listId?.let { id -> all.filter { it.listId == id } } ?: all }
|
|
||||||
val list = listId?.let { id -> ready?.snapshot?.lists?.firstOrNull { it.id == id } }
|
|
||||||
WidgetSurface(background = background) {
|
|
||||||
when {
|
|
||||||
data == null -> Unit
|
|
||||||
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp))
|
|
||||||
rows.isNullOrEmpty() -> UpNextEmpty(listId)
|
|
||||||
size.height >= MinTallHeight -> UpNextTall(rows, ready.snapshot, list, size.height)
|
|
||||||
else -> UpNextCompact(rows.first(), ready.snapshot, size.width, size.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun UpNextEmpty(listId: Long?) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
Row(modifier = GlanceModifier.fillMaxSize().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
WidgetMessage(context.getString(R.string.widget_up_next_empty), GlanceModifier.defaultWeight())
|
|
||||||
Spacer(GlanceModifier.width(8.dp))
|
|
||||||
WidgetActionButton(
|
|
||||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
|
||||||
contentDescription = context.getString(R.string.new_task),
|
|
||||||
action = actionStartActivity(MainActivity.newTaskIntent(context, listId)),
|
|
||||||
style = WidgetActionStyle.FILLED,
|
|
||||||
size = 36.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One row tall: a cookie-shaped check leading, then the time as the hero and the title under it. */
|
|
||||||
@Composable
|
|
||||||
private fun UpNextCompact(row: WidgetTaskRow, snapshot: WidgetStatsSnapshot, width: Dp, height: Dp) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
|
||||||
val now = Clock.System.now()
|
|
||||||
val accent = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.primary
|
|
||||||
val checkSize = (height * 0.45f).coerceIn(32.dp, 44.dp)
|
|
||||||
val heroSize = (height.value * 0.25f).coerceIn(16f, 22f)
|
|
||||||
Row(
|
|
||||||
modifier = GlanceModifier.fillMaxSize().padding(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
CompleteButton(row, title, checkSize)
|
|
||||||
Spacer(GlanceModifier.width(12.dp))
|
|
||||||
Column(modifier = GlanceModifier.defaultWeight().clickable(openTask(context, row))) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = upNextHero(context, row, snapshot.use24Hour),
|
|
||||||
maxLines = 1,
|
|
||||||
style = TextStyle(color = accent, fontSize = heroSize.sp, fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
if (width >= MinRelativeInlineWidth) {
|
|
||||||
Spacer(GlanceModifier.width(8.dp))
|
|
||||||
RowPill(row, upNextLabel(context, row, now))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
maxLines = if (height >= 88.dp) 2 else 1,
|
|
||||||
style = titleStyle(row, 15f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tall: the list as a caption with the add pill, then a big cookie check
|
|
||||||
* beside a display-size time and the title, and — with room to spare —
|
|
||||||
* what comes after it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun UpNextTall(rows: List<WidgetTaskRow>, snapshot: WidgetStatsSnapshot, list: ListTile?, height: Dp) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val row = rows.first()
|
|
||||||
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
|
||||||
val now = Clock.System.now()
|
|
||||||
val accent = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.primary
|
|
||||||
val heroSize = (height.value * 0.15f).coerceIn(26f, 38f)
|
|
||||||
val checkSize = (height * 0.2f).coerceIn(44.dp, 56.dp)
|
|
||||||
val laterCount = ((height - MinTallHeight - 40.dp) / LaterRowHeight).toInt().coerceIn(0, minOf(rows.size - 1, MAX_LATER_ROWS))
|
|
||||||
Column(modifier = GlanceModifier.fillMaxSize().padding(12.dp)) {
|
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
ListDot(row.color)
|
|
||||||
Spacer(GlanceModifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = list?.name ?: context.getString(R.string.widget_up_next_label),
|
|
||||||
maxLines = 1,
|
|
||||||
modifier = GlanceModifier.defaultWeight(),
|
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
WidgetHeaderAction(
|
|
||||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
|
||||||
contentDescription = context.getString(R.string.new_task),
|
|
||||||
action = actionStartActivity(MainActivity.newTaskIntent(context, list?.id)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
CompleteButton(row, title, checkSize)
|
|
||||||
Spacer(GlanceModifier.width(12.dp))
|
|
||||||
Column(modifier = GlanceModifier.defaultWeight().clickable(openTask(context, row))) {
|
|
||||||
Text(
|
|
||||||
text = upNextHero(context, row, snapshot.use24Hour),
|
|
||||||
maxLines = 1,
|
|
||||||
style = TextStyle(color = accent, fontSize = heroSize.sp, fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
RowPill(row, upNextLabel(context, row, now))
|
|
||||||
Spacer(GlanceModifier.height(6.dp))
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
maxLines = if (laterCount > 0) 2 else 3,
|
|
||||||
style = titleStyle(row, 16f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (laterCount > 0) {
|
|
||||||
Text(
|
|
||||||
text = context.getString(R.string.widget_up_next_then),
|
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp, fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
Spacer(GlanceModifier.height(4.dp))
|
|
||||||
Column { rows.drop(1).take(laterCount).forEach { later -> LaterRow(later, snapshot.use24Hour) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The cookie check; while [WidgetCompletions] holds the row it bursts into a filled sunny shape. */
|
|
||||||
@Composable
|
|
||||||
private fun CompleteButton(row: WidgetTaskRow, title: String, size: Dp) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val completing = WidgetCompletions.isCompleting(row)
|
|
||||||
WidgetShapedIconButton(
|
|
||||||
shape = if (completing) WidgetShape.SUNNY else WidgetShape.COOKIE,
|
|
||||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_check),
|
|
||||||
contentDescription = context.getString(R.string.widget_complete_task, title),
|
|
||||||
action = completeTask(row),
|
|
||||||
color = when {
|
|
||||||
completing -> GlanceTheme.colors.tertiary
|
|
||||||
row.isOverdue -> GlanceTheme.colors.errorContainer
|
|
||||||
else -> GlanceTheme.colors.primaryContainer
|
|
||||||
},
|
|
||||||
iconTint = when {
|
|
||||||
completing -> GlanceTheme.colors.onTertiary
|
|
||||||
row.isOverdue -> GlanceTheme.colors.onErrorContainer
|
|
||||||
else -> GlanceTheme.colors.onPrimaryContainer
|
|
||||||
},
|
|
||||||
size = size,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RelativePill(text: String, overdue: Boolean, done: Boolean = false) {
|
|
||||||
val container = when {
|
|
||||||
done -> GlanceTheme.colors.tertiaryContainer
|
|
||||||
overdue -> GlanceTheme.colors.errorContainer
|
|
||||||
else -> GlanceTheme.colors.secondaryContainer
|
|
||||||
}
|
|
||||||
val content = when {
|
|
||||||
done -> GlanceTheme.colors.onTertiaryContainer
|
|
||||||
overdue -> GlanceTheme.colors.onErrorContainer
|
|
||||||
else -> GlanceTheme.colors.onSecondaryContainer
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier.cornerRadius(10.dp)
|
|
||||||
.background(container)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
maxLines = 1,
|
|
||||||
style = TextStyle(color = content, fontSize = 11.sp, fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The relative pill's text, or "Done" while the row is being ticked off. */
|
|
||||||
@Composable
|
|
||||||
private fun RowPill(row: WidgetTaskRow, relative: String) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
if (WidgetCompletions.isCompleting(row)) {
|
|
||||||
RelativePill(context.getString(R.string.reminder_action_done), row.isOverdue, done = true)
|
|
||||||
} else if (relative.isNotEmpty()) {
|
|
||||||
RelativePill(relative, row.isOverdue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun titleStyle(row: WidgetTaskRow, fontSize: Float): TextStyle {
|
|
||||||
val completing = WidgetCompletions.isCompleting(row)
|
|
||||||
return TextStyle(
|
|
||||||
color = if (completing) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface,
|
|
||||||
fontSize = fontSize.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
textDecoration = if (completing) TextDecoration.LineThrough else TextDecoration.None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LaterRow(row: WidgetTaskRow, use24Hour: Boolean) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
Row(
|
|
||||||
modifier = GlanceModifier.fillMaxWidth().height(LaterRowHeight).clickable(openTask(context, row)),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
ListDot(row.color)
|
|
||||||
Spacer(GlanceModifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = row.title.ifBlank { context.getString(R.string.task_untitled) },
|
|
||||||
maxLines = 1,
|
|
||||||
modifier = GlanceModifier.defaultWeight(),
|
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = upNextHero(context, row, use24Hour),
|
|
||||||
maxLines = 1,
|
|
||||||
style = TextStyle(
|
|
||||||
color = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.onSurfaceVariant,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ListDot(color: Int) {
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier.size(8.dp).cornerRadius(4.dp)
|
|
||||||
.background(ColorProvider(day = pastelize(color, false), night = pastelize(color, true))),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openTask(context: Context, row: WidgetTaskRow): Action =
|
|
||||||
actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()))
|
|
||||||
|
|
||||||
private fun completeTask(row: WidgetTaskRow): Action =
|
|
||||||
actionRunCallback<CompleteTaskAction>(
|
|
||||||
actionParametersOf(
|
|
||||||
CompleteTaskAction.TaskIdKey to row.taskId,
|
|
||||||
CompleteTaskAction.OccurrenceKey to (row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The big line: a timed task's clock time (with its weekday once it's past
|
|
||||||
* tomorrow), or an all-day task's day.
|
|
||||||
*/
|
|
||||||
internal fun upNextHero(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String {
|
|
||||||
val due = row.due ?: return ""
|
|
||||||
val locale = context.resources.configuration.locales[0]
|
|
||||||
val day = DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, "EEEd"), locale)
|
|
||||||
.format(due.toJavaInstant().atZone(ZoneId.systemDefault()))
|
|
||||||
if (row.isAllDay) {
|
|
||||||
return when (row.dueKind) {
|
|
||||||
DueKind.TODAY -> context.getString(R.string.smart_today)
|
|
||||||
DueKind.TOMORROW -> context.getString(R.string.widget_up_next_tomorrow)
|
|
||||||
else -> day
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val time = due.formatTime(use24Hour)
|
|
||||||
return if (row.dueKind == DueKind.LATER && !row.isOverdue) "$day $time" else time
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The relative line beside [upNextHero]: "in 3 h", "Tomorrow", "2 d overdue".
|
|
||||||
* Coarse on purpose — this widget only redraws on data changes, at midnight,
|
|
||||||
* and on the half-hourly `updatePeriodMillis`, so a minute-level countdown
|
|
||||||
* would just look wrong between redraws.
|
|
||||||
*/
|
|
||||||
internal fun upNextLabel(context: Context, row: WidgetTaskRow, now: Instant): String {
|
|
||||||
val due = row.due ?: return ""
|
|
||||||
if (row.isAllDay && !row.isOverdue) return if (row.dueKind == DueKind.LATER) daysUntil(context, due, now) else ""
|
|
||||||
if (row.isOverdue) {
|
|
||||||
val hours = (now - due).inWholeHours
|
|
||||||
return when {
|
|
||||||
hours < 1 -> context.getString(R.string.widget_up_next_soon)
|
|
||||||
hours < HOURS_PER_DAY -> context.resources.getQuantityString(R.plurals.widget_up_next_overdue_hours, hours.toInt(), hours.toInt())
|
|
||||||
else -> {
|
|
||||||
val days = hours / HOURS_PER_DAY
|
|
||||||
context.resources.getQuantityString(R.plurals.widget_up_next_overdue_days, days.toInt(), days.toInt())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (row.dueKind == DueKind.TOMORROW) return context.getString(R.string.widget_up_next_tomorrow)
|
|
||||||
val hours = (due - now).inWholeHours
|
|
||||||
return when {
|
|
||||||
hours < 1 -> context.getString(R.string.widget_up_next_soon)
|
|
||||||
hours < HOURS_PER_DAY -> context.resources.getQuantityString(R.plurals.widget_up_next_in_hours, hours.toInt(), hours.toInt())
|
|
||||||
else -> daysUntil(context, due, now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun daysUntil(context: Context, due: Instant, now: Instant): String {
|
|
||||||
val days = ((due - now).inWholeHours / HOURS_PER_DAY).toInt().coerceAtLeast(1)
|
|
||||||
return context.resources.getQuantityString(R.plurals.widget_up_next_in_days, days, days)
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val HOURS_PER_DAY = 24L
|
|
||||||
private const val MAX_LATER_ROWS = 10
|
|
||||||
private val MinTallHeight = 150.dp
|
|
||||||
private val MinRelativeInlineWidth = 200.dp
|
|
||||||
private val LaterRowHeight = 24.dp
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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 [UpNextWidget]; also keeps the midnight rollover armed. */
|
|
||||||
class UpNextWidgetReceiver : GlanceAppWidgetReceiver() {
|
|
||||||
override val glanceAppWidget: GlanceAppWidget = UpNextWidget()
|
|
||||||
|
|
||||||
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,177 +0,0 @@
|
|||||||
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.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.LocalSize
|
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
|
||||||
import androidx.glance.appwidget.SizeMode
|
|
||||||
import androidx.glance.appwidget.action.actionStartActivity
|
|
||||||
import androidx.glance.appwidget.cornerRadius
|
|
||||||
import androidx.glance.appwidget.provideContent
|
|
||||||
import androidx.glance.background
|
|
||||||
import androidx.glance.currentState
|
|
||||||
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.height
|
|
||||||
import androidx.glance.layout.padding
|
|
||||||
import androidx.glance.layout.size
|
|
||||||
import androidx.glance.layout.width
|
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
|
||||||
import androidx.glance.text.FontWeight
|
|
||||||
import androidx.glance.text.Text
|
|
||||||
import androidx.glance.text.TextAlign
|
|
||||||
import androidx.glance.text.TextStyle
|
|
||||||
import de.jeanlucmakiola.agendula.MainActivity
|
|
||||||
import de.jeanlucmakiola.agendula.R
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
|
||||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
|
|
||||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
|
|
||||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
|
||||||
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 java.time.LocalDate
|
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Week" home-screen widget: a seven-day strip, one tile per day, each with
|
|
||||||
* its open-task count — the one shape none of the app's other widgets or
|
|
||||||
* screens offer (they're all "one list" or "one task" shaped).
|
|
||||||
*/
|
|
||||||
class WeekWidget : GlanceAppWidget() {
|
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
|
||||||
|
|
||||||
// Exact, so the day count can follow the real width.
|
|
||||||
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 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,
|
|
||||||
) {
|
|
||||||
WeekWidgetBody(data, background)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WeekWidgetBody(data: WidgetStatsData?, background: WidgetBackground) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val ready = data as? WidgetStatsData.Ready
|
|
||||||
WidgetSurface(background = background) {
|
|
||||||
when {
|
|
||||||
data == null -> Unit
|
|
||||||
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp))
|
|
||||||
else -> {
|
|
||||||
// As many days as fit at a comfortable width, up to the week.
|
|
||||||
// Gaps come from each cell's padding, not Spacers: Glance caps a
|
|
||||||
// Row at 10 children.
|
|
||||||
val days = ((LocalSize.current.width - 8.dp) / MinDayWidth).toInt().coerceIn(1, ready.snapshot.week.size)
|
|
||||||
Row(modifier = GlanceModifier.fillMaxSize().padding(4.dp)) {
|
|
||||||
ready.snapshot.week.take(days).forEach { day ->
|
|
||||||
// Glance has no margin — padding on the tile itself lands inside its fill.
|
|
||||||
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(horizontal = 2.dp)) {
|
|
||||||
WeekDayCell(day, GlanceModifier.fillMaxSize())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WeekDayCell(day: WeekDay, modifier: GlanceModifier) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val today = isToday(day.date)
|
|
||||||
val background = if (today) GlanceTheme.colors.primaryContainer else GlanceTheme.colors.surfaceVariant
|
|
||||||
val onBackground = if (today) GlanceTheme.colors.onPrimaryContainer else GlanceTheme.colors.onSurfaceVariant
|
|
||||||
val open = if (today) {
|
|
||||||
actionStartActivity(MainActivity.todayIntent(context))
|
|
||||||
} else {
|
|
||||||
actionStartActivity(MainActivity.smartListIntent(context, SmartList.UPCOMING))
|
|
||||||
}
|
|
||||||
val badgeBackground = if (today) GlanceTheme.colors.primary else GlanceTheme.colors.surface
|
|
||||||
val onBadgeBackground = if (today) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurfaceVariant
|
|
||||||
WidgetTile(color = background, modifier = modifier, onClick = open) {
|
|
||||||
Column(modifier = GlanceModifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
val locale = context.resources.configuration.locales[0] ?: Locale.ROOT
|
|
||||||
Spacer(GlanceModifier.defaultWeight())
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.size(38.dp)
|
|
||||||
.cornerRadius(19.dp)
|
|
||||||
.background(badgeBackground),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
text = day.date.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale),
|
|
||||||
maxLines = 1,
|
|
||||||
style = TextStyle(color = onBadgeBackground, fontSize = 10.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${day.date.dayOfMonth}",
|
|
||||||
style = TextStyle(color = onBadgeBackground, fontSize = 13.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(GlanceModifier.height(3.dp))
|
|
||||||
// Always rendered, even at 0 — an omitted count row made every
|
|
||||||
// other day's badge drift up or down relative to its neighbours.
|
|
||||||
// The checklist glyph (not a single checkmark) is what reads this
|
|
||||||
// as "N tasks" rather than an unlabelled, ambiguous number.
|
|
||||||
val countColor = if (day.overdueCount > 0) GlanceTheme.colors.error else onBackground
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Image(
|
|
||||||
provider = ImageProvider(GlanceR.drawable.floret_ic_widget_checklist),
|
|
||||||
contentDescription = null,
|
|
||||||
colorFilter = ColorFilter.tint(countColor),
|
|
||||||
modifier = GlanceModifier.size(14.dp),
|
|
||||||
)
|
|
||||||
Spacer(GlanceModifier.width(2.dp))
|
|
||||||
Text(
|
|
||||||
text = "${day.count}",
|
|
||||||
style = TextStyle(color = countColor, fontSize = 15.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(GlanceModifier.defaultWeight())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isToday(date: LocalDate): Boolean = date == java.time.LocalDate.now()
|
|
||||||
|
|
||||||
private val MinDayWidth = 64.dp
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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 [WeekWidget]; also keeps the midnight rollover armed. */
|
|
||||||
class WeekWidgetReceiver : GlanceAppWidgetReceiver() {
|
|
||||||
override val glanceAppWidget: GlanceAppWidget = WeekWidget()
|
|
||||||
|
|
||||||
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,31 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.widget
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rows ticked in a widget but not yet completed in the store. Widgets can't
|
|
||||||
* animate, so [CompleteTaskAction] shows the row as done for a beat before
|
|
||||||
* completing it — every widget drawing that row reads this to render the
|
|
||||||
* ticked frame.
|
|
||||||
*/
|
|
||||||
internal object WidgetCompletions {
|
|
||||||
private val keys = MutableStateFlow<Set<String>>(emptySet())
|
|
||||||
|
|
||||||
fun key(taskId: Long, occurrenceStart: Long?): String = "$taskId@${occurrenceStart ?: ""}"
|
|
||||||
|
|
||||||
/** Marks [key] as completing; `false` if it already was (a double tap). */
|
|
||||||
fun start(key: String): Boolean {
|
|
||||||
var added = false
|
|
||||||
keys.update { current -> if (key in current) current else current + key.also { added = true } }
|
|
||||||
return added
|
|
||||||
}
|
|
||||||
|
|
||||||
fun finish(key: String) = keys.update { it - key }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun isCompleting(row: WidgetTaskRow): Boolean =
|
|
||||||
key(row.taskId, row.occurrenceStart?.toEpochMilliseconds()) in keys.collectAsState().value
|
|
||||||
}
|
|
||||||
@@ -4,15 +4,12 @@ import android.content.Context
|
|||||||
import android.text.format.DateFormat
|
import android.text.format.DateFormat
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.calendarDate
|
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||||
import de.jeanlucmakiola.agendula.domain.comparator
|
import de.jeanlucmakiola.agendula.domain.comparator
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -24,81 +21,19 @@ import kotlin.time.Instant
|
|||||||
/** Per-instance Glance state key holding the widget's [TaskFilter] ([widgetStorageValue]). */
|
/** Per-instance Glance state key holding the widget's [TaskFilter] ([widgetStorageValue]). */
|
||||||
internal val WIDGET_FILTER_KEY = stringPreferencesKey("task_filter")
|
internal val WIDGET_FILTER_KEY = stringPreferencesKey("task_filter")
|
||||||
|
|
||||||
/** Per-instance Glance state key: group a multi-list widget's rows under list headers. */
|
|
||||||
internal val WIDGET_GROUP_BY_LIST_KEY = booleanPreferencesKey("group_by_list")
|
|
||||||
|
|
||||||
/** Per-instance Glance state key holding the widget's [WidgetBackground] ([name]); unset = [WidgetBackground.SOLID]. */
|
|
||||||
internal val WIDGET_BACKGROUND_KEY = stringPreferencesKey("widget_background")
|
|
||||||
|
|
||||||
/** Per-instance Glance state key holding a [WidgetThemeMode] override; unset = follow the app's own theme setting. */
|
|
||||||
internal val WIDGET_THEME_KEY = stringPreferencesKey("widget_theme")
|
|
||||||
|
|
||||||
/** Per-instance Glance state key holding the widget's [WidgetRowColor] ([name]); unset = [WidgetRowColor.NEUTRAL]. */
|
|
||||||
internal val WIDGET_ROW_COLOR_KEY = stringPreferencesKey("widget_row_color")
|
|
||||||
|
|
||||||
/** Per-instance Glance state key holding the Lists widget's chosen list ids, comma-joined; unset = every visible list. */
|
|
||||||
internal val WIDGET_LIST_IDS_KEY = stringPreferencesKey("widget_list_ids")
|
|
||||||
|
|
||||||
/** `null` means "every visible list" — the Lists widget's default. */
|
|
||||||
internal fun parseWidgetListIds(raw: String?): Set<Long>? =
|
|
||||||
raw?.split(",")?.mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }?.toSet()?.takeIf { it.isNotEmpty() }
|
|
||||||
|
|
||||||
internal fun Set<Long>.widgetListIdsStorageValue(): String = joinToString(",")
|
|
||||||
|
|
||||||
internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY)
|
internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY)
|
||||||
|
|
||||||
/** How a row's tonal container is tinted: the neutral surface role, or the task's own list colour. */
|
|
||||||
internal enum class WidgetRowColor { NEUTRAL, BY_LIST }
|
|
||||||
|
|
||||||
internal fun parseWidgetRowColor(raw: String?): WidgetRowColor =
|
|
||||||
raw?.let { runCatching { WidgetRowColor.valueOf(it) }.getOrNull() } ?: WidgetRowColor.NEUTRAL
|
|
||||||
|
|
||||||
internal fun parseWidgetBackground(raw: String?): WidgetBackground =
|
|
||||||
raw?.let { runCatching { WidgetBackground.valueOf(it) }.getOrNull() } ?: WidgetBackground.SOLID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [WidgetBackground]s a widget offers. Today and Up next draw their hero (ring
|
|
||||||
* label, time and title) straight on the surface rather than in containers, so
|
|
||||||
* on [WidgetBackground.TRANSPARENT] it would sit unreadable on the wallpaper.
|
|
||||||
*/
|
|
||||||
internal fun WidgetKind.backgrounds(): List<WidgetBackground> = when (this) {
|
|
||||||
WidgetKind.TODAY, WidgetKind.UP_NEXT -> WidgetBackground.entries - WidgetBackground.TRANSPARENT
|
|
||||||
else -> WidgetBackground.entries
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [raw] parsed, falling back to SOLID for a stored value [kind] no longer offers. */
|
|
||||||
internal fun parseWidgetBackground(raw: String?, kind: WidgetKind): WidgetBackground =
|
|
||||||
parseWidgetBackground(raw).takeIf { it in kind.backgrounds() } ?: WidgetBackground.SOLID
|
|
||||||
|
|
||||||
/** `null` means "follow the app's own theme setting" — resolved by the caller against [TaskWidgetData.Ready.themeMode]. */
|
|
||||||
internal fun parseWidgetThemeOverride(raw: String?): WidgetThemeMode? =
|
|
||||||
raw?.let { runCatching { WidgetThemeMode.valueOf(it) }.getOrNull() }
|
|
||||||
|
|
||||||
internal fun TaskWidgetData.Ready.effectiveThemeMode(override: WidgetThemeMode?): WidgetThemeMode =
|
|
||||||
effectiveThemeMode(themeMode, override)
|
|
||||||
|
|
||||||
/** [override], or [appThemeMode] translated to its Glance counterpart — shared by every widget's data type. */
|
|
||||||
internal fun effectiveThemeMode(appThemeMode: ThemeMode, override: WidgetThemeMode?): WidgetThemeMode =
|
|
||||||
override ?: when (appThemeMode) {
|
|
||||||
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
|
|
||||||
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
|
|
||||||
ThemeMode.DARK -> WidgetThemeMode.DARK
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The smart lists a widget can show, in picker order. */
|
/** The smart lists a widget can show, in picker order. */
|
||||||
internal val WIDGET_SMART_LISTS = listOf(SmartList.TODAY, SmartList.UPCOMING, SmartList.OVERDUE, SmartList.ALL)
|
internal val WIDGET_SMART_LISTS = listOf(SmartList.TODAY, SmartList.UPCOMING, SmartList.OVERDUE, SmartList.ALL)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upper bound on rows, so the serialized RemoteViews stays well inside the
|
* Upper bound on rows, so the serialized RemoteViews stays well inside the
|
||||||
* binder transaction limit however long the list is. Lower than the old flat
|
* binder transaction limit however long the list is.
|
||||||
* list's 60: each row is now wrapped in its own tonal container, so the
|
|
||||||
* per-row view count is higher for the same visual density (calendula#214).
|
|
||||||
*/
|
*/
|
||||||
internal const val MAX_WIDGET_ROWS = 40
|
internal const val MAX_WIDGET_ROWS = 60
|
||||||
|
|
||||||
internal fun TaskFilter.widgetStorageValue(): String = when (this) {
|
internal fun TaskFilter.widgetStorageValue(): String = when (this) {
|
||||||
is TaskFilter.OfList -> "list:$listId"
|
is TaskFilter.OfList -> "list:$listId"
|
||||||
is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}"
|
|
||||||
is TaskFilter.Smart -> "smart:${list.name}"
|
is TaskFilter.Smart -> "smart:${list.name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,9 +43,6 @@ internal fun parseWidgetFilter(raw: String?): TaskFilter {
|
|||||||
return when {
|
return when {
|
||||||
value.startsWith("list:") ->
|
value.startsWith("list:") ->
|
||||||
value.removePrefix("list:").toLongOrNull()?.takeIf { it > 0 }?.let { TaskFilter.OfList(it) }
|
value.removePrefix("list:").toLongOrNull()?.takeIf { it > 0 }?.let { TaskFilter.OfList(it) }
|
||||||
value.startsWith("lists:") ->
|
|
||||||
value.removePrefix("lists:").split(",").mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }
|
|
||||||
.toSet().takeIf { it.isNotEmpty() }?.let { TaskFilter.OfLists(it) }
|
|
||||||
value.startsWith("smart:") ->
|
value.startsWith("smart:") ->
|
||||||
WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) }
|
WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) }
|
||||||
else -> null
|
else -> null
|
||||||
@@ -137,7 +69,6 @@ internal fun isOverdue(due: Instant, allDay: Boolean, now: Instant, today: Local
|
|||||||
|
|
||||||
data class WidgetTaskRow(
|
data class WidgetTaskRow(
|
||||||
val taskId: Long,
|
val taskId: Long,
|
||||||
val listId: Long,
|
|
||||||
val occurrenceStart: Instant?,
|
val occurrenceStart: Instant?,
|
||||||
val title: String,
|
val title: String,
|
||||||
val due: Instant?,
|
val due: Instant?,
|
||||||
@@ -153,121 +84,52 @@ sealed interface TaskWidgetData {
|
|||||||
|
|
||||||
data class Ready(
|
data class Ready(
|
||||||
val filter: TaskFilter,
|
val filter: TaskFilter,
|
||||||
/** Names of the real lists [rows] can belong to, by list id. */
|
/** The real list's name; `null` for a smart list or a list that no longer exists. */
|
||||||
val listNames: Map<Long, String>,
|
val listName: String?,
|
||||||
val rows: List<WidgetTaskRow>,
|
val rows: List<WidgetTaskRow>,
|
||||||
/** Group [rows] under a header per list (only meaningful with several lists). */
|
|
||||||
val groupByList: Boolean,
|
|
||||||
val dynamicColor: Boolean,
|
val dynamicColor: Boolean,
|
||||||
val themeMode: ThemeMode,
|
val themeMode: ThemeMode,
|
||||||
val use24Hour: Boolean,
|
val use24Hour: Boolean,
|
||||||
) : TaskWidgetData {
|
) : TaskWidgetData
|
||||||
/** The one list's id, when [filter] names exactly one real list — seeds a widget's add button. */
|
|
||||||
val singleListId: Long? = when (filter) {
|
|
||||||
is TaskFilter.OfList -> filter.listId
|
|
||||||
is TaskFilter.OfLists -> filter.listIds.singleOrNull()
|
|
||||||
is TaskFilter.Smart -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [singleListId]'s name. */
|
|
||||||
val singleListName: String? = singleListId?.let(listNames::get)
|
|
||||||
|
|
||||||
val overdueCount: Int = rows.count { it.isOverdue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A run of rows in the widget's list: one task, or a run's header. */
|
|
||||||
internal sealed interface WidgetSection {
|
|
||||||
data class Header(val key: String, val name: String) : WidgetSection
|
|
||||||
data class TaskItem(val row: WidgetTaskRow) : WidgetSection
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One headed run: its header's stable [key] and display [name], and the rows under it. */
|
|
||||||
private data class WidgetRun(val key: String, val name: String, val rows: List<WidgetTaskRow>)
|
|
||||||
|
|
||||||
/** Each [WidgetRun] as its own [WidgetSection.Header] followed by its rows. */
|
|
||||||
private fun headedRuns(runs: List<WidgetRun>): List<WidgetSection> =
|
|
||||||
runs.flatMap { run -> listOf(WidgetSection.Header(run.key, run.name)) + run.rows.map(WidgetSection::TaskItem) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [rows] split into headed runs: one per list when [groupByList] is on
|
|
||||||
* (already sorted that way by [widgetRows]), otherwise one per [DueKind]
|
|
||||||
* bucket — "Overdue" / "Today" / "Tomorrow" / "Later" / "No date" — in that
|
|
||||||
* fixed order, since [rows] isn't due-sorted. Each run becomes one header
|
|
||||||
* followed by its own standalone, independently coloured task pills.
|
|
||||||
*/
|
|
||||||
internal fun widgetSections(
|
|
||||||
rows: List<WidgetTaskRow>,
|
|
||||||
groupByList: Boolean,
|
|
||||||
listNames: Map<Long, String>,
|
|
||||||
dueBucketLabel: (DueKind?) -> String,
|
|
||||||
): List<WidgetSection> {
|
|
||||||
if (groupByList) {
|
|
||||||
val runs = mutableListOf<WidgetRun>()
|
|
||||||
var current: MutableList<WidgetTaskRow>? = null
|
|
||||||
for (row in rows) {
|
|
||||||
if (current == null || runs.last().key != "list:${row.listId}") {
|
|
||||||
current = mutableListOf()
|
|
||||||
runs += WidgetRun("list:${row.listId}", listNames[row.listId].orEmpty(), current)
|
|
||||||
}
|
|
||||||
current.add(row)
|
|
||||||
}
|
|
||||||
return headedRuns(runs)
|
|
||||||
}
|
|
||||||
val buckets = rows.groupBy { it.dueKind }
|
|
||||||
val runs = DueKind.entries.plus(null).mapNotNull { kind ->
|
|
||||||
buckets[kind]?.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { WidgetRun("due:${kind?.name.orEmpty()}", dueBucketLabel(kind), it) }
|
|
||||||
}
|
|
||||||
return headedRuns(runs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open rows for [tasks], sorted the way the app sorts them, then grouped by list
|
* Open rows for [tasks], sorted the way the app sorts them. A subtask whose
|
||||||
* when [groupByList] is on (stable, so each list's own order survives). A
|
* parent is also shown is left out, as on the list screen.
|
||||||
* subtask whose parent is also shown is left out, as on the list screen.
|
|
||||||
*/
|
*/
|
||||||
internal fun widgetRows(
|
internal fun widgetRows(
|
||||||
tasks: List<Task>,
|
tasks: List<Task>,
|
||||||
comparator: Comparator<Task>,
|
comparator: Comparator<Task>,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
groupByList: Boolean,
|
|
||||||
listNames: Map<Long, String>,
|
|
||||||
): List<WidgetTaskRow> {
|
): List<WidgetTaskRow> {
|
||||||
val open = tasks.filterNot { it.isClosed }
|
val open = tasks.filterNot { it.isClosed }
|
||||||
val present = open.mapTo(HashSet()) { it.taskId }
|
val present = open.mapTo(HashSet()) { it.taskId }
|
||||||
val today = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate()
|
val today = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate()
|
||||||
val sorted = open
|
return open
|
||||||
.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
|
.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
|
||||||
.sortedWith(comparator)
|
.sortedWith(comparator)
|
||||||
.let {
|
.take(MAX_WIDGET_ROWS)
|
||||||
if (groupByList) {
|
.map { task ->
|
||||||
it.sortedWith(compareBy({ task -> listNames[task.listId].orEmpty() }, { task -> task.listId }))
|
WidgetTaskRow(
|
||||||
} else it
|
taskId = task.taskId,
|
||||||
|
occurrenceStart = task.occurrenceStart,
|
||||||
|
title = task.title,
|
||||||
|
due = task.due,
|
||||||
|
isAllDay = task.isAllDay,
|
||||||
|
dueKind = task.due?.let { dueKind(it, task.isAllDay, today, zone) },
|
||||||
|
isOverdue = task.due?.let { isOverdue(it, task.isAllDay, now, today, zone) } ?: false,
|
||||||
|
color = task.effectiveColor,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return sorted.take(MAX_WIDGET_ROWS).map { it.toWidgetRow(now, today, zone) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** This task as a [WidgetTaskRow], resolving its due bucket and overdue state against [today]. */
|
|
||||||
internal fun Task.toWidgetRow(now: Instant, today: LocalDate, zone: ZoneId): WidgetTaskRow = WidgetTaskRow(
|
|
||||||
taskId = taskId,
|
|
||||||
listId = listId,
|
|
||||||
occurrenceStart = occurrenceStart,
|
|
||||||
title = title,
|
|
||||||
due = due,
|
|
||||||
isAllDay = isAllDay,
|
|
||||||
dueKind = due?.let { dueKind(it, isAllDay, today, zone) },
|
|
||||||
isOverdue = due?.let { isOverdue(it, isAllDay, now, today, zone) } ?: false,
|
|
||||||
color = effectiveColor,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live widget data for [filter]. Re-emits on every store change, since the
|
* Live widget data for [filter]. Re-emits on every store change, since the
|
||||||
* repository's flows observe the store; a store that can't be read (permission
|
* repository's flows observe the store; a store that can't be read (permission
|
||||||
* revoked, provider uninstalled) reads as [TaskWidgetData.Unavailable].
|
* revoked, provider uninstalled) reads as [TaskWidgetData.Unavailable].
|
||||||
*/
|
*/
|
||||||
internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): Flow<TaskWidgetData> {
|
internal fun Context.taskWidgetData(filter: TaskFilter): Flow<TaskWidgetData> {
|
||||||
val ep = widgetEntryPoint()
|
val ep = widgetEntryPoint()
|
||||||
val repository = ep.tasksRepository()
|
val repository = ep.tasksRepository()
|
||||||
return combine(
|
return combine(
|
||||||
@@ -275,19 +137,10 @@ internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): F
|
|||||||
repository.taskLists(),
|
repository.taskLists(),
|
||||||
ep.settingsPrefs().settings,
|
ep.settingsPrefs().settings,
|
||||||
) { tasks, lists, settings ->
|
) { tasks, lists, settings ->
|
||||||
val listNames = lists.associate { it.id to it.name }
|
|
||||||
val data: TaskWidgetData = TaskWidgetData.Ready(
|
val data: TaskWidgetData = TaskWidgetData.Ready(
|
||||||
filter = filter,
|
filter = filter,
|
||||||
listNames = listNames,
|
listName = (filter as? TaskFilter.OfList)?.let { f -> lists.firstOrNull { it.id == f.listId }?.name },
|
||||||
rows = widgetRows(
|
rows = widgetRows(settings.visibleIn(filter, tasks), settings.sortOrder.comparator(), Clock.System.now(), ZoneId.systemDefault()),
|
||||||
settings.visibleIn(filter, tasks),
|
|
||||||
settings.sortOrder.comparator(),
|
|
||||||
Clock.System.now(),
|
|
||||||
ZoneId.systemDefault(),
|
|
||||||
groupByList,
|
|
||||||
listNames,
|
|
||||||
),
|
|
||||||
groupByList = groupByList,
|
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
themeMode = settings.themeMode,
|
themeMode = settings.themeMode,
|
||||||
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
|
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.widget
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's own wake-up for the next local midnight, so "Today" on the widget
|
||||||
|
* flips over on the day boundary. `ACTION_DATE_CHANGED` isn't delivered to
|
||||||
|
* manifest receivers since Android 8, which is why this exists.
|
||||||
|
*
|
||||||
|
* One inexact alarm at a time, re-armed by every firing: `setAndAllowWhileIdle`
|
||||||
|
* needs no permission and survives doze, and a rollover a few minutes late is
|
||||||
|
* invisible on a sleeping screen.
|
||||||
|
*/
|
||||||
|
object WidgetRolloverScheduler {
|
||||||
|
|
||||||
|
/** Fire just after midnight, so an early delivery doesn't still read the old date. */
|
||||||
|
internal val ROLLOVER_SLACK = 5.seconds
|
||||||
|
|
||||||
|
/** Arm the next rollover, or cancel it when no widget is placed. Idempotent. */
|
||||||
|
fun sync(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
|
||||||
|
val pendingIntent = rolloverPendingIntent(appContext)
|
||||||
|
if (!hasPlacedWidgets(appContext)) {
|
||||||
|
alarmManager.cancel(pendingIntent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
|
||||||
|
alarmManager.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instant just after the next local midnight following [now] in [zone].
|
||||||
|
* Uses the actual start of day, so it holds where DST skips midnight or a
|
||||||
|
* date-line move skips a whole date.
|
||||||
|
*/
|
||||||
|
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
|
||||||
|
val date = now.toLocalDateTime(zone).date
|
||||||
|
var days = 1
|
||||||
|
while (days <= MAX_LOOKAHEAD_DAYS) {
|
||||||
|
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
|
||||||
|
if (candidate > now) return candidate
|
||||||
|
days++
|
||||||
|
}
|
||||||
|
// Unreachable for any real zone; an hour rather than the slack so it can't loop.
|
||||||
|
return now + 1.hours
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasPlacedWidgets(context: Context): Boolean {
|
||||||
|
val manager = AppWidgetManager.getInstance(context) ?: return false
|
||||||
|
return manager.getAppWidgetIds(ComponentName(context, TaskWidgetReceiver::class.java)).isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rolloverPendingIntent(context: Context): PendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ROLLOVER_REQUEST_CODE,
|
||||||
|
Intent(context, WidgetUpdateReceiver::class.java)
|
||||||
|
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
||||||
|
|
||||||
|
private const val MAX_LOOKAHEAD_DAYS = 3
|
||||||
|
}
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.widget
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.text.format.DateFormat
|
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
|
||||||
import de.jeanlucmakiola.agendula.domain.calendarDate
|
|
||||||
import de.jeanlucmakiola.agendula.domain.comparator
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/** One day of the Week widget's strip. [first] is that day's earliest task, in sort order. */
|
|
||||||
internal data class WeekDay(val date: LocalDate, val count: Int, val overdueCount: Int, val first: WidgetTaskRow?)
|
|
||||||
|
|
||||||
/** One tile of the Lists widget. */
|
|
||||||
internal data class ListTile(val id: Long, val name: String, val color: Int, val openCount: Int, val acceptsWrites: Boolean)
|
|
||||||
|
|
||||||
internal data class WidgetStatsSnapshot(
|
|
||||||
val todayDone: Int,
|
|
||||||
val todayRemaining: Int,
|
|
||||||
val todayTasks: List<WidgetTaskRow>,
|
|
||||||
/** Open dated tasks, earliest due first — Up next shows the head, optionally narrowed to one list. */
|
|
||||||
val upcoming: List<WidgetTaskRow>,
|
|
||||||
val week: List<WeekDay>,
|
|
||||||
val lists: List<ListTile>,
|
|
||||||
val dynamicColor: Boolean,
|
|
||||||
val themeMode: ThemeMode,
|
|
||||||
val use24Hour: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal sealed interface WidgetStatsData {
|
|
||||||
/** No readable store yet, mirroring [TaskWidgetData.Unavailable]. */
|
|
||||||
data object Unavailable : WidgetStatsData
|
|
||||||
data class Ready(val snapshot: WidgetStatsSnapshot) : WidgetStatsData
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val MAX_TODAY_ROWS = 20
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far through today a set of [open] and [completed] tasks says you are:
|
|
||||||
* tasks done today (by [Task.completedAt]'s local date), tasks still open that
|
|
||||||
* are overdue or due today (in [comparator] order), and those tasks as rows.
|
|
||||||
*/
|
|
||||||
internal fun todayProgress(
|
|
||||||
open: List<Task>,
|
|
||||||
completed: List<Task>,
|
|
||||||
comparator: Comparator<Task>,
|
|
||||||
now: Instant,
|
|
||||||
today: LocalDate,
|
|
||||||
zone: ZoneId,
|
|
||||||
): Triple<Int, Int, List<WidgetTaskRow>> {
|
|
||||||
val done = completed.count { task ->
|
|
||||||
val at = task.completedAt ?: return@count false
|
|
||||||
at.toLocalDate(zone) == today
|
|
||||||
}
|
|
||||||
val remaining = open.filter { task ->
|
|
||||||
val due = task.due ?: return@filter false
|
|
||||||
isOverdue(due, task.isAllDay, now, today, zone) || dueKind(due, task.isAllDay, today, zone) == DueKind.TODAY
|
|
||||||
}.sortedWith(comparator)
|
|
||||||
return Triple(done, remaining.size, remaining.take(MAX_TODAY_ROWS).map { it.toWidgetRow(now, today, zone) })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open dated tasks by earliest due moment, overdue included; undated tasks are skipped. */
|
|
||||||
internal fun upcoming(open: List<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WidgetTaskRow> =
|
|
||||||
open.filter { it.due != null }.sortedBy { it.due!! }.take(MAX_UPCOMING_ROWS).map { it.toWidgetRow(now, today, zone) }
|
|
||||||
|
|
||||||
private const val MAX_UPCOMING_ROWS = 200
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seven [WeekDay]s starting at [today]. Today's bucket folds in every overdue
|
|
||||||
* task regardless of its own due day, since that's where the app's own
|
|
||||||
* Overdue/Today smart lists put it; every other day matches its own due date.
|
|
||||||
*/
|
|
||||||
internal fun weekDays(open: List<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> =
|
|
||||||
(0..6L).map { offset ->
|
|
||||||
val date = today.plusDays(offset)
|
|
||||||
val dayTasks = open.filter { task ->
|
|
||||||
val due = task.due ?: return@filter false
|
|
||||||
if (offset == 0L) {
|
|
||||||
isOverdue(due, task.isAllDay, now, today, zone) || due.calendarDate(task.isAllDay, zone) == date
|
|
||||||
} else {
|
|
||||||
due.calendarDate(task.isAllDay, zone) == date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val overdueCount = if (offset == 0L) {
|
|
||||||
dayTasks.count { isOverdue(it.due!!, it.isAllDay, now, today, zone) }
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
val first = dayTasks.sortedWith(comparator).firstOrNull()?.toWidgetRow(now, today, zone)
|
|
||||||
WeekDay(date, dayTasks.size, overdueCount, first)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One tile per visible list: its open, non-subtask count — a subtask whose parent is also open is left out. */
|
|
||||||
internal fun listTiles(open: List<Task>, lists: List<TaskList>): List<ListTile> {
|
|
||||||
val present = open.mapTo(HashSet()) { it.taskId }
|
|
||||||
val counted = open.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
|
|
||||||
val counts = counted.groupingBy { it.listId }.eachCount()
|
|
||||||
return lists.filter { it.isVisible }.map { list ->
|
|
||||||
ListTile(list.id, list.name, list.color, counts[list.id] ?: 0, list.acceptsWrites)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Instant.toLocalDate(zone: ZoneId): LocalDate =
|
|
||||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Live stats for the Today, Up next, Lists and Week widgets — the ALL and
|
|
||||||
* COMPLETED smart lists, filtered the same way the app's own smart lists are
|
|
||||||
* ([de.jeanlucmakiola.agendula.data.prefs.Settings.visibleIn]).
|
|
||||||
*/
|
|
||||||
internal fun Context.widgetStats(): Flow<WidgetStatsData> {
|
|
||||||
val ep = widgetEntryPoint()
|
|
||||||
val repository = ep.tasksRepository()
|
|
||||||
return combine(
|
|
||||||
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
|
|
||||||
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
|
||||||
repository.taskLists(),
|
|
||||||
ep.settingsPrefs().settings,
|
|
||||||
) { openRaw, completedRaw, lists, settings ->
|
|
||||||
val open = settings.visibleIn(TaskFilter.Smart(SmartList.ALL), openRaw)
|
|
||||||
val completed = settings.visibleIn(TaskFilter.Smart(SmartList.ALL), completedRaw)
|
|
||||||
val now = Clock.System.now()
|
|
||||||
val zone = ZoneId.systemDefault()
|
|
||||||
val today = now.toLocalDate(zone)
|
|
||||||
val comparator = settings.sortOrder.comparator()
|
|
||||||
val (done, remaining, todayTasks) = todayProgress(open, completed, comparator, now, today, zone)
|
|
||||||
val data: WidgetStatsData = WidgetStatsData.Ready(
|
|
||||||
WidgetStatsSnapshot(
|
|
||||||
todayDone = done,
|
|
||||||
todayRemaining = remaining,
|
|
||||||
todayTasks = todayTasks,
|
|
||||||
upcoming = upcoming(open, now, today, zone),
|
|
||||||
week = weekDays(open, comparator, now, today, zone),
|
|
||||||
lists = listTiles(open, lists),
|
|
||||||
dynamicColor = settings.dynamicColor,
|
|
||||||
themeMode = settings.themeMode,
|
|
||||||
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
data
|
|
||||||
}.catch { emit(WidgetStatsData.Unavailable) }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.widget
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.LocalContext
|
||||||
|
import androidx.glance.material3.ColorProviders
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
|
||||||
|
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
|
||||||
|
|
||||||
|
private val AgendulaGlanceColors = ColorProviders(
|
||||||
|
light = AgendulaLightFallback,
|
||||||
|
dark = AgendulaDarkFallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glance counterpart of `AgendulaTheme`: Material You on API 31+ when dynamic
|
||||||
|
* colour is on, the brand fallback schemes otherwise. On [ThemeMode.SYSTEM] it
|
||||||
|
* draws only from colour-role tokens, so light/dark follows the system without a
|
||||||
|
* redraw; a forced light or dark theme pins one scheme for both.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AgendulaGlanceTheme(dynamicColor: Boolean, themeMode: ThemeMode, content: @Composable () -> Unit) {
|
||||||
|
val dynamic = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
val context = LocalContext.current
|
||||||
|
val colors = when (themeMode) {
|
||||||
|
ThemeMode.SYSTEM -> if (dynamic) GlanceTheme.colors else AgendulaGlanceColors
|
||||||
|
ThemeMode.LIGHT -> ColorProviders(
|
||||||
|
if (dynamic) dynamicLightColorScheme(context) else AgendulaLightFallback,
|
||||||
|
)
|
||||||
|
ThemeMode.DARK -> ColorProviders(
|
||||||
|
if (dynamic) dynamicDarkColorScheme(context) else AgendulaDarkFallback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GlanceTheme(colors = colors, content = content)
|
||||||
|
}
|
||||||
@@ -1,54 +1,56 @@
|
|||||||
package de.jeanlucmakiola.agendula.widget
|
package de.jeanlucmakiola.agendula.widget
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.glance.appwidget.updateAll
|
import androidx.glance.appwidget.updateAll
|
||||||
import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
/** Every home-screen widget's receiver class — the set [WidgetRollover] checks for placed instances. */
|
import kotlinx.coroutines.launch
|
||||||
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = listOf(
|
|
||||||
TaskWidgetReceiver::class.java,
|
|
||||||
TodayWidgetReceiver::class.java,
|
|
||||||
UpNextWidgetReceiver::class.java,
|
|
||||||
ListsWidgetReceiver::class.java,
|
|
||||||
WeekWidgetReceiver::class.java,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Redraws every placed home-screen widget. */
|
|
||||||
internal suspend fun updateAllWidgets(context: Context) {
|
|
||||||
TaskWidget().updateAll(context)
|
|
||||||
TodayWidget().updateAll(context)
|
|
||||||
UpNextWidget().updateAll(context)
|
|
||||||
ListsWidget().updateAll(context)
|
|
||||||
WeekWidget().updateAll(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.agendula.widget.ROLLOVER"
|
|
||||||
|
|
||||||
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
|
||||||
|
|
||||||
internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
ROLLOVER_REQUEST_CODE,
|
|
||||||
Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redraws every widget when its day or language goes stale, and keeps the
|
* Redraws the task widget when its day or its language goes stale, and keeps the
|
||||||
* midnight rollover armed — the app-specific glue around
|
* midnight rollover armed. Task data changes don't come through here — see
|
||||||
* [FloretWidgetUpdateReceiver]. Task data changes don't come through here — see
|
|
||||||
* [TaskWidgetUpdater].
|
* [TaskWidgetUpdater].
|
||||||
|
*
|
||||||
|
* Exported for the system broadcasts; an extra redraw from another app is harmless.
|
||||||
*/
|
*/
|
||||||
class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
|
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||||
override val rolloverAction = ACTION_WIDGET_ROLLOVER
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES
|
if (intent.action !in HANDLED_ACTIONS) return
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val rearm = intent.action in REARM_ACTIONS
|
||||||
|
// The host redraws the widget itself after these two.
|
||||||
|
val redraw = intent.action !in REARM_ONLY_ACTIONS
|
||||||
|
val pending = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
if (rearm) WidgetRolloverScheduler.sync(appContext)
|
||||||
|
if (redraw && WidgetRolloverScheduler.hasPlacedWidgets(appContext)) {
|
||||||
|
TaskWidget().updateAll(appContext)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pending.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
|
companion object {
|
||||||
|
const val ACTION_ROLLOVER = "de.jeanlucmakiola.agendula.widget.ROLLOVER"
|
||||||
|
|
||||||
override suspend fun redraw(context: Context) {
|
private val REARM_ONLY_ACTIONS = setOf(
|
||||||
updateAllWidgets(context)
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
|
||||||
|
ACTION_ROLLOVER,
|
||||||
|
Intent.ACTION_TIME_CHANGED,
|
||||||
|
Intent.ACTION_TIMEZONE_CHANGED,
|
||||||
|
Intent.ACTION_DATE_CHANGED,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_LOCALE_CHANGED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
|
||||||
|
</vector>
|
||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Round badge for the widget-picker preview. Tinted at the use site. -->
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:shape="oval">
|
android:shape="oval">
|
||||||
<solid android:color="@android:color/white" />
|
<solid android:color="@color/widget_preview_primary_container" />
|
||||||
</shape>
|
</shape>
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Full-pill action button for the widget-picker preview. Tinted at the use site. -->
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<solid android:color="@android:color/white" />
|
|
||||||
<corners android:radius="100dp" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Static mock of WidgetProgressRing (4 of 6 done) for the widget-picker preview. -->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="72dp" android:height="72dp"
|
|
||||||
android:viewportWidth="100" android:viewportHeight="100">
|
|
||||||
<path
|
|
||||||
android:pathData="M50,5 A45,45 0 1 1 50,95 A45,45 0 1 1 50,5"
|
|
||||||
android:strokeColor="@color/widget_preview_surface_variant"
|
|
||||||
android:strokeWidth="10" />
|
|
||||||
<path
|
|
||||||
android:pathData="M50,5 A45,45 0 1 1 11.03,72.5"
|
|
||||||
android:strokeColor="@color/widget_preview_primary"
|
|
||||||
android:strokeWidth="10"
|
|
||||||
android:strokeLineCap="round" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Static mock of WidgetGroupedRow (Position.Alone) for the widget-picker preview. Tinted at the use site. -->
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<solid android:color="@android:color/white" />
|
|
||||||
<corners android:radius="22dp" />
|
|
||||||
</shape>
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#B48A9C" />
|
||||||
|
<corners android:radius="2dp" />
|
||||||
|
</shape>
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Static mock of WidgetTile for the widget-picker preview. Tinted at the use site. -->
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<solid android:color="@android:color/white" />
|
|
||||||
<corners android:radius="16dp" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
|
||||||
widget is rendered by Glance. -->
|
|
||||||
<GridLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:background="@drawable/preview_widget_bg"
|
|
||||||
android:padding="4dp"
|
|
||||||
android:columnCount="2"
|
|
||||||
android:rowCount="2"
|
|
||||||
tools:ignore="HardcodedText,UseAppTint">
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_column="0"
|
|
||||||
android:layout_row="0"
|
|
||||||
android:layout_columnWeight="1"
|
|
||||||
android:layout_rowWeight="1"
|
|
||||||
android:padding="4dp">
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@drawable/preview_tile"
|
|
||||||
android:backgroundTint="#B87898"
|
|
||||||
android:padding="8dp">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:maxLines="2"
|
|
||||||
android:text="Home"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@android:color/white"
|
|
||||||
android:textSize="13sp" />
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="3"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@android:color/white"
|
|
||||||
android:textSize="18sp" />
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="20dp"
|
|
||||||
android:layout_height="20dp"
|
|
||||||
android:padding="3dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:background="@drawable/preview_circle"
|
|
||||||
android:backgroundTint="@color/widget_preview_primary_container"
|
|
||||||
android:src="@drawable/floret_ic_widget_add"
|
|
||||||
android:tint="@color/widget_preview_on_primary_container" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_column="1"
|
|
||||||
android:layout_row="0"
|
|
||||||
android:layout_columnWeight="1"
|
|
||||||
android:layout_rowWeight="1"
|
|
||||||
android:padding="4dp">
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@drawable/preview_tile"
|
|
||||||
android:backgroundTint="#6E93B8"
|
|
||||||
android:padding="8dp">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:maxLines="2"
|
|
||||||
android:text="Work"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@android:color/white"
|
|
||||||
android:textSize="13sp" />
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="5"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@android:color/white"
|
|
||||||
android:textSize="18sp" />
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="20dp"
|
|
||||||
android:layout_height="20dp"
|
|
||||||
android:padding="3dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:background="@drawable/preview_circle"
|
|
||||||
android:backgroundTint="@color/widget_preview_primary_container"
|
|
||||||
android:src="@drawable/floret_ic_widget_add"
|
|
||||||
android:tint="@color/widget_preview_on_primary_container" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_column="0"
|
|
||||||
android:layout_row="1"
|
|
||||||
android:layout_columnWeight="1"
|
|
||||||
android:layout_rowWeight="1"
|
|
||||||
android:padding="4dp">
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@drawable/preview_tile"
|
|
||||||
android:backgroundTint="#8DB878"
|
|
||||||
android:padding="8dp">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:maxLines="2"
|
|
||||||
android:text="Groceries"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@android:color/white"
|
|
||||||
android:textSize="13sp" />
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="4"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@android:color/white"
|
|
||||||
android:textSize="18sp" />
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="20dp"
|
|
||||||
android:layout_height="20dp"
|
|
||||||
android:padding="3dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:background="@drawable/preview_circle"
|
|
||||||
android:backgroundTint="@color/widget_preview_primary_container"
|
|
||||||
android:src="@drawable/floret_ic_widget_add"
|
|
||||||
android:tint="@color/widget_preview_on_primary_container" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_column="1"
|
|
||||||
android:layout_row="1"
|
|
||||||
android:layout_columnWeight="1"
|
|
||||||
android:layout_rowWeight="1"
|
|
||||||
android:padding="4dp">
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@drawable/preview_tile"
|
|
||||||
android:backgroundTint="#B89565"
|
|
||||||
android:padding="8dp">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:maxLines="2"
|
|
||||||
android:text="Ideas"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@android:color/white"
|
|
||||||
android:textSize="13sp" />
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="2"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@android:color/white"
|
|
||||||
android:textSize="18sp" />
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="20dp"
|
|
||||||
android:layout_height="20dp"
|
|
||||||
android:padding="3dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:background="@drawable/preview_circle"
|
|
||||||
android:backgroundTint="@color/widget_preview_primary_container"
|
|
||||||
android:src="@drawable/floret_ic_widget_add"
|
|
||||||
android:tint="@color/widget_preview_on_primary_container" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
</GridLayout>
|
|
||||||
@@ -7,105 +7,67 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:background="@drawable/preview_widget_bg"
|
android:background="@drawable/preview_widget_bg"
|
||||||
android:paddingHorizontal="8dp"
|
android:padding="12dp"
|
||||||
android:paddingVertical="4dp"
|
|
||||||
tools:ignore="HardcodedText,UseCompoundDrawables,UseAppTint">
|
tools:ignore="HardcodedText,UseCompoundDrawables,UseAppTint">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="36dp"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical">
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:maxLines="1"
|
android:layout_marginStart="8dp"
|
||||||
android:text="@string/smart_today"
|
android:text="@string/smart_today"
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@color/widget_preview_primary"
|
android:textColor="@color/widget_preview_primary"
|
||||||
android:textSize="16sp" />
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="36dp"
|
android:layout_width="40dp"
|
||||||
android:layout_height="28dp"
|
android:layout_height="40dp"
|
||||||
android:padding="5dp"
|
android:padding="9dp"
|
||||||
android:contentDescription="@null"
|
android:contentDescription="@null"
|
||||||
android:background="@drawable/preview_pill"
|
android:background="@drawable/preview_add_bg"
|
||||||
android:backgroundTint="@color/widget_preview_primary"
|
android:src="@drawable/ic_widget_add"
|
||||||
android:src="@drawable/floret_ic_widget_add"
|
android:tint="@color/widget_preview_on_primary_container" />
|
||||||
android:tint="@color/widget_preview_on_primary" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:minHeight="40dp"
|
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical">
|
||||||
android:background="@drawable/preview_row"
|
|
||||||
android:backgroundTint="@color/widget_preview_surface_variant"
|
|
||||||
android:paddingHorizontal="8dp"
|
|
||||||
android:paddingVertical="6dp">
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="28dp"
|
android:layout_width="4dp"
|
||||||
android:layout_height="28dp"
|
android:layout_height="32dp"
|
||||||
android:padding="6dp"
|
|
||||||
android:contentDescription="@null"
|
android:contentDescription="@null"
|
||||||
android:src="@drawable/floret_ic_widget_check"
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="#B48A9C" />
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="22dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
android:layout_marginStart="9dp"
|
||||||
|
android:layout_marginEnd="9dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/ic_widget_checkbox"
|
||||||
android:tint="@color/widget_preview_variant" />
|
android:tint="@color/widget_preview_variant" />
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:maxLines="1"
|
|
||||||
android:text="Water the plants"
|
android:text="Water the plants"
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@color/widget_preview_on_surface"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:minHeight="40dp"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:background="@drawable/preview_row"
|
|
||||||
android:backgroundTint="@color/widget_preview_surface_variant"
|
|
||||||
android:paddingHorizontal="8dp"
|
|
||||||
android:paddingVertical="6dp">
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="28dp"
|
|
||||||
android:layout_height="28dp"
|
|
||||||
android:padding="6dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:src="@drawable/floret_ic_widget_check"
|
|
||||||
android:tint="@color/widget_preview_variant" />
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:orientation="vertical">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:text="Call the dentist"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@color/widget_preview_on_surface"
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
android:textSize="14sp" />
|
android:textSize="14sp" />
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:maxLines="1"
|
android:text="Today"
|
||||||
android:text="10:30"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
android:textColor="@color/widget_preview_variant"
|
||||||
android:textSize="12sp" />
|
android:textSize="12sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -115,38 +77,75 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:minHeight="40dp"
|
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical">
|
||||||
android:background="@drawable/preview_row"
|
|
||||||
android:backgroundTint="@color/widget_preview_surface_variant"
|
|
||||||
android:paddingHorizontal="8dp"
|
|
||||||
android:paddingVertical="6dp">
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="28dp"
|
android:layout_width="4dp"
|
||||||
android:layout_height="28dp"
|
android:layout_height="32dp"
|
||||||
android:padding="6dp"
|
|
||||||
android:contentDescription="@null"
|
android:contentDescription="@null"
|
||||||
android:src="@drawable/floret_ic_widget_check"
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="#7FA3C9" />
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="22dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
android:layout_marginStart="9dp"
|
||||||
|
android:layout_marginEnd="9dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/ic_widget_checkbox"
|
||||||
android:tint="@color/widget_preview_variant" />
|
android:tint="@color/widget_preview_variant" />
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:maxLines="1"
|
android:text="Call the dentist"
|
||||||
android:text="Pick up groceries"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@color/widget_preview_on_surface"
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
android:textSize="14sp" />
|
android:textSize="14sp" />
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:maxLines="1"
|
android:text="Today · 10:30"
|
||||||
android:text="18:00"
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="4dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="#8FB58A" />
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="22dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
android:layout_marginStart="9dp"
|
||||||
|
android:layout_marginEnd="9dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/ic_widget_checkbox"
|
||||||
|
android:tint="@color/widget_preview_variant" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Pick up groceries"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Today · 18:00"
|
||||||
android:textColor="@color/widget_preview_variant"
|
android:textColor="@color/widget_preview_variant"
|
||||||
android:textSize="12sp" />
|
android:textSize="12sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
|
||||||
widget is rendered by Glance. -->
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@drawable/preview_widget_bg"
|
|
||||||
android:padding="8dp"
|
|
||||||
tools:ignore="HardcodedText,UseAppTint">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="36dp"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:text="@string/smart_today"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@color/widget_preview_primary"
|
|
||||||
android:textSize="16sp" />
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="36dp"
|
|
||||||
android:layout_height="28dp"
|
|
||||||
android:padding="5dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:background="@drawable/preview_pill"
|
|
||||||
android:backgroundTint="@color/widget_preview_primary"
|
|
||||||
android:src="@drawable/floret_ic_widget_add"
|
|
||||||
android:tint="@color/widget_preview_on_primary" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:layout_marginTop="4dp">
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:maxWidth="160dp"
|
|
||||||
android:maxHeight="160dp"
|
|
||||||
android:adjustViewBounds="true"
|
|
||||||
android:scaleType="fitCenter"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:src="@drawable/preview_ring" />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:text="4/6"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@color/widget_preview_on_surface"
|
|
||||||
android:textSize="22sp" />
|
|
||||||
</FrameLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
|
||||||
widget is rendered by Glance. -->
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:background="@drawable/preview_widget_bg"
|
|
||||||
android:padding="8dp"
|
|
||||||
tools:ignore="HardcodedText,UseAppTint">
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="40dp"
|
|
||||||
android:layout_height="40dp">
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:src="@drawable/floret_widget_shape_cookie"
|
|
||||||
android:tint="@color/widget_preview_primary_container" />
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="17dp"
|
|
||||||
android:layout_height="17dp"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:src="@drawable/floret_ic_widget_check"
|
|
||||||
android:tint="@color/widget_preview_on_primary_container" />
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:layout_marginStart="12dp"
|
|
||||||
android:orientation="vertical">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:text="14:30"
|
|
||||||
android:textColor="@color/widget_preview_primary"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:text="Call the dentist"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@color/widget_preview_on_surface"
|
|
||||||
android:textSize="15sp" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
|
||||||
widget is rendered by Glance. -->
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:background="@drawable/preview_widget_bg"
|
|
||||||
android:padding="4dp"
|
|
||||||
tools:ignore="HardcodedText,UseAppTint">
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:paddingHorizontal="2dp">
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center"
|
|
||||||
android:background="@drawable/preview_tile"
|
|
||||||
android:backgroundTint="@color/widget_preview_primary_container"
|
|
||||||
android:padding="8dp">
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="38dp"
|
|
||||||
android:layout_height="38dp"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center"
|
|
||||||
android:background="@drawable/preview_circle"
|
|
||||||
android:backgroundTint="@color/widget_preview_primary">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:text="Mon"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@color/widget_preview_on_primary"
|
|
||||||
android:textSize="10sp" />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:text="17"
|
|
||||||
android:textColor="@color/widget_preview_on_primary"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="3dp"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="14dp"
|
|
||||||
android:layout_height="14dp"
|
|
||||||
android:layout_marginEnd="2dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:src="@drawable/floret_ic_widget_checklist"
|
|
||||||
android:tint="@color/widget_preview_on_primary_container" />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="2"
|
|
||||||
android:textColor="@color/widget_preview_on_primary_container"
|
|
||||||
android:textSize="15sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:paddingHorizontal="2dp">
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center"
|
|
||||||
android:background="@drawable/preview_tile"
|
|
||||||
android:backgroundTint="@color/widget_preview_surface_variant"
|
|
||||||
android:padding="8dp">
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="38dp"
|
|
||||||
android:layout_height="38dp"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center"
|
|
||||||
android:background="@drawable/preview_circle"
|
|
||||||
android:backgroundTint="@color/widget_preview_surface">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:text="Tue"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="10sp" />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:text="18"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="3dp"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="14dp"
|
|
||||||
android:layout_height="14dp"
|
|
||||||
android:layout_marginEnd="2dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:src="@drawable/floret_ic_widget_checklist"
|
|
||||||
android:tint="@color/widget_preview_variant" />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="1"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="15sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:paddingHorizontal="2dp">
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center"
|
|
||||||
android:background="@drawable/preview_tile"
|
|
||||||
android:backgroundTint="@color/widget_preview_surface_variant"
|
|
||||||
android:padding="8dp">
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="38dp"
|
|
||||||
android:layout_height="38dp"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center"
|
|
||||||
android:background="@drawable/preview_circle"
|
|
||||||
android:backgroundTint="@color/widget_preview_surface">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:text="Wed"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="10sp" />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:text="19"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="3dp"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="14dp"
|
|
||||||
android:layout_height="14dp"
|
|
||||||
android:layout_marginEnd="2dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:src="@drawable/floret_ic_widget_checklist"
|
|
||||||
android:tint="@color/widget_preview_variant" />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="0"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="15sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:paddingHorizontal="2dp">
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center"
|
|
||||||
android:background="@drawable/preview_tile"
|
|
||||||
android:backgroundTint="@color/widget_preview_surface_variant"
|
|
||||||
android:padding="8dp">
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="38dp"
|
|
||||||
android:layout_height="38dp"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center"
|
|
||||||
android:background="@drawable/preview_circle"
|
|
||||||
android:backgroundTint="@color/widget_preview_surface">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:text="Thu"
|
|
||||||
android:fontFamily="sans-serif-medium"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="10sp" />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:text="20"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="3dp"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="14dp"
|
|
||||||
android:layout_height="14dp"
|
|
||||||
android:layout_marginEnd="2dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:src="@drawable/floret_ic_widget_checklist"
|
|
||||||
android:tint="@color/widget_preview_variant" />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="3"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="15sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
<string name="lists_header">Listen</string>
|
<string name="lists_header">Listen</string>
|
||||||
<string name="new_task">Neue Aufgabe</string>
|
<string name="new_task">Neue Aufgabe</string>
|
||||||
<string name="lists_failure">Deine Aufgaben konnten nicht gelesen werden.</string>
|
<string name="lists_failure">Deine Aufgaben konnten nicht gelesen werden.</string>
|
||||||
<string name="lists_empty">Keine Aufgaben-Listen bisher.</string>
|
<string name="lists_empty">Keine Aufgaben-Listen bisher. Füge eine in deiner Aufgaben-App hinzu oder mit der + Schaltfläche.</string>
|
||||||
<string name="smart_today">Heute</string>
|
<string name="smart_today">Heute</string>
|
||||||
<string name="smart_overdue">Überfällig</string>
|
<string name="smart_overdue">Überfällig</string>
|
||||||
<string name="smart_upcoming">Anstehend</string>
|
<string name="smart_upcoming">Anstehend</string>
|
||||||
@@ -99,23 +99,23 @@
|
|||||||
<string name="reminder_channel_name">Aufgaben Erinnerungen</string>
|
<string name="reminder_channel_name">Aufgaben Erinnerungen</string>
|
||||||
<string name="reminder_channel_desc">Benachrichtigungen für fällige Aufgaben.</string>
|
<string name="reminder_channel_desc">Benachrichtigungen für fällige Aufgaben.</string>
|
||||||
<string name="onboarding_no_provider_title">Eine Task-App wird benötigt</string>
|
<string name="onboarding_no_provider_title">Eine Task-App wird benötigt</string>
|
||||||
<string name="onboarding_no_provider_body">Es ist keine App installiert, in der man Aufgaben speichern könnte. Installieren OpenTasks oder tasks.org oder wechsel zu Agendulas eigenem Speicher.</string>
|
<string name="onboarding_no_provider_body">Agendula zeigt und bearbeitet die Aufgaben, die in OpenTasks oder tasks.org gespeichert sind und per DAVx5 synchronisiert werden. Installiere eine davon, um zu starten.</string>
|
||||||
<string name="onboarding_permission_title">Erlaube Zugriff auf deine Aufgaben</string>
|
<string name="onboarding_permission_title">Erlaube Zugriff auf deine Aufgaben</string>
|
||||||
<string name="onboarding_permission_body">Agendula benötigt die Berechtigung, deine Aufgaben zu lesen und zu schreiben. Das ist die einzige Berechtigung, die es jemals anfragt.</string>
|
<string name="onboarding_permission_body">Agendula benötigt die Berechtigung, deine Aufgaben zu lesen und zu schreiben. Das ist die einzige Berechtigung, die es jemals anfragt.</string>
|
||||||
<string name="onboarding_permission_button">Zugriff gewähren</string>
|
<string name="onboarding_permission_button">Zugriff gewähren</string>
|
||||||
<string name="reminder_onboarding_title">Verpasse nichts, was fällig ist</string>
|
<string name="reminder_onboarding_title">Verpasse nichts, was fällig ist</string>
|
||||||
<string name="reminder_onboarding_body">Agendula benachrichtigt dich, wenn etwas fällig wird, also muss nichts in deinem Kopf leben.</string>
|
<string name="reminder_onboarding_body">Aufgaben-Apps senden selbst keine Erinnerungen, daher liefert Agendula sie für dich. Schalte sie ein, um eine Benachrichtigung zu erhalten, wenn eine Aufgabe fällig ist.</string>
|
||||||
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
|
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
|
||||||
<string name="reminder_onboarding_skip_button">Nicht jetzt</string>
|
<string name="reminder_onboarding_skip_button">Nicht jetzt</string>
|
||||||
<string name="reminder_benefit_delivery_title">Agendula erinnert dich</string>
|
<string name="reminder_benefit_delivery_title">Agendula erinnert dich</string>
|
||||||
<string name="reminder_benefit_delivery_body">Jede Aufgaben mit Fälligkeitsdatum bekommt seine eigene Benachrichtigung.</string>
|
<string name="reminder_benefit_delivery_body">Es plant eine Benachrichtigung für jede Aufgabe mit einem Fälligkeitsdatum.</string>
|
||||||
<string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string>
|
<string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string>
|
||||||
<string name="reminder_benefit_timing_body">Wähle, wie weit im Voraus du erinnert werden möchtest — für alles oder pro Liste.</string>
|
<string name="reminder_benefit_timing_body">Wähle in den Einstellungen, wie weit im Voraus du erinnert werden möchtest.</string>
|
||||||
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
|
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
|
||||||
<string name="reminder_benefit_reversible_body">Schalte Erinnerungen aus, wann immer du willst — es ist nur ein Schalter.</string>
|
<string name="reminder_benefit_reversible_body">Schalte Erinnerungen aus, wann immer du willst — es ist nur ein Schalter.</string>
|
||||||
<string name="settings_title">Einstellungen</string>
|
<string name="settings_title">Einstellungen</string>
|
||||||
<string name="settings_section_appearance">Darstellung</string>
|
<string name="settings_section_appearance">Darstellung</string>
|
||||||
<string name="settings_appearance_subtitle">Theme, Farbe und Zeitformat</string>
|
<string name="settings_appearance_subtitle">Theme und Farbe</string>
|
||||||
<string name="settings_section_task_form">Aufgaben-Formular</string>
|
<string name="settings_section_task_form">Aufgaben-Formular</string>
|
||||||
<string name="settings_task_form_subtitle">Standard Felder, Liste und Unter-Aufgaben</string>
|
<string name="settings_task_form_subtitle">Standard Felder, Liste und Unter-Aufgaben</string>
|
||||||
<string name="settings_reminders_subtitle">Benachrichtigungen für fällige Aufgaben</string>
|
<string name="settings_reminders_subtitle">Benachrichtigungen für fällige Aufgaben</string>
|
||||||
@@ -169,244 +169,4 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<string name="settings_translate">Hilf beim Übersetzen</string>
|
<string name="settings_translate">Hilf beim Übersetzen</string>
|
||||||
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
|
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
|
||||||
<string name="task_completed">Fertig</string>
|
|
||||||
<string name="cd_repeats">Wiederholt</string>
|
|
||||||
<string name="shortcut_new_task">Neue Aufgabe</string>
|
|
||||||
<string name="shortcut_today">Heute</string>
|
|
||||||
<string name="qs_tile_new_task">Neue Aufgabe</string>
|
|
||||||
<string name="lists_reorder">Liste umsortieren</string>
|
|
||||||
<string name="lists_reorder_done">Ferig umsortiert</string>
|
|
||||||
<string name="lists_reorder_handle">Ziehen zum Verschieben</string>
|
|
||||||
<string name="sort_title">Sortieren nach</string>
|
|
||||||
<string name="sort_due">Fälligkeitsdatum</string>
|
|
||||||
<string name="sort_priority">Priorität</string>
|
|
||||||
<string name="sort_title_alpha">Titel</string>
|
|
||||||
<string name="sort_created">Kürzlich hinzugefügt</string>
|
|
||||||
<plurals name="clear_completed_body">
|
|
||||||
<item quantity="one">%1$d erledigte Aufgabe in dieser Liste wird gelöscht.</item>
|
|
||||||
<item quantity="other">%1$d erledigte Aufgaben in dieser Liste werden gelöscht.</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="clear_completed_confirm">Löschen</string>
|
|
||||||
<string name="settings_list_notifications">Benachrichtigungen</string>
|
|
||||||
<string name="settings_list_notifications_hint">Töne, Vibration und Wichtigkeit für Erinnerungen der Liste</string>
|
|
||||||
<string name="recurrence_generic">Wiederholende Aufgabe</string>
|
|
||||||
<string name="recurrence_daily">Jeden Tag</string>
|
|
||||||
<string name="recurrence_weekly">Jede Woche</string>
|
|
||||||
<string name="recurrence_monthly">Jedem Monat</string>
|
|
||||||
<string name="recurrence_yearly">Jedes Jahr</string>
|
|
||||||
<plurals name="recurrence_every_n_days">
|
|
||||||
<item quantity="one">Jeden Tag</item>
|
|
||||||
<item quantity="other">Alle %1$d Tage</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="recurrence_every_n_weeks">
|
|
||||||
<item quantity="one">Jede Woche</item>
|
|
||||||
<item quantity="other">Alle %1$d Wochen</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="recurrence_every_n_months">
|
|
||||||
<item quantity="one">Jeden Monat</item>
|
|
||||||
<item quantity="other">Allw %1$d Monate</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="recurrence_every_n_years">
|
|
||||||
<item quantity="one">Jedes Jahr</item>
|
|
||||||
<item quantity="other">Alle %1$d Jahre</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="recurrence_on_days">%1$s am %2$s</string>
|
|
||||||
<string name="recurrence_with_until">%1$s bis %2$s</string>
|
|
||||||
<string name="recurrence_none">Wiederholt sich nicht</string>
|
|
||||||
<string name="recurrence_custom">Benutzerdefiniert</string>
|
|
||||||
<string name="recurrence_unit_days">Tage</string>
|
|
||||||
<string name="recurrence_unit_weeks">Wochen</string>
|
|
||||||
<string name="recurrence_unit_months">Monate</string>
|
|
||||||
<string name="recurrence_unit_years">Jahre</string>
|
|
||||||
<string name="recurrence_incomplete">Gebe eine Nummer zwischen 1 und 999 ein</string>
|
|
||||||
<string name="recurrence_end_never">Nie</string>
|
|
||||||
<string name="recurring_scope_occurrence">Nur diese Aufgabe</string>
|
|
||||||
<string name="recurring_scope_following">Diese und folgende Aufgaben</string>
|
|
||||||
<string name="error_recurrence_without_date">Eine sich wiederholende Aufgabe braucht ein Start- oder Fälligkeitsdatum</string>
|
|
||||||
<string name="edit_location_label">Standort</string>
|
|
||||||
<string name="edit_url_label">Link</string>
|
|
||||||
<string name="edit_url_hint">https://…</string>
|
|
||||||
<string name="edit_discard_title">Änderungen verwerfen?</string>
|
|
||||||
<string name="edit_discard_body">Deine Änderungen an dieser Aufgabe wurden nicht gespeichert.</string>
|
|
||||||
<string name="edit_discard_confirm">Verwerfen</string>
|
|
||||||
<string name="edit_keep_editing">Weiter bearbeiten</string>
|
|
||||||
<string name="edit_reminder_add">Erinnerung hinzufügen</string>
|
|
||||||
<string name="edit_reminder_add_first">Erinnerung hinzufügen (sonst wird Standard angewendet)</string>
|
|
||||||
<string name="edit_reminder_remove">Erinnerung entfernen</string>
|
|
||||||
<string name="subtask_progress">%1$d / %2$d</string>
|
|
||||||
<string name="progress_fraction_compact">%1$d/%2$d</string>
|
|
||||||
<string name="detail_delete_title">Aufgabe löschen?</string>
|
|
||||||
<string name="detail_delete_body">Die Aufgabe wird gelöscht.</string>
|
|
||||||
<plurals name="detail_delete_body_subtasks">
|
|
||||||
<item quantity="one">Diese Aufgabe und ihre Unteraufgabe werden gelöscht.</item>
|
|
||||||
<item quantity="other">Diese Aufgabe und ihre %1$d Unteraufgaben werden gelöscht.</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="detail_status_cancelled">Abgebrochen</string>
|
|
||||||
<string name="detail_status_in_process">In Bearbeitung</string>
|
|
||||||
<string name="more_options">Mehr Optionen</string>
|
|
||||||
<string name="detail_cancel_task">Aufgabe abbrechen</string>
|
|
||||||
<string name="detail_restore">Aufgabe wiederherstellen</string>
|
|
||||||
<string name="detail_duplicate">Duplizieren</string>
|
|
||||||
<string name="lists_empty_action">Liste erstellen</string>
|
|
||||||
<string name="list_add">Neue Liste</string>
|
|
||||||
<string name="list_new_title">Neue Liste</string>
|
|
||||||
<string name="list_edit_title">Liste bearbeiten</string>
|
|
||||||
<string name="list_name_hint">Listenname</string>
|
|
||||||
<string name="list_color">Farbe</string>
|
|
||||||
<string name="list_delete">Liste löschen</string>
|
|
||||||
<string name="list_delete_confirm_title">Liste löschen?</string>
|
|
||||||
<string name="list_where_device">Nur auf diesem Gerät</string>
|
|
||||||
<string name="detail_pane_empty">Wähle eine Aufgabe, um sie hier zu sehen</string>
|
|
||||||
<string name="percent_complete">%1$d%%</string>
|
|
||||||
<string name="reminder_on_the_day">Ab dem Tag</string>
|
|
||||||
<string name="recurrence_next">Als nächstes: %1$s</string>
|
|
||||||
<string name="recurrence_end_until">An einem Tag</string>
|
|
||||||
<plurals name="home_today_remaining_count">
|
|
||||||
<item quantity="one">%1$d übrig</item>
|
|
||||||
<item quantity="other">%1$d übrig</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="reminder_day_today">heute</string>
|
|
||||||
<string name="reminder_day_tomorrow">morgen</string>
|
|
||||||
<string name="reminder_day_yesterday">gestern</string>
|
|
||||||
<string name="reminder_action_done">Fertig</string>
|
|
||||||
<string name="onboarding_welcome_title">Alles, was du machen wolltest</string>
|
|
||||||
<string name="onboarding_welcome_body">Ein ruhiger Ort für deine Aufgaben auf deinem Smartphone, das deins bleibt. Lass es uns einrichten, es braucht eine Minute.</string>
|
|
||||||
<string name="onboarding_welcome_button">Loslegen</string>
|
|
||||||
<string name="onboarding_welcome_lists_title">Listen und Unteraufgaben</string>
|
|
||||||
<string name="onboarding_welcome_reminders_title">Erinnerungen, die ankommen</string>
|
|
||||||
<string name="onboarding_welcome_reminders_body">Eine Benachrichtigung, wenn etwas fällig wird, nach deinem Plan</string>
|
|
||||||
<string name="onboarding_welcome_sync_body">Synchronisiere über CalDAV mit einem Server, den du wählst, oder behalte alles auf diesem Gerät.</string>
|
|
||||||
<string name="onboarding_sync_title">Mit deinem eigenen Server synchronisieren lassen?</string>
|
|
||||||
<string name="onboarding_sync_body">Agendula spricht CalDav — Nextcloud, Radicale, Posteo, Fastmail und alles, was auch CalDAV spricht. Keine Konten bei uns, nie.</string>
|
|
||||||
<string name="onboarding_sync_connect">Konto verbinden</string>
|
|
||||||
<string name="onboarding_sync_skip">Nicht jetzt</string>
|
|
||||||
<string name="onboarding_sync_devices_body">Hake etwas hier ab, und es wird Überfall abgehakt.</string>
|
|
||||||
<string name="onboarding_sync_copy_body">Deine Aufgaben leben auch auf dem Server, also ist ein verlorenes Gerät nicht verlorene Arbeit.</string>
|
|
||||||
<string name="onboarding_list_title">Deine erste Liste</string>
|
|
||||||
<string name="onboarding_list_default_name">Persönlich</string>
|
|
||||||
<string name="onboarding_list_create">Liste erstellen</string>
|
|
||||||
<string name="onboarding_backup_title">Eine Sicherung behalten</string>
|
|
||||||
<string name="onboarding_backup_continue">Fortsetzen</string>
|
|
||||||
<string name="onboarding_quick_add_title">Wie möchtest du Aufgaben hinzufügen?</string>
|
|
||||||
<string name="clear_completed_action">Löschen</string>
|
|
||||||
<string name="clear_completed_title">Erledigte Aufgaben löschen?</string>
|
|
||||||
<string name="sync_notice_retry">Wiederholen</string>
|
|
||||||
<string name="sync_notice_cause_server_newer">Durch die neuere Kopie vom Server ersetzt</string>
|
|
||||||
<string name="sync_sign_in_channel_name">Anmeldung nötig</string>
|
|
||||||
<string name="sync_sign_in_title">Bei %1$s wieder anmelden</string>
|
|
||||||
<string name="sync_notification_channel">Synchronisieren</string>
|
|
||||||
<string name="sync_notification_title">Aufgaben synchronisieren</string>
|
|
||||||
<string name="add_account_server_label">Server-Adresse</string>
|
|
||||||
<string name="add_account_server_hint">https://cloud.example.com</string>
|
|
||||||
<string name="add_account_username_label">Benutzername</string>
|
|
||||||
<string name="add_account_password_label">Passwort</string>
|
|
||||||
<string name="add_account_sign_in">Anmelden</string>
|
|
||||||
<string name="add_account_browser_title">Auf Browser warten</string>
|
|
||||||
<string name="add_account_browser_body">Bestätige Agendula im Browser, komme dann zurück hierher. Dein Passwort gelangt niemals zu dieser App, der Server vergibt ein seperates App-Passwort, das du jederzeit löschen kannst.</string>
|
|
||||||
<string name="add_account_browser_use_password">Stattdessen Passwort nutzen</string>
|
|
||||||
<string name="add_account_browser_retry">Nochmal versuchen</string>
|
|
||||||
<string name="add_account_browser_open">Öffne die Anmeldeseite</string>
|
|
||||||
<string name="add_account_browser_insecure">Diese Anmeldeseite ist unverschlüsselt (http). Alles, was du darin schreibst, auch dein Passwort, wird unverschlüsselt versendet.</string>
|
|
||||||
<string name="add_account_lists_title">Welche Listen sollen synchronisiert werden?</string>
|
|
||||||
<string name="add_account_lists_shared">Mit dir geteilt</string>
|
|
||||||
<string name="add_account_save">Konto hinzufügen</string>
|
|
||||||
<string name="add_account_no_lists_selected">Wähle mindestens eine Liste</string>
|
|
||||||
<string name="widget_tasks_label">Aufgaben</string>
|
|
||||||
<string name="widget_empty">Hier ist nichts zu tun</string>
|
|
||||||
<string name="widget_unavailable">Öffne Agendula und die Einrichtung abzuschließen</string>
|
|
||||||
<string name="widget_config_title">Im Widget zeigen</string>
|
|
||||||
<string name="widget_config_smart_lists">Intelligente Listen</string>
|
|
||||||
<string name="list_color_blue">Blau</string>
|
|
||||||
<string name="list_color_purple">Lila</string>
|
|
||||||
<string name="list_color_pink">Rosa</string>
|
|
||||||
<plurals name="home_today_progress_count">
|
|
||||||
<item quantity="one">%1$d von %2$d abgeschlossen</item>
|
|
||||||
<item quantity="other">%1$d von %2$d abgeschlossen</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="settings_group_look">Aussehen und Verhalten</string>
|
|
||||||
<string name="settings_group_data">Daten</string>
|
|
||||||
<string name="settings_group_app">App</string>
|
|
||||||
<string name="settings_group_about">Über</string>
|
|
||||||
<string name="settings_autofocus_title">Fokusiere den Titel bei Erstellung neuer Aufgaben</string>
|
|
||||||
<plurals name="duration_minutes">
|
|
||||||
<item quantity="one">%d Minute</item>
|
|
||||||
<item quantity="other">%d Minuten</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="duration_hours">
|
|
||||||
<item quantity="one">%d Stunde</item>
|
|
||||||
<item quantity="other">%d Stunden</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="settings_section_accounts">Konten</string>
|
|
||||||
<string name="accounts_empty_title">Noch keine Konten</string>
|
|
||||||
<string name="accounts_add">Konto hinzufügen</string>
|
|
||||||
<string name="accounts_sync_interval">Automatisch synchronisieren</string>
|
|
||||||
<plurals name="accounts_sync_every_minutes">
|
|
||||||
<item quantity="one">Jede %d Minute</item>
|
|
||||||
<item quantity="other">Jede %d Minuten</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="list_save_failed">Liste konnte nicht gespeichert werden.</string>
|
|
||||||
<string name="list_delete_failed">Liste konnte nicht gelöscht werden.</string>
|
|
||||||
<string name="list_save_failed_offline">Server konnte nicht erreicht werden. Versuche es bitte erneut, wenn du wieder online bist.</string>
|
|
||||||
<string name="list_save_failed_unsupported">Dieses Konto lässt Apps keine Aufgabenlisten erstellen.</string>
|
|
||||||
<string name="list_delete_confirm_message">\"%1$s\" und alle Aufgaben davon werden gelöscht. Das kann nicht rückgängig gemacht werden.</string>
|
|
||||||
<string name="list_color_red">Rot</string>
|
|
||||||
<string name="list_color_orange">Orange</string>
|
|
||||||
<string name="list_color_olive">Olive</string>
|
|
||||||
<string name="list_color_green">Grün</string>
|
|
||||||
<string name="list_color_cyan">Cyan</string>
|
|
||||||
<string name="settings_time_format">Zeitformat</string>
|
|
||||||
<string name="settings_time_format_auto">Automatisch</string>
|
|
||||||
<string name="settings_week_start">Woche startet am</string>
|
|
||||||
<string name="settings_week_start_auto">Automatisch</string>
|
|
||||||
<string name="settings_week_start_auto_summary">Momentan: %1$s</string>
|
|
||||||
<string name="settings_licences">Open-Source Lizenzen</string>
|
|
||||||
<string name="settings_licences_subtitle">Die Projekte, auf denen Agendula aufgebaut ist</string>
|
|
||||||
<string name="licences_title">Open-Source Lizenzen</string>
|
|
||||||
<string name="licences_intro">Agendula beinhaltet die unten gelistete Arbeit. Drücke eine, um ihren Quellcode zu erhalten.</string>
|
|
||||||
<string name="settings_privacy">Datenschutzbestimmungen</string>
|
|
||||||
<string name="settings_notifications_blocked">Benachrichtigungen sind deaktiviert</string>
|
|
||||||
<plurals name="import_done">
|
|
||||||
<item quantity="one">%1$d Aufgabe in %2$s importiert</item>
|
|
||||||
<item quantity="other">%1$d Aufgaben in %2$s importiert</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="import_done_skipped_existing">
|
|
||||||
<item quantity="one">%1$d schon vorhanden. Wird übersprungen.</item>
|
|
||||||
<item quantity="other">%1$d schon vorhanden. Werden übersprungen.</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="import_failed_unreadable">Die Datei konnte nicht als iCalendar eingelesen werden</string>
|
|
||||||
<string name="import_failed_no_tasks">Die Datei beinhaltet keine Aufgaben</string>
|
|
||||||
<string name="import_failed_write">Die Aufgabe konnte nicht gespeichert werden</string>
|
|
||||||
<plurals name="accounts_sync_every_hours">
|
|
||||||
<item quantity="one">Jede Stunde</item>
|
|
||||||
<item quantity="other">Alle %d Stunden</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="accounts_remove">Konto entfernen</string>
|
|
||||||
<string name="accounts_generic_provider">CalDAV-Konto</string>
|
|
||||||
<string name="accounts_sync_now">Jetzt synchronisieren</string>
|
|
||||||
<string name="accounts_removing">Entfernen…</string>
|
|
||||||
<string name="accounts_lists">Listen</string>
|
|
||||||
<string name="add_account_step_of">Schritt %1$d von %2$d</string>
|
|
||||||
<string name="add_account_provider_other_summary">Baïkal, DAViCal, SOGo, Radicale oder einen, den du selber laufen lässt</string>
|
|
||||||
<string name="add_account_provider_other_header">Jeglichen CalDAV-Server</string>
|
|
||||||
<string name="add_account_provider_other">Andere CalDAV-Server</string>
|
|
||||||
<string name="add_account_email_title">Was ist dort deine Adresse?</string>
|
|
||||||
<string name="add_account_email_body">Die E-Mail-Adresse, mit der du diesen Dienst nutzt. Agendula findet den Server selbst.</string>
|
|
||||||
<string name="add_account_email_label">E-Mail-Adresse</string>
|
|
||||||
<string name="add_account_summary_title">Du bist eingerichtet</string>
|
|
||||||
<string name="add_account_summary_done">Fertig</string>
|
|
||||||
<string name="add_account_server_title">Wo sind deine Aufgaben?</string>
|
|
||||||
<string name="add_account_credentials_title">Anmelden</string>
|
|
||||||
<string name="add_account_error_not_an_address">Das scheint keine Web-Adresse zu sein. Versuche etwas wie cloud.example.com.</string>
|
|
||||||
<string name="add_account_working_signing_in">Anmelden</string>
|
|
||||||
<string name="add_account_working_reading_lists">Aufgaben auslesen</string>
|
|
||||||
<string name="add_account_working_saving">Konto hinzufügen</string>
|
|
||||||
<string name="add_account_error_google_unsupported">Google Kalendar unterstützt nach eigener Dokumentation nicht Aufgaben über CalDAV, also kann Agendula nicht damit synchronisieren.</string>
|
|
||||||
<string name="add_account_error_already_exists">Konto ist schon eingerichtet.</string>
|
|
||||||
<string name="add_account_error_credentials_rejected">Diser Benutzername oder dieses Passwort wurde nicht akzeptiert.</string>
|
|
||||||
<string name="add_account_browser_error_maintenance">Der Server befindet sich um Wartungsmodus. Versuche es erneut, wenn er wieder zurück ist.</string>
|
|
||||||
<string name="add_account_setup_icloud_title">iCloud braucht ein App-spezifisches Passwort</string>
|
|
||||||
<string name="accounts_sign_in_again">Erneut anmelden</string>
|
|
||||||
<string name="sync_notice_body">%1$s</string>
|
|
||||||
<string name="sync_notices_title">Änderungen durch letzte Synchronisierung</string>
|
|
||||||
<string name="sync_notices_dismiss">Verstanden</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,6 +6,4 @@
|
|||||||
<color name="widget_preview_primary">@android:color/system_accent1_200</color>
|
<color name="widget_preview_primary">@android:color/system_accent1_200</color>
|
||||||
<color name="widget_preview_primary_container">@android:color/system_accent1_700</color>
|
<color name="widget_preview_primary_container">@android:color/system_accent1_700</color>
|
||||||
<color name="widget_preview_on_primary_container">@android:color/system_accent1_100</color>
|
<color name="widget_preview_on_primary_container">@android:color/system_accent1_100</color>
|
||||||
<color name="widget_preview_surface_variant">@android:color/system_neutral2_700</color>
|
|
||||||
<color name="widget_preview_on_primary">@android:color/system_accent1_800</color>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,6 +6,4 @@
|
|||||||
<color name="widget_preview_primary">#E2BAC9</color>
|
<color name="widget_preview_primary">#E2BAC9</color>
|
||||||
<color name="widget_preview_primary_container">#583B49</color>
|
<color name="widget_preview_primary_container">#583B49</color>
|
||||||
<color name="widget_preview_on_primary_container">#F6D9E5</color>
|
<color name="widget_preview_on_primary_container">#F6D9E5</color>
|
||||||
<color name="widget_preview_surface_variant">#49454F</color>
|
|
||||||
<color name="widget_preview_on_primary">#402533</color>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,6 +6,4 @@
|
|||||||
<color name="widget_preview_primary">@android:color/system_accent1_600</color>
|
<color name="widget_preview_primary">@android:color/system_accent1_600</color>
|
||||||
<color name="widget_preview_primary_container">@android:color/system_accent1_100</color>
|
<color name="widget_preview_primary_container">@android:color/system_accent1_100</color>
|
||||||
<color name="widget_preview_on_primary_container">@android:color/system_accent1_900</color>
|
<color name="widget_preview_on_primary_container">@android:color/system_accent1_900</color>
|
||||||
<color name="widget_preview_surface_variant">@android:color/system_neutral2_100</color>
|
|
||||||
<color name="widget_preview_on_primary">@android:color/system_accent1_0</color>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -430,8 +430,6 @@
|
|||||||
<string name="settings_default_list_first">First available list</string>
|
<string name="settings_default_list_first">First available list</string>
|
||||||
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
|
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
|
||||||
<string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string>
|
<string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string>
|
||||||
<string name="settings_default_start_today">Default start to today</string>
|
|
||||||
<string name="settings_default_start_today_hint">Pre-fill a new task\'s start with today\'s date</string>
|
|
||||||
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
|
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
|
||||||
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
|
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
|
||||||
|
|
||||||
@@ -757,68 +755,9 @@
|
|||||||
<!-- Home-screen widget -->
|
<!-- Home-screen widget -->
|
||||||
<string name="widget_tasks_label">Tasks</string>
|
<string name="widget_tasks_label">Tasks</string>
|
||||||
<string name="widget_tasks_description">Today\'s tasks, or any list, with a tap to tick them off</string>
|
<string name="widget_tasks_description">Today\'s tasks, or any list, with a tap to tick them off</string>
|
||||||
<string name="widget_empty">All caught up</string>
|
<string name="widget_empty">Nothing to do here</string>
|
||||||
<string name="widget_empty_hint">Nothing left to do, enjoy it</string>
|
|
||||||
<string name="widget_unavailable">Open Agendula to finish setting up</string>
|
<string name="widget_unavailable">Open Agendula to finish setting up</string>
|
||||||
<string name="widget_complete_task">Complete %1$s</string>
|
<string name="widget_complete_task">Complete %1$s</string>
|
||||||
<string name="widget_config_title">Show in widget</string>
|
<string name="widget_config_title">Show in widget</string>
|
||||||
<string name="widget_config_smart_lists">Smart lists</string>
|
<string name="widget_config_smart_lists">Smart lists</string>
|
||||||
<string name="widget_config_group_by_list">Group by list</string>
|
|
||||||
<string name="widget_config_group_by_list_hint">Show each list\'s tasks under its own heading</string>
|
|
||||||
<string name="widget_section_overdue">Overdue</string>
|
|
||||||
<string name="widget_section_later">Later</string>
|
|
||||||
<string name="widget_section_no_date">No date</string>
|
|
||||||
<string name="widget_config_background">Background</string>
|
|
||||||
<string name="widget_config_background_solid">Solid</string>
|
|
||||||
<string name="widget_config_background_solid_hint">Fills the widget with the theme\'s surface colour</string>
|
|
||||||
<string name="widget_config_background_tinted">Tinted</string>
|
|
||||||
<string name="widget_config_background_tinted_hint">A softer, tonal fill</string>
|
|
||||||
<string name="widget_config_background_transparent">Transparent</string>
|
|
||||||
<string name="widget_config_background_transparent_hint">No fill — tasks stay readable in their own containers</string>
|
|
||||||
<string name="widget_config_theme">Theme</string>
|
|
||||||
<string name="widget_config_theme_follow_app">Follow app</string>
|
|
||||||
<string name="widget_config_theme_light">Light</string>
|
|
||||||
<string name="widget_config_theme_dark">Dark</string>
|
|
||||||
<string name="widget_config_row_color">Row colour</string>
|
|
||||||
<string name="widget_config_row_color_neutral">Neutral</string>
|
|
||||||
<string name="widget_config_row_color_neutral_hint">Every row uses the same tonal fill</string>
|
|
||||||
<string name="widget_config_row_color_by_list">By list</string>
|
|
||||||
<string name="widget_config_row_color_by_list_hint">Each row is tinted with its list\'s own colour</string>
|
|
||||||
<string name="widget_today_label">Today</string>
|
|
||||||
<string name="widget_today_description">A progress ring for today\'s tasks</string>
|
|
||||||
<string name="widget_today_all_done">All done</string>
|
|
||||||
<string name="widget_today_nothing_due">Nothing due today</string>
|
|
||||||
<plurals name="widget_today_progress_cd">
|
|
||||||
<item quantity="one">%1$d of %2$d done</item>
|
|
||||||
<item quantity="other">%1$d of %2$d done</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="widget_up_next_label">Up next</string>
|
|
||||||
<string name="widget_up_next_description">Your single nearest due task</string>
|
|
||||||
<string name="widget_up_next_empty">Nothing due</string>
|
|
||||||
<string name="widget_up_next_soon">Soon</string>
|
|
||||||
<string name="widget_up_next_tomorrow">Tomorrow</string>
|
|
||||||
<string name="widget_up_next_then">Then</string>
|
|
||||||
<string name="widget_up_next_any_list">All lists</string>
|
|
||||||
<plurals name="widget_up_next_in_hours">
|
|
||||||
<item quantity="one">in %1$d h</item>
|
|
||||||
<item quantity="other">in %1$d h</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="widget_up_next_in_days">
|
|
||||||
<item quantity="one">in %1$d d</item>
|
|
||||||
<item quantity="other">in %1$d d</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="widget_up_next_overdue_hours">
|
|
||||||
<item quantity="one">%1$d h overdue</item>
|
|
||||||
<item quantity="other">%1$d h overdue</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="widget_up_next_overdue_days">
|
|
||||||
<item quantity="one">%1$d d overdue</item>
|
|
||||||
<item quantity="other">%1$d d overdue</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="widget_lists_label">Lists</string>
|
|
||||||
<string name="widget_lists_description">A shortcut tile per list, with its open count</string>
|
|
||||||
<string name="widget_lists_empty">No lists to show</string>
|
|
||||||
<string name="widget_lists_config_title">Lists to show</string>
|
|
||||||
<string name="widget_week_label">Week</string>
|
|
||||||
<string name="widget_week_description">A seven-day strip of task counts</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -8,6 +8,4 @@
|
|||||||
<color name="widget_preview_primary">#6A4E5B</color>
|
<color name="widget_preview_primary">#6A4E5B</color>
|
||||||
<color name="widget_preview_primary_container">#F6D9E5</color>
|
<color name="widget_preview_primary_container">#F6D9E5</color>
|
||||||
<color name="widget_preview_on_primary_container">#2A101C</color>
|
<color name="widget_preview_on_primary_container">#2A101C</color>
|
||||||
<color name="widget_preview_surface_variant">#E7E0EC</color>
|
|
||||||
<color name="widget_preview_on_primary">#FFFFFF</color>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:minWidth="110dp"
|
|
||||||
android:minHeight="110dp"
|
|
||||||
android:targetCellWidth="2"
|
|
||||||
android:targetCellHeight="2"
|
|
||||||
android:minResizeWidth="57dp"
|
|
||||||
android:minResizeHeight="57dp"
|
|
||||||
android:resizeMode="horizontal|vertical"
|
|
||||||
android:widgetCategory="home_screen"
|
|
||||||
android:description="@string/widget_lists_description"
|
|
||||||
android:updatePeriodMillis="1800000"
|
|
||||||
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
|
|
||||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
|
||||||
android:previewLayout="@layout/widget_preview_lists"
|
|
||||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:minWidth="110dp"
|
|
||||||
android:minHeight="110dp"
|
|
||||||
android:targetCellWidth="2"
|
|
||||||
android:targetCellHeight="2"
|
|
||||||
android:minResizeWidth="110dp"
|
|
||||||
android:minResizeHeight="110dp"
|
|
||||||
android:resizeMode="horizontal|vertical"
|
|
||||||
android:widgetCategory="home_screen"
|
|
||||||
android:description="@string/widget_today_description"
|
|
||||||
android:updatePeriodMillis="1800000"
|
|
||||||
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
|
|
||||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
|
||||||
android:previewLayout="@layout/widget_preview_today"
|
|
||||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:minWidth="110dp"
|
|
||||||
android:minHeight="56dp"
|
|
||||||
android:targetCellWidth="2"
|
|
||||||
android:targetCellHeight="1"
|
|
||||||
android:minResizeWidth="110dp"
|
|
||||||
android:minResizeHeight="56dp"
|
|
||||||
android:resizeMode="horizontal|vertical"
|
|
||||||
android:widgetCategory="home_screen"
|
|
||||||
android:description="@string/widget_up_next_description"
|
|
||||||
android:updatePeriodMillis="1800000"
|
|
||||||
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
|
|
||||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
|
||||||
android:previewLayout="@layout/widget_preview_up_next"
|
|
||||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:minWidth="250dp"
|
|
||||||
android:minHeight="56dp"
|
|
||||||
android:targetCellWidth="4"
|
|
||||||
android:targetCellHeight="1"
|
|
||||||
android:minResizeWidth="250dp"
|
|
||||||
android:minResizeHeight="56dp"
|
|
||||||
android:resizeMode="vertical"
|
|
||||||
android:widgetCategory="home_screen"
|
|
||||||
android:description="@string/widget_week_description"
|
|
||||||
android:updatePeriodMillis="1800000"
|
|
||||||
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
|
|
||||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
|
||||||
android:previewLayout="@layout/widget_preview_week"
|
|
||||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
|
||||||
@@ -10,9 +10,6 @@ fun testTask(
|
|||||||
status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||||
priority: Priority = Priority.NONE,
|
priority: Priority = Priority.NONE,
|
||||||
due: Instant? = null,
|
due: Instant? = null,
|
||||||
isAllDay: Boolean = false,
|
|
||||||
completedAt: Instant? = null,
|
|
||||||
parentId: Long? = null,
|
|
||||||
): Task = Task(
|
): Task = Task(
|
||||||
taskId = id,
|
taskId = id,
|
||||||
listId = listId,
|
listId = listId,
|
||||||
@@ -25,14 +22,14 @@ fun testTask(
|
|||||||
percentComplete = null,
|
percentComplete = null,
|
||||||
start = null,
|
start = null,
|
||||||
due = due,
|
due = due,
|
||||||
isAllDay = isAllDay,
|
isAllDay = false,
|
||||||
timeZone = null,
|
timeZone = null,
|
||||||
completedAt = completedAt,
|
completedAt = null,
|
||||||
listColor = 0,
|
listColor = 0,
|
||||||
taskColor = null,
|
taskColor = null,
|
||||||
listName = null,
|
listName = null,
|
||||||
accountName = null,
|
accountName = null,
|
||||||
parentId = parentId,
|
parentId = null,
|
||||||
isRecurring = false,
|
isRecurring = false,
|
||||||
occurrenceStart = null,
|
occurrenceStart = null,
|
||||||
distanceFromCurrent = 0,
|
distanceFromCurrent = 0,
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
package de.jeanlucmakiola.agendula.widget
|
package de.jeanlucmakiola.agendula.widget
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSorting
|
import de.jeanlucmakiola.agendula.domain.TaskSorting
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||||
import de.jeanlucmakiola.agendula.domain.testTask
|
import de.jeanlucmakiola.agendula.domain.testTask
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -30,9 +27,7 @@ class WidgetDataTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `every offered filter round-trips through storage`() {
|
fun `every offered filter round-trips through storage`() {
|
||||||
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } +
|
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } + TaskFilter.OfList(42)
|
||||||
TaskFilter.OfList(42) +
|
|
||||||
TaskFilter.OfLists(setOf(1L, 2L, 3L))
|
|
||||||
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
|
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +38,7 @@ class WidgetDataTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `garbage and smart lists the widget does not offer fall back to Today`() {
|
fun `garbage and smart lists the widget does not offer fall back to Today`() {
|
||||||
listOf(
|
listOf("", "list:", "list:-3", "list:abc", "smart:COMPLETED", "smart:nope", "whatever").forEach {
|
||||||
"", "list:", "list:-3", "list:abc", "lists:", "lists:abc", "lists:-1,-2",
|
|
||||||
"smart:COMPLETED", "smart:nope", "whatever",
|
|
||||||
).forEach {
|
|
||||||
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
|
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,8 +80,6 @@ class WidgetDataTest {
|
|||||||
TaskSorting.DEFAULT,
|
TaskSorting.DEFAULT,
|
||||||
now,
|
now,
|
||||||
newYork,
|
newYork,
|
||||||
groupByList = false,
|
|
||||||
listNames = emptyMap(),
|
|
||||||
)
|
)
|
||||||
assertThat(rows.map { it.title }).containsExactly("soon", "later").inOrder()
|
assertThat(rows.map { it.title }).containsExactly("soon", "later").inOrder()
|
||||||
}
|
}
|
||||||
@@ -99,135 +89,13 @@ class WidgetDataTest {
|
|||||||
val parent = testTask(id = 1, title = "parent")
|
val parent = testTask(id = 1, title = "parent")
|
||||||
val child = testTask(id = 2, title = "child").copy(parentId = 1)
|
val child = testTask(id = 2, title = "child").copy(parentId = 1)
|
||||||
val orphan = testTask(id = 3, title = "orphan").copy(parentId = 99)
|
val orphan = testTask(id = 3, title = "orphan").copy(parentId = 99)
|
||||||
val rows = widgetRows(
|
val rows = widgetRows(listOf(parent, child, orphan), TaskSorting.DEFAULT, now, newYork)
|
||||||
listOf(parent, child, orphan),
|
|
||||||
TaskSorting.DEFAULT,
|
|
||||||
now,
|
|
||||||
newYork,
|
|
||||||
groupByList = false,
|
|
||||||
listNames = emptyMap(),
|
|
||||||
)
|
|
||||||
assertThat(rows.map { it.title }).containsExactly("parent", "orphan")
|
assertThat(rows.map { it.title }).containsExactly("parent", "orphan")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rows are capped`() {
|
fun `rows are capped`() {
|
||||||
val many = (1L..(MAX_WIDGET_ROWS + 10L)).map { testTask(id = it, title = "t$it") }
|
val many = (1L..(MAX_WIDGET_ROWS + 10L)).map { testTask(id = it, title = "t$it") }
|
||||||
assertThat(
|
assertThat(widgetRows(many, TaskSorting.DEFAULT, now, newYork)).hasSize(MAX_WIDGET_ROWS)
|
||||||
widgetRows(many, TaskSorting.DEFAULT, now, newYork, groupByList = false, listNames = emptyMap()),
|
|
||||||
).hasSize(MAX_WIDGET_ROWS)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `grouping sorts by list name, keeping each list's own order`() {
|
|
||||||
val tasks = listOf(
|
|
||||||
testTask(id = 1, listId = 2, title = "b-later", due = at("2026-07-22T09:00:00")),
|
|
||||||
testTask(id = 2, listId = 1, title = "a-soon", due = at("2026-07-20T15:00:00")),
|
|
||||||
testTask(id = 3, listId = 1, title = "a-later", due = at("2026-07-22T09:00:00")),
|
|
||||||
)
|
|
||||||
val rows = widgetRows(
|
|
||||||
tasks,
|
|
||||||
TaskSorting.DEFAULT,
|
|
||||||
now,
|
|
||||||
newYork,
|
|
||||||
groupByList = true,
|
|
||||||
listNames = mapOf(1L to "Alpha", 2L to "Beta"),
|
|
||||||
)
|
|
||||||
assertThat(rows.map { it.title }).containsExactly("a-soon", "a-later", "b-later").inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- sections ----------------------------------------------------------
|
|
||||||
|
|
||||||
private fun row(id: Long, listId: Long = 1, dueKind: DueKind? = null, overdue: Boolean = false) = WidgetTaskRow(
|
|
||||||
taskId = id,
|
|
||||||
listId = listId,
|
|
||||||
occurrenceStart = null,
|
|
||||||
title = "t$id",
|
|
||||||
due = null,
|
|
||||||
isAllDay = false,
|
|
||||||
dueKind = dueKind,
|
|
||||||
isOverdue = overdue,
|
|
||||||
color = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `due buckets appear in a fixed order and skip empty ones`() {
|
|
||||||
val rows = listOf(
|
|
||||||
row(1, dueKind = DueKind.LATER),
|
|
||||||
row(2, dueKind = DueKind.PAST, overdue = true),
|
|
||||||
row(3, dueKind = DueKind.TODAY),
|
|
||||||
)
|
|
||||||
val sections = widgetSections(rows, groupByList = false, listNames = emptyMap()) { it?.name ?: "none" }
|
|
||||||
assertThat(sections.filterIsInstance<WidgetSection.Header>().map { it.name })
|
|
||||||
.containsExactly("PAST", "TODAY", "LATER").inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a bucket's rows keep their own order under its one header`() {
|
|
||||||
val rows = listOf(row(1, dueKind = DueKind.TODAY), row(2, dueKind = DueKind.TODAY), row(3, dueKind = DueKind.TODAY))
|
|
||||||
val sections = widgetSections(rows, groupByList = false, listNames = emptyMap()) { "x" }
|
|
||||||
assertThat(sections.filterIsInstance<WidgetSection.Header>()).hasSize(1)
|
|
||||||
assertThat(sections.filterIsInstance<WidgetSection.TaskItem>().map { it.row.taskId }).containsExactly(1L, 2L, 3L).inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `grouping by list keeps the widgetRows order and headers each run once`() {
|
|
||||||
val rows = listOf(row(1, listId = 1), row(2, listId = 1), row(3, listId = 2))
|
|
||||||
val sections = widgetSections(rows, groupByList = true, listNames = mapOf(1L to "Alpha", 2L to "Beta")) { "x" }
|
|
||||||
assertThat(sections.filterIsInstance<WidgetSection.Header>().map { it.name }).containsExactly("Alpha", "Beta").inOrder()
|
|
||||||
assertThat(sections.filterIsInstance<WidgetSection.TaskItem>().map { it.row.taskId }).containsExactly(1L, 2L, 3L).inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- per-widget background / theme --------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an unset background is Solid, and round-trips`() {
|
|
||||||
assertThat(parseWidgetBackground(null)).isEqualTo(WidgetBackground.SOLID)
|
|
||||||
WidgetBackground.entries.forEach { assertThat(parseWidgetBackground(it.name)).isEqualTo(it) }
|
|
||||||
assertThat(parseWidgetBackground("garbage")).isEqualTo(WidgetBackground.SOLID)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an unset theme override follows the app, Light and Dark pin regardless`() {
|
|
||||||
assertThat(parseWidgetThemeOverride(null)).isNull()
|
|
||||||
assertThat(parseWidgetThemeOverride("garbage")).isNull()
|
|
||||||
assertThat(parseWidgetThemeOverride("LIGHT")).isEqualTo(WidgetThemeMode.LIGHT)
|
|
||||||
assertThat(parseWidgetThemeOverride("DARK")).isEqualTo(WidgetThemeMode.DARK)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readyWithTheme(appTheme: ThemeMode) = TaskWidgetData.Ready(
|
|
||||||
filter = DEFAULT_WIDGET_FILTER,
|
|
||||||
listNames = emptyMap(),
|
|
||||||
rows = emptyList(),
|
|
||||||
groupByList = false,
|
|
||||||
dynamicColor = true,
|
|
||||||
themeMode = appTheme,
|
|
||||||
use24Hour = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `no override resolves to whatever the app's own theme means`() {
|
|
||||||
assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
|
|
||||||
assertThat(readyWithTheme(ThemeMode.SYSTEM).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.SYSTEM)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an override wins over the app's own theme`() {
|
|
||||||
assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(WidgetThemeMode.LIGHT)).isEqualTo(WidgetThemeMode.LIGHT)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an unset row colour is Neutral, and round-trips`() {
|
|
||||||
assertThat(parseWidgetRowColor(null)).isEqualTo(WidgetRowColor.NEUTRAL)
|
|
||||||
WidgetRowColor.entries.forEach { assertThat(parseWidgetRowColor(it.name)).isEqualTo(it) }
|
|
||||||
assertThat(parseWidgetRowColor("garbage")).isEqualTo(WidgetRowColor.NEUTRAL)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `overdue count only counts overdue rows`() {
|
|
||||||
val ready = readyWithTheme(ThemeMode.SYSTEM).copy(
|
|
||||||
rows = listOf(row(1, overdue = true), row(2, overdue = false), row(3, overdue = true)),
|
|
||||||
)
|
|
||||||
assertThat(ready.overdueCount).isEqualTo(2)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.widget
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** The "when is the next local midnight" arithmetic, especially where midnight is not 00:00. */
|
||||||
|
class WidgetRolloverSchedulerTest {
|
||||||
|
|
||||||
|
private val berlin = TimeZone.of("Europe/Berlin")
|
||||||
|
|
||||||
|
private fun at(local: String, zone: TimeZone): Instant =
|
||||||
|
LocalDateTime.parse(local).toInstant(zone)
|
||||||
|
|
||||||
|
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
|
||||||
|
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
|
||||||
|
|
||||||
|
// --- the ordinary day ----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `midday rolls over at the coming midnight`() {
|
||||||
|
val next = nextRollover("2026-08-27T12:00:00")
|
||||||
|
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a second before midnight still targets tonight, not tomorrow night`() {
|
||||||
|
val next = nextRollover("2026-08-27T23:59:59")
|
||||||
|
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `at midnight exactly the target is the next day, never the current instant`() {
|
||||||
|
// Re-arming after a firing must move a whole day on, or the widget wakes
|
||||||
|
// itself in a tight loop.
|
||||||
|
val now = at("2026-08-28T00:00:00", berlin)
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
||||||
|
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
|
||||||
|
assertThat(next - now).isGreaterThan(Duration.ZERO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `re-arming from the slack instant itself moves a full day on`() {
|
||||||
|
// What actually happens in practice: the receiver runs at midnight + slack.
|
||||||
|
val now = at("2026-08-28T00:00:05", berlin)
|
||||||
|
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
|
||||||
|
.isEqualTo(at("2026-08-29T00:00:05", berlin))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the result is always in the future for every minute of a day`() {
|
||||||
|
val zone = berlin
|
||||||
|
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
|
||||||
|
// in the past, i.e. an alarm that fires immediately, forever.
|
||||||
|
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
|
||||||
|
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
|
||||||
|
while (probe < end) {
|
||||||
|
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
|
||||||
|
probe += 1.minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- daylight saving -----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `spring forward keeps the rollover one day away, not one hour short`() {
|
||||||
|
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
|
||||||
|
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
|
||||||
|
val now = at("2024-03-30T12:00:00", berlin)
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
||||||
|
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
|
||||||
|
assertThat(next - now).isLessThan(24.hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fall back does not overshoot into the repeated hour`() {
|
||||||
|
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
|
||||||
|
// "now + 24h" would land at 23:00 on the 26th and never roll over.
|
||||||
|
val now = at("2024-10-26T12:00:00", berlin)
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
||||||
|
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
|
||||||
|
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zone that repeats midnight takes the first start of day`() {
|
||||||
|
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
|
||||||
|
// began twice. Pinned as the accepted trade: the widget runs an hour
|
||||||
|
// ahead until the next redraw. No live zone does this since 2019.
|
||||||
|
val saoPaulo = TimeZone.of("America/Sao_Paulo")
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(
|
||||||
|
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
|
||||||
|
)
|
||||||
|
val local = next.toLocalDateTime(saoPaulo)
|
||||||
|
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
|
||||||
|
assertThat(local.hour).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zone where midnight does not exist rolls over at the real start of day`() {
|
||||||
|
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
|
||||||
|
// Targeting a literal 00:00 there would arm an instant on the wrong day.
|
||||||
|
val havana = TimeZone.of("America/Havana")
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
|
||||||
|
val local = next.toLocalDateTime(havana)
|
||||||
|
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
|
||||||
|
assertThat(local.hour).isEqualTo(1)
|
||||||
|
assertThat(local.minute).isEqualTo(0)
|
||||||
|
// And it is genuinely the first instant of that date, not a guess.
|
||||||
|
assertThat(next).isEqualTo(
|
||||||
|
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
|
||||||
|
WidgetRolloverScheduler.ROLLOVER_SLACK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- timezone changes ----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same instant rolls over at different times in different zones`() {
|
||||||
|
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
|
||||||
|
// follows the zone, not a cached offset.
|
||||||
|
val instant = at("2026-08-27T12:00:00", berlin)
|
||||||
|
val tokyo = TimeZone.of("Asia/Tokyo")
|
||||||
|
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
|
||||||
|
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
|
||||||
|
assertThat(tokyoNext).isNotEqualTo(berlinNext)
|
||||||
|
assertThat(tokyoNext).isLessThan(berlinNext)
|
||||||
|
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a half-hour offset zone still lands on its own midnight`() {
|
||||||
|
val kathmandu = TimeZone.of("Asia/Kathmandu")
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(
|
||||||
|
at("2026-08-27T12:00:00", kathmandu), kathmandu,
|
||||||
|
)
|
||||||
|
val local = next.toLocalDateTime(kathmandu)
|
||||||
|
assertThat(local.date.toString()).isEqualTo("2026-08-28")
|
||||||
|
assertThat(local.hour).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the wiring ----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the receiver actually handles the action the alarm is sent with`() {
|
||||||
|
// The single point where the whole fix would die silently: the alarm
|
||||||
|
// fires, the receiver drops it on the action guard, nothing redraws.
|
||||||
|
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
|
||||||
|
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a language change redraws without re-arming`() {
|
||||||
|
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS).contains(Intent.ACTION_LOCALE_CHANGED)
|
||||||
|
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).doesNotContain(Intent.ACTION_LOCALE_CHANGED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `everything that loses the alarm re-arms it`() {
|
||||||
|
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
|
||||||
|
WidgetUpdateReceiver.ACTION_ROLLOVER,
|
||||||
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||||
|
Intent.ACTION_TIME_CHANGED,
|
||||||
|
Intent.ACTION_TIMEZONE_CHANGED,
|
||||||
|
Intent.ACTION_DATE_CHANGED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.widget
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
|
||||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
|
||||||
import de.jeanlucmakiola.agendula.domain.comparator
|
|
||||||
import de.jeanlucmakiola.agendula.domain.testTask
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.ZoneId
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
class WidgetStatsTest {
|
|
||||||
|
|
||||||
private val newYork = ZoneId.of("America/New_York")
|
|
||||||
private val today = LocalDate.of(2026, 7, 20)
|
|
||||||
private val comparator = TaskSortOrder.DUE.comparator()
|
|
||||||
|
|
||||||
private fun at(local: String, zone: ZoneId = newYork): Instant =
|
|
||||||
Instant.fromEpochMilliseconds(LocalDateTime.parse(local).atZone(zone).toInstant().toEpochMilli())
|
|
||||||
|
|
||||||
private val now = at("2026-07-20T12:00:00")
|
|
||||||
|
|
||||||
// --- todayProgress ---------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a task completed today counts as done, one completed yesterday does not`() {
|
|
||||||
val completed = listOf(
|
|
||||||
testTask(id = 1, status = TaskStatus.COMPLETED, completedAt = at("2026-07-20T09:00:00")),
|
|
||||||
testTask(id = 2, status = TaskStatus.COMPLETED, completedAt = at("2026-07-19T23:00:00")),
|
|
||||||
)
|
|
||||||
val (done, _, _) = todayProgress(emptyList(), completed, comparator, now, today, newYork)
|
|
||||||
assertThat(done).isEqualTo(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an all-day task due today and an overdue timed task both count as remaining`() {
|
|
||||||
val open = listOf(
|
|
||||||
testTask(id = 1, due = allDayInstantOf(today), isAllDay = true),
|
|
||||||
testTask(id = 2, due = at("2026-07-20T08:00:00")), // already past `now` (12:00)
|
|
||||||
testTask(id = 3, due = at("2026-07-21T08:00:00")), // tomorrow, not remaining
|
|
||||||
)
|
|
||||||
val (_, remaining, _) = todayProgress(open, emptyList(), comparator, now, today, newYork)
|
|
||||||
assertThat(remaining).isEqualTo(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an earlier-due remaining task sorts ahead of a later one`() {
|
|
||||||
val open = listOf(
|
|
||||||
testTask(id = 1, due = at("2026-07-20T11:00:00")), // overdue, closer to now
|
|
||||||
testTask(id = 2, due = at("2026-07-20T07:00:00")), // more overdue, earliest
|
|
||||||
)
|
|
||||||
val (_, _, rows) = todayProgress(open, emptyList(), comparator, now, today, newYork)
|
|
||||||
assertThat(rows.map { it.taskId }).containsExactly(2L, 1L).inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- upcoming ----------------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `upcoming sorts by due, overdue first, and skips undated ones`() {
|
|
||||||
val open = listOf(
|
|
||||||
testTask(id = 1, due = null),
|
|
||||||
testTask(id = 2, due = at("2026-07-21T08:00:00")),
|
|
||||||
testTask(id = 3, due = at("2026-07-20T08:00:00")), // overdue, earliest
|
|
||||||
)
|
|
||||||
assertThat(upcoming(open, now, today, newYork).map { it.taskId }).containsExactly(3L, 2L).inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `upcoming is empty when nothing is dated`() {
|
|
||||||
assertThat(upcoming(listOf(testTask(due = null)), now, today, newYork)).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- weekDays ------------------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `today's bucket folds in overdue tasks from earlier days`() {
|
|
||||||
val open = listOf(
|
|
||||||
testTask(id = 1, due = at("2026-07-18T08:00:00")), // two days overdue
|
|
||||||
testTask(id = 2, due = at("2026-07-20T08:00:00")), // due today, already past now
|
|
||||||
)
|
|
||||||
val week = weekDays(open, comparator, now, today, newYork)
|
|
||||||
assertThat(week.first().date).isEqualTo(today)
|
|
||||||
assertThat(week.first().count).isEqualTo(2)
|
|
||||||
assertThat(week.first().overdueCount).isEqualTo(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a future day only counts tasks due that exact day`() {
|
|
||||||
val open = listOf(
|
|
||||||
testTask(id = 1, due = at("2026-07-22T08:00:00")),
|
|
||||||
testTask(id = 2, due = at("2026-07-23T08:00:00")),
|
|
||||||
)
|
|
||||||
val week = weekDays(open, comparator, now, today, newYork)
|
|
||||||
assertThat(week[2].count).isEqualTo(1) // today + 2 = 07-22
|
|
||||||
assertThat(week[2].overdueCount).isEqualTo(0)
|
|
||||||
assertThat(week[3].count).isEqualTo(1) // today + 3 = 07-23
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a day at the far edge of daylight saving still buckets by local date`() {
|
|
||||||
// Spans the US spring-forward boundary; only exercised to confirm
|
|
||||||
// calendarDate (not a raw day-of-year add) drives the bucketing.
|
|
||||||
val dstToday = LocalDate.of(2026, 3, 7)
|
|
||||||
val dstNow = at("2026-03-07T12:00:00")
|
|
||||||
val open = listOf(testTask(id = 1, due = at("2026-03-08T08:00:00")))
|
|
||||||
val week = weekDays(open, comparator, dstNow, dstToday, newYork)
|
|
||||||
assertThat(week[1].date).isEqualTo(LocalDate.of(2026, 3, 8))
|
|
||||||
assertThat(week[1].count).isEqualTo(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- listTiles -------------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `listTiles counts open tasks per visible list and skips a subtask whose parent is also open`() {
|
|
||||||
val lists = listOf(
|
|
||||||
TaskList(1, "Home", 0, "a", "t", false, isVisible = true, owner = null),
|
|
||||||
TaskList(2, "Hidden", 0, "a", "t", false, isVisible = false, owner = null),
|
|
||||||
)
|
|
||||||
val open = listOf(
|
|
||||||
testTask(id = 1, listId = 1),
|
|
||||||
testTask(id = 2, listId = 1, parentId = 1), // subtask of an open parent
|
|
||||||
testTask(id = 3, listId = 2),
|
|
||||||
)
|
|
||||||
val tiles = listTiles(open, lists)
|
|
||||||
assertThat(tiles).hasSize(1)
|
|
||||||
assertThat(tiles.single().id).isEqualTo(1L)
|
|
||||||
assertThat(tiles.single().openCount).isEqualTo(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+21
-92
@@ -11,12 +11,9 @@ carries no APK assets.
|
|||||||
Codeberg is the canonical forge; Gitea is build infrastructure. See
|
Codeberg is the canonical forge; Gitea is build infrastructure. See
|
||||||
[Two forges, one repo](#two-forges-one-repo) for how the two are wired.
|
[Two forges, one repo](#two-forges-one-repo) for how the two are wired.
|
||||||
|
|
||||||
Before a stable release you can ship **betas** (`X.Y.Z-beta.N`) from the
|
While Agendula is pre-1.0 (`versionName` starts with `0.`), every release is
|
||||||
release branch. They go to **Codeberg only**, flagged as pre-releases; F-Droid
|
flagged as a **pre-release**. This happens automatically and graduates to a
|
||||||
and Play never see them. See [Cutting a beta](#cutting-a-beta).
|
stable release at `1.0.0` — no manual toggling.
|
||||||
|
|
||||||
While Agendula was pre-1.0 (`versionName` started with `0.`), every release was
|
|
||||||
flagged as a **pre-release** automatically.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,30 +22,16 @@ flagged as a **pre-release** automatically.
|
|||||||
A release is defined by the `versionName`/`versionCode` committed in
|
A release is defined by the `versionName`/`versionCode` committed in
|
||||||
`app/build.gradle.kts` — **not** by a hand-pushed tag:
|
`app/build.gradle.kts` — **not** by a hand-pushed tag:
|
||||||
|
|
||||||
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `1.1.0`), or
|
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `0.2.0`)
|
||||||
`MAJOR.MINOR.PATCH-beta.N` for a beta (e.g. `1.1.0-beta.2`)
|
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`0.2.0` → `200`,
|
||||||
- `versionCode` is derived from it by `scripts/version_info.sh`:
|
`1.3.4` → `10304`)
|
||||||
|
|
||||||
| `versionName` | `versionCode` | Example |
|
So `MINOR` and `PATCH` each have room for 0–99. The release pipeline reads
|
||||||
| --- | --- | --- |
|
`versionName`, pins `versionCode` to the derived value, builds, publishes, and —
|
||||||
| `X.Y.Z` below 1.1.0 (legacy) | `X*10000 + Y*100 + Z` | `1.0.1` → `10001` |
|
once the APK is live — creates the tag `v<versionName>` at that commit. The tag
|
||||||
| `X.Y.Z-beta.N` (N = 1–98) | `X*1000000 + Y*10000 + Z*100 + N` | `1.1.0-beta.2` → `1010002` |
|
is an **output** of a successful release, not its trigger, so a tag always marks
|
||||||
| `X.Y.Z` from 1.1.0 | `X*1000000 + Y*10000 + Z*100 + 99` | `1.1.0` → `1010099` |
|
a fully-shipped version (and a failure before publish leaves no tag, so
|
||||||
|
re-running the workflow safely retries).
|
||||||
A beta's code sits below its own stable release and above everything before
|
|
||||||
it, so a beta install updates in place to the next beta and then to the stable
|
|
||||||
version. Every 1.0.x keeps the code it already has, and `1.1.0-beta.1`
|
|
||||||
(`1010001`) is above all of them. `MINOR` and `PATCH` each have room for 0–99.
|
|
||||||
Run `scripts/version_info.sh` to see what the committed version resolves to;
|
|
||||||
CI fails a PR whose committed `versionCode` doesn't match, because the official
|
|
||||||
F-Droid repo builds the tag exactly as committed.
|
|
||||||
|
|
||||||
The release pipeline reads `versionName`, pins `versionCode` to the derived
|
|
||||||
value, builds, publishes, and — once the APK is live — creates the tag
|
|
||||||
`v<versionName>` at that commit. The tag is an **output** of a successful
|
|
||||||
release, not its trigger, so a tag always marks a fully-shipped version (and a
|
|
||||||
failure before publish leaves no tag, so re-running the workflow safely
|
|
||||||
retries).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -56,19 +39,16 @@ retries).
|
|||||||
|
|
||||||
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
|
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
|
||||||
feature/fix branches for this release into it. Everything below happens on
|
feature/fix branches for this release into it. Everything below happens on
|
||||||
that branch, before it reaches `main`. To ship betas first, follow
|
that branch, before it reaches `main`.
|
||||||
[Cutting a beta](#cutting-a-beta) from here and come back to step 2 when
|
|
||||||
going stable.
|
|
||||||
2. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items under a new
|
2. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items under a new
|
||||||
`## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading
|
`## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading
|
||||||
and the next `## [` becomes the Gitea and Codeberg release notes. The
|
and the next `## [` becomes the Gitea and Codeberg release notes. The
|
||||||
heading **must** match the version exactly.
|
heading **must** match the version exactly.
|
||||||
3. **Bump the committed `versionName`** (and `versionCode`, which
|
3. **Bump the committed `versionName`** (and `versionCode`) in
|
||||||
`scripts/version_info.sh` prints) in `app/build.gradle.kts`. **This bump is what triggers the release** when the
|
`app/build.gradle.kts`. **This bump is what triggers the release** when the
|
||||||
branch merges to `main`.
|
branch merges to `main`.
|
||||||
Then write this version's **"What's New"** by hand:
|
Then write this version's **"What's New"** by hand:
|
||||||
`fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt` (e.g.
|
`fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt`, one per
|
||||||
`1010099.txt` for 1.1.0), one per
|
|
||||||
shipped locale, each a short summary **under 500 characters** — F-Droid and
|
shipped locale, each a short summary **under 500 characters** — F-Droid and
|
||||||
Play both publish these files, and Play rejects a longer one. It is not a
|
Play both publish these files, and Play rejects a longer one. It is not a
|
||||||
copy of the CHANGELOG.md section. `scripts/sync_changelog_to_fastlane.sh`
|
copy of the CHANGELOG.md section. `scripts/sync_changelog_to_fastlane.sh`
|
||||||
@@ -99,48 +79,6 @@ retries).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cutting a beta
|
|
||||||
|
|
||||||
A beta is a test build of the next version, published as a **pre-release on
|
|
||||||
Codeberg** and nowhere else. It is signed with the real app key, so testers
|
|
||||||
install it over their stable install and it updates in place to later betas
|
|
||||||
and to the stable release.
|
|
||||||
|
|
||||||
1. **On `release/vX.Y.Z`**, with the features merged in, set
|
|
||||||
`versionName = "X.Y.Z-beta.1"` and the matching `versionCode`
|
|
||||||
(`scripts/version_info.sh` prints it; `1.1.0-beta.1` → `1010001`).
|
|
||||||
No What's New file — betas don't ship to the stores. Notes come from a
|
|
||||||
`## [X.Y.Z-beta.N]` section of `CHANGELOG.md` if you write one, otherwise
|
|
||||||
from `## [Unreleased]`. So keep the entries under `## [Unreleased]` while
|
|
||||||
betas are going out and only move them to `## [X.Y.Z]` when going stable:
|
|
||||||
once they're moved, a beta's notes come out empty.
|
|
||||||
2. **Optionally verify it on a device** with `scripts/verify-release.sh`, as for
|
|
||||||
a stable release.
|
|
||||||
3. **Push the branch to Codeberg.** When it reaches Gitea, `beta.yaml` sees a
|
|
||||||
beta version without a tag, runs the unit tests, builds and signs the APK,
|
|
||||||
creates the `vX.Y.Z-beta.1` tag + a Gitea pre-release (with the R8 mapping)
|
|
||||||
and publishes the **Codeberg pre-release** with the APK + `.sha256`.
|
|
||||||
4. **Next round:** bump to `-beta.2` (and its `versionCode`) and push again.
|
|
||||||
5. **Going stable:** set `versionName = "X.Y.Z"` and its `versionCode`
|
|
||||||
(`1.1.0` → `1010099`), then continue from step 2 of
|
|
||||||
[Cutting a release](#cutting-a-release).
|
|
||||||
|
|
||||||
Who gets a beta:
|
|
||||||
|
|
||||||
- **Obtainium** users only with *Include prereleases* switched on for the app.
|
|
||||||
That is how a tester opts in; everyone else stays on stable.
|
|
||||||
- **F-Droid** (self-hosted and official) never: `beta.yaml` doesn't touch the
|
|
||||||
self-hosted repo, and the official recipe's `UpdateCheckMode: Tags ^v[0-9.]+$`
|
|
||||||
ignores `-beta` tags.
|
|
||||||
- **Play** never.
|
|
||||||
|
|
||||||
Guards: a beta version can't reach `main` (CI fails the PR, and `release.yaml`'s
|
|
||||||
`detect` refuses one as a backstop), `beta.yaml` refuses a beta of a version
|
|
||||||
that already shipped stable, and betas start at 1.1.0 (the 1.0.x codes have no
|
|
||||||
room below them).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What the pipeline does
|
## What the pipeline does
|
||||||
|
|
||||||
CI and release are split so a change is built once on its PR and only does
|
CI and release are split so a change is built once on its PR and only does
|
||||||
@@ -156,12 +94,10 @@ release work when a merge actually cuts a release:
|
|||||||
`values-*/strings.xml`, so Weblate PRs get fast feedback. Runs on every PR
|
`values-*/strings.xml`, so Weblate PRs get fast feedback. Runs on every PR
|
||||||
without a path filter, so the required `Translations / check` status is always
|
without a path filter, so the required `Translations / check` status is always
|
||||||
reported.
|
reported.
|
||||||
- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC, on push to
|
- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC plus
|
||||||
`main`, plus `workflow_dispatch`, **Gitea**) — the dependency sweep. Runs the
|
`workflow_dispatch`, **Gitea**) — the dependency sweep. Runs the pinned
|
||||||
pinned Renovate image as its job container and targets Codeberg's API. New
|
Renovate image as its job container and targets Codeberg's API; the cadence
|
||||||
PRs only open in the Monday `schedule` window in `renovate.json5` (keep it
|
lives here, not in `renovate.json5`, so the two don't double-gate.
|
||||||
bracketing the cron); the on-merge runs fall outside it and only rebase
|
|
||||||
existing PRs that the merge left conflicted.
|
|
||||||
- **`release.yaml`** (`.gitea/workflows/`, on push to `main` plus
|
- **`release.yaml`** (`.gitea/workflows/`, on push to `main` plus
|
||||||
`workflow_dispatch`, **Gitea**) — a cheap `detect` job reads `versionName` and
|
`workflow_dispatch`, **Gitea**) — a cheap `detect` job reads `versionName` and
|
||||||
checks **Codeberg** for a tag for it. Only when there isn't one does the
|
checks **Codeberg** for a tag for it. Only when there isn't one does the
|
||||||
@@ -174,12 +110,6 @@ release work when a merge actually cuts a release:
|
|||||||
**Codeberg** with the signed APK + a SHA-256 checksum, and finally builds the
|
**Codeberg** with the signed APK + a SHA-256 checksum, and finally builds the
|
||||||
App Bundle. Ordinary merges with no version bump fall through `detect` and do
|
App Bundle. Ordinary merges with no version bump fall through `detect` and do
|
||||||
nothing.
|
nothing.
|
||||||
- **`beta.yaml`** (`.gitea/workflows/`, on push to `release/**`, **Gitea**) —
|
|
||||||
the same `detect` idea for betas: only when the committed `versionName` is
|
|
||||||
`X.Y.Z-beta.N` and Codeberg has no tag for it does the `beta` job run: unit
|
|
||||||
tests, pin `versionCode`, build & sign the release APK with the **app key**,
|
|
||||||
create the tag + a Gitea pre-release with the R8 mapping, and publish the
|
|
||||||
**Codeberg pre-release** with the APK + SHA-256. No F-Droid, no Play.
|
|
||||||
- **`play` job** (same workflow, after `release`) — uploads the App Bundle and
|
- **`play` job** (same workflow, after `release`) — uploads the App Bundle and
|
||||||
every locale's "What's New" to Google Play. Runs last and separately so a Play
|
every locale's "What's New" to Google Play. Runs last and separately so a Play
|
||||||
rejection can't endanger a release that already shipped; skips cleanly until
|
rejection can't endanger a release that already shipped; skips cleanly until
|
||||||
@@ -193,8 +123,7 @@ Releases aren't git objects and don't sync with the push mirror in either
|
|||||||
direction, so the pipeline pushes the `vX.Y.Z` tag straight to Codeberg, creates
|
direction, so the pipeline pushes the `vX.Y.Z` tag straight to Codeberg, creates
|
||||||
the release over the Codeberg API, and attaches `agendula_v<version>.apk` + its
|
the release over the Codeberg API, and attaches `agendula_v<version>.apk` + its
|
||||||
`.sha256`. It's the same APK the F-Droid repo serves (same **app key**), so it
|
`.sha256`. It's the same APK the F-Droid repo serves (same **app key**), so it
|
||||||
adds no trust surface. The logic lives in `scripts/publish_codeberg_release.sh`,
|
adds no trust surface. It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset,
|
||||||
which `beta.yaml` uses too (with the pre-release flag set). It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset,
|
|
||||||
but it is **not** `continue-on-error`: through 0.2.1–0.3.2 this step reported
|
but it is **not** `continue-on-error`: through 0.2.1–0.3.2 this step reported
|
||||||
green while never once publishing, which is how a crash-fix release reached
|
green while never once publishing, which is how a crash-fix release reached
|
||||||
F-Droid but not the Codeberg/Obtainium users who needed it. A broken mirror
|
F-Droid but not the Codeberg/Obtainium users who needed it. A broken mirror
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ on 2026-09-24. Its CI rebuilt v1.0.0 and verified it against our published APK
|
|||||||
|
|
||||||
The file here is a copy of the submitted recipe. fdroiddata's copy is the one
|
The file here is a copy of the submitted recipe. fdroiddata's copy is the one
|
||||||
that counts; after the merge F-Droid picks up new `vX.Y.Z` tags on its own
|
that counts; after the merge F-Droid picks up new `vX.Y.Z` tags on its own
|
||||||
(`AutoUpdateMode`), so there is no per-release work there. Beta tags
|
(`AutoUpdateMode`), so there is no per-release work there. Only a change to the
|
||||||
(`vX.Y.Z-beta.N`) don't match `UpdateCheckMode: Tags ^v[0-9.]+$`, so betas stay
|
|
||||||
off the official repo; keep that pattern if the recipe ever changes. Only a change to the
|
|
||||||
recipe itself (a new submodule, a build flag) needs an MR, pinned to a full
|
recipe itself (a new submodule, a build flag) needs an MR, pinned to a full
|
||||||
commit hash, never a tag.
|
commit hash, never a tag.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Agendula gibt es jetzt bei Google Play. An der App hat sich seit 1.0.0 nichts geändert:
|
||||||
|
- CalDAV-Sync eingebaut: Nextcloud, Radicale, Baïkal und mehr.
|
||||||
|
- Agendula speichert deine Aufgaben selbst, keine andere App nötig.
|
||||||
|
- Wiederkehrende Aufgaben, mehrere Erinnerungen, Listen in der App,
|
||||||
|
iCalendar-Import und -Export, ein Widget und eine Schnelleinstellungs-Kachel.
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
### Neu
|
|
||||||
- Neue Widgets: Today, Up next, Lists und Week.
|
|
||||||
- Ein Widget kann mehrere Listen zeigen, auf Wunsch nach Liste gruppiert.
|
|
||||||
- Neue Aufgaben können heute beginnen (Einstellungen → Aufgaben-Formular).
|
|
||||||
|
|
||||||
### Geändert
|
|
||||||
- Das Aufgaben-Widget zeigt Zeilen im Agenda-Stil, optional farbig.
|
|
||||||
- „At a glance“ entfällt; die Widgets Aufgaben und Today decken es ab.
|
|
||||||
- Synchronisierte Listen lassen sich jetzt auch umbenennen und löschen.
|
|
||||||
|
|
||||||
### Behoben
|
|
||||||
- Doppelter Abstand im Listen-Editor.
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Agendula is now on Google Play. Nothing changed in the app since 1.0.0:
|
||||||
|
- CalDAV sync built in: Nextcloud, Radicale, Baïkal and more.
|
||||||
|
- Agendula keeps your tasks itself, no other app needed.
|
||||||
|
- Repeating tasks, several reminders per task, lists managed in the app,
|
||||||
|
iCalendar import and export, a home-screen widget and a Quick Settings tile.
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
### Added
|
|
||||||
- New home-screen widgets: Today, Up next, Lists and Week.
|
|
||||||
- A widget can show tasks from several lists, optionally grouped by list.
|
|
||||||
- Settings → Task form can pre-fill a new task's start with today.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- The Tasks widget has agenda-style rows and an optional row colour.
|
|
||||||
- "At a glance" is gone; the Tasks and Today widgets cover it.
|
|
||||||
- Synced lists can now be renamed and deleted too.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Double padding in the list editor.
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Agendula is now on Google Play. Nothing changed in the app since 1.0.0:
|
||||||
|
- CalDAV sync built in: Nextcloud, Radicale, Baïkal and more.
|
||||||
|
- Agendula keeps your tasks itself, no other app needed.
|
||||||
|
- Repeating tasks, several reminders per task, lists managed in the app,
|
||||||
|
iCalendar import and export, a home-screen widget and a Quick Settings tile.
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
### Added
|
|
||||||
- New home-screen widgets: Today, Up next, Lists and Week.
|
|
||||||
- A widget can show tasks from several lists, optionally grouped by list.
|
|
||||||
- Settings → Task form can pre-fill a new task's start with today.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- The Tasks widget has agenda-style rows and an optional row colour.
|
|
||||||
- "At a glance" is gone; the Tasks and Today widgets cover it.
|
|
||||||
- Synced lists can now be renamed and deleted too.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Double padding in the list editor.
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
O Agendula agora está no Google Play. Nada mudou no app desde a 1.0.0:
|
||||||
|
- Sincronização CalDAV integrada: Nextcloud, Radicale, Baïkal e outros.
|
||||||
|
- O Agendula guarda suas tarefas sozinho, sem outro app.
|
||||||
|
- Tarefas recorrentes, vários lembretes por tarefa, listas no app, importação e
|
||||||
|
exportação iCalendar, widget e bloco de Configurações rápidas.
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
### Novo
|
|
||||||
- Novos widgets: Today, Up next, Lists e Week.
|
|
||||||
- Um widget pode mostrar várias listas, agrupadas por lista se quiser.
|
|
||||||
- Novas tarefas podem começar hoje (Ajustes → Formulário de tarefa).
|
|
||||||
|
|
||||||
### Alterado
|
|
||||||
- O widget Tasks mostra linhas em estilo agenda, com cor opcional.
|
|
||||||
- "At a glance" saiu; os widgets Tasks e Today cobrem o que ele mostrava.
|
|
||||||
- Listas sincronizadas agora também podem ser renomeadas e excluídas.
|
|
||||||
|
|
||||||
### Corrigido
|
|
||||||
- Espaçamento duplo no editor de listas.
|
|
||||||
+1
-1
Submodule floret-kit updated: cb109c0e82...db4be68dad
@@ -177,7 +177,6 @@ floret-core-locale = { group = "de.jeanlucmakiola.floret", name = "core-locale"
|
|||||||
floret-core-crash = { group = "de.jeanlucmakiola.floret", name = "core-crash" }
|
floret-core-crash = { group = "de.jeanlucmakiola.floret", name = "core-crash" }
|
||||||
floret-identity = { group = "de.jeanlucmakiola.floret", name = "identity" }
|
floret-identity = { group = "de.jeanlucmakiola.floret", name = "identity" }
|
||||||
floret-components = { group = "de.jeanlucmakiola.floret", name = "components" }
|
floret-components = { group = "de.jeanlucmakiola.floret", name = "components" }
|
||||||
floret-glance = { group = "de.jeanlucmakiola.floret", name = "glance" }
|
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
+3
-10
@@ -52,16 +52,9 @@
|
|||||||
prConcurrentLimit: 5,
|
prConcurrentLimit: 5,
|
||||||
prHourlyLimit: 0,
|
prHourlyLimit: 0,
|
||||||
|
|
||||||
// New branches/PRs only in the Monday window (UTC), which brackets the weekly
|
// Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml,
|
||||||
// cron in .gitea/workflows/renovate.yml with slack for a late start — keep
|
// Mondays) — no internal `schedule` here, so the two don't double-gate and
|
||||||
// the two in step, or the sweep lands outside the window and finds nothing.
|
// silently skip a run.
|
||||||
// The same workflow also runs on every push to main; those runs fall outside
|
|
||||||
// the window and, via the default updateNotScheduled: true, only maintain
|
|
||||||
// existing branches — chiefly rebasing PRs a merge just conflicted.
|
|
||||||
schedule: ["* 4-6 * * 1"],
|
|
||||||
// Rebase only on conflict, not every time main moves (automerge is off, so
|
|
||||||
// this matches "auto"; spelled out because the on-merge trigger relies on it).
|
|
||||||
rebaseWhen: "conflicted",
|
|
||||||
|
|
||||||
// Workflows are split by forge and neither directory is .github: CI lives in
|
// Workflows are split by forge and neither directory is .github: CI lives in
|
||||||
// .forgejo/workflows (Codeberg) and the release/renovate jobs in
|
// .forgejo/workflows (Codeberg) and the release/renovate jobs in
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ without setup.
|
|||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
import struct
|
import struct
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -121,11 +120,10 @@ def check_images(locale_dir):
|
|||||||
|
|
||||||
|
|
||||||
def version_code():
|
def version_code():
|
||||||
"""The committed version's code, or None for a beta (it ships no What's New)."""
|
gradle = (ROOT / "app/build.gradle.kts").read_text()
|
||||||
def info(key):
|
name = re.search(r'versionName\s*=\s*"([^"]+)"', gradle).group(1)
|
||||||
return subprocess.run(["bash", str(ROOT / "scripts/version_info.sh"), key],
|
parts = (name.split(".") + ["0", "0"])[:3]
|
||||||
check=True, capture_output=True, text=True).stdout.strip()
|
return int(parts[0]) * 10000 + int(parts[1]) * 100 + int(parts[2])
|
||||||
return None if info("channel") == "beta" else int(info("version_code"))
|
|
||||||
|
|
||||||
|
|
||||||
def shipped_languages():
|
def shipped_languages():
|
||||||
@@ -185,7 +183,7 @@ def main():
|
|||||||
err(images / "featureGraphic.png", "required for Play (1024x500, no alpha)")
|
err(images / "featureGraphic.png", "required for Play (1024x500, no alpha)")
|
||||||
if len(list((images / "phoneScreenshots").glob("*"))) < 2:
|
if len(list((images / "phoneScreenshots").glob("*"))) < 2:
|
||||||
err(images / "phoneScreenshots", "Play requires at least 2 phone screenshots")
|
err(images / "phoneScreenshots", "Play requires at least 2 phone screenshots")
|
||||||
for loc in REQUIRED_LOCALES if current is not None else []:
|
for loc in REQUIRED_LOCALES:
|
||||||
if not (META / loc / "changelogs" / f"{current}.txt").exists():
|
if not (META / loc / "changelogs" / f"{current}.txt").exists():
|
||||||
err(META / loc / "changelogs" / f"{current}.txt", "no What's New for this version")
|
err(META / loc / "changelogs" / f"{current}.txt", "no What's New for this version")
|
||||||
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Publish TAG on Codeberg, which is canonical for tags and releases, with the
|
|
||||||
# signed APK and its SHA-256 checksum attached: the direct-download channel
|
|
||||||
# that Obtainium follows. Shared by .gitea/workflows/release.yaml (stable) and
|
|
||||||
# beta.yaml (pre-release). Run from the repo checkout: it pushes the tag.
|
|
||||||
#
|
|
||||||
# Codeberg push-mirrors branches and tags to Gitea, but releases aren't git
|
|
||||||
# objects and don't sync in either direction, so this pushes the tag straight
|
|
||||||
# to Codeberg and creates the release over the API. Skips cleanly when TOKEN is
|
|
||||||
# unset; fails loudly on anything else (see release.yaml for why).
|
|
||||||
#
|
|
||||||
# Env:
|
|
||||||
# TOKEN CODEBERG_RELEASE_TOKEN (write:repository); empty = skip
|
|
||||||
# API https://codeberg.org/api/v1/repos/<owner>/<repo>
|
|
||||||
# TAG, SHA vX.Y.Z[-beta.N] and the commit it marks
|
|
||||||
# PRERELEASE true | false
|
|
||||||
# NOTES_FILE release notes (scripts/release_notes.sh)
|
|
||||||
# APK the signed release APK
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -z "${TOKEN:-}" ]; then
|
|
||||||
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
: "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}" "${APK:?}"
|
|
||||||
if [ ! -f "$APK" ]; then echo "No release APK at $APK." >&2; exit 1; fi
|
|
||||||
|
|
||||||
WORK=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$WORK"' EXIT
|
|
||||||
|
|
||||||
ASSET_APK="agendula_${TAG}.apk"
|
|
||||||
ASSET_SUM="${ASSET_APK}.sha256"
|
|
||||||
cp "$APK" "$WORK/$ASSET_APK"
|
|
||||||
( cd "$WORK" && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
|
||||||
|
|
||||||
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the mirror runs
|
|
||||||
# Codeberg -> Gitea, so waiting for a tag to arrive from Gitea (what 0.3.2 did)
|
|
||||||
# would wait forever. The tag 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 creating the release 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.
|
|
||||||
HOST=${API#https://}; HOST=${HOST%%/*}
|
|
||||||
REPO=${API#*/repos/}
|
|
||||||
git tag -f "$TAG" "$SHA"
|
|
||||||
git push -f "https://${REPO%%/*}:${TOKEN}@${HOST}/${REPO}.git" "refs/tags/$TAG"
|
|
||||||
|
|
||||||
python3 - "$TAG" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json"
|
|
||||||
import json, sys
|
|
||||||
tag, pre, notes = sys.argv[1:4]
|
|
||||||
print(json.dumps({
|
|
||||||
"tag_name": tag,
|
|
||||||
"name": tag,
|
|
||||||
"body": open(notes).read(),
|
|
||||||
"draft": False,
|
|
||||||
"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
|
|
||||||
# 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). 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 @"$WORK/payload.json" "$API/releases/$EXIST"
|
|
||||||
ID="$EXIST"; break
|
|
||||||
fi
|
|
||||||
CODE=$(curl -s -o "$WORK/response.json" -w "%{http_code}" -X POST \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @"$WORK/payload.json" "$API/releases")
|
|
||||||
echo "release POST attempt $attempt HTTP $CODE"
|
|
||||||
ID=$(jq -r '.id // empty' "$WORK/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=@$WORK/$A" \
|
|
||||||
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
|
||||||
done
|
|
||||||
echo "Published $TAG to Codeberg."
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Create (or update) the release for TAG on the Gitea build instance, the
|
|
||||||
# changelog of record there, and attach the R8 mapping. Shared by
|
|
||||||
# .gitea/workflows/release.yaml and beta.yaml.
|
|
||||||
#
|
|
||||||
# Creating it with target_commitish makes Gitea create the tag at SHA, so the
|
|
||||||
# tag only ever marks a fully-shipped version. Upsert, so re-runs are safe.
|
|
||||||
#
|
|
||||||
# Env:
|
|
||||||
# TOKEN, API Gitea token and .../api/v1/repos/<owner>/<repo>
|
|
||||||
# TAG, SHA vX.Y.Z[-beta.N] and the commit it marks
|
|
||||||
# PRERELEASE true | false
|
|
||||||
# NOTES_FILE release notes (scripts/release_notes.sh)
|
|
||||||
# MAPPING optional path to R8's mapping.txt
|
|
||||||
set -euo pipefail
|
|
||||||
: "${TOKEN:?}" "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}"
|
|
||||||
|
|
||||||
WORK=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$WORK"' EXIT
|
|
||||||
|
|
||||||
python3 - "$TAG" "$SHA" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json"
|
|
||||||
import json, sys
|
|
||||||
tag, sha, pre, notes = sys.argv[1:5]
|
|
||||||
print(json.dumps({
|
|
||||||
"tag_name": tag,
|
|
||||||
"target_commitish": sha,
|
|
||||||
"name": tag,
|
|
||||||
"body": open(notes).read(),
|
|
||||||
"draft": False,
|
|
||||||
"prerelease": pre == "true",
|
|
||||||
}))
|
|
||||||
PY
|
|
||||||
|
|
||||||
# PATCH if a release for the tag already exists, else POST a new one (which
|
|
||||||
# also creates the tag at target_commitish).
|
|
||||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
|
|
||||||
if [ -n "$ID" ]; then
|
|
||||||
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X PATCH \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @"$WORK/payload.json" "$API/releases/$ID")
|
|
||||||
OK=200
|
|
||||||
else
|
|
||||||
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X POST \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @"$WORK/payload.json" "$API/releases")
|
|
||||||
OK=201
|
|
||||||
fi
|
|
||||||
cat "$WORK/response.json"; echo
|
|
||||||
if [ "$CODE" != "$OK" ]; then
|
|
||||||
echo "Gitea release upsert failed with HTTP $CODE (expected $OK)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ID=$(jq -r '.id' "$WORK/response.json")
|
|
||||||
echo "Created/updated Gitea release $TAG at $SHA"
|
|
||||||
|
|
||||||
# The R8 mapping keeps user crash stacktraces deobfuscatable. It's not an APK,
|
|
||||||
# so it fits the no-binaries rule for this release. Best-effort: never fail a
|
|
||||||
# release over it.
|
|
||||||
attach_mapping() {
|
|
||||||
local asset="mapping-${TAG#v}.txt.gz" old
|
|
||||||
gzip -c "$MAPPING" > "$WORK/$asset"
|
|
||||||
# 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')
|
|
||||||
if [ -n "$old" ]; then
|
|
||||||
curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$old" >/dev/null
|
|
||||||
fi
|
|
||||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@$WORK/$asset" \
|
|
||||||
"$API/releases/$ID/assets?name=$asset" -o /dev/null -w "asset $asset HTTP %{http_code}\n"
|
|
||||||
}
|
|
||||||
if [ -z "${MAPPING:-}" ] || [ ! -f "$MAPPING" ]; then
|
|
||||||
echo "No mapping.txt (R8 off?) — skipping."
|
|
||||||
else
|
|
||||||
attach_mapping || echo "warning: could not attach the R8 mapping to $TAG" >&2
|
|
||||||
fi
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Print the forge release notes for VERSION: its `## [VERSION]` section of
|
|
||||||
# CHANGELOG.md. A beta (X.Y.Z-beta.N) normally has no section of its own, so it
|
|
||||||
# falls back to `## [Unreleased]`, i.e. what the release branch carries so far,
|
|
||||||
# under a line saying what a beta is.
|
|
||||||
#
|
|
||||||
# scripts/release_notes.sh 1.1.0 > release-notes.md
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.." # repo root
|
|
||||||
|
|
||||||
VERSION=${1:?usage: release_notes.sh VERSION}
|
|
||||||
|
|
||||||
section() {
|
|
||||||
awk -v ver="$1" '
|
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
|
||||||
/^## \[/ { flag = 0 }
|
|
||||||
flag' CHANGELOG.md | sed -e '/./,$!d'
|
|
||||||
}
|
|
||||||
|
|
||||||
NOTES=$(section "$VERSION")
|
|
||||||
if [[ "$VERSION" == *-beta.* ]]; then
|
|
||||||
[ -n "$NOTES" ] || NOTES=$(section Unreleased)
|
|
||||||
printf '%s\n\n' "**Beta of ${VERSION%%-*}, for testing.** It updates in place to later betas and to the stable release. Obtainium only offers betas with *Include prereleases* switched on; F-Droid and Play never get them."
|
|
||||||
fi
|
|
||||||
if [ -n "$NOTES" ]; then
|
|
||||||
printf '%s\n' "$NOTES"
|
|
||||||
else
|
|
||||||
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._"
|
|
||||||
fi
|
|
||||||
@@ -11,15 +11,11 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.." # repo root
|
cd "$(dirname "$0")/.." # repo root
|
||||||
|
|
||||||
VERSION=$(bash scripts/version_info.sh version)
|
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||||
VERSION_CODE=$(bash scripts/version_info.sh version_code)
|
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
|
||||||
|
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
|
||||||
# Betas only ship to Codeberg, whose notes come from CHANGELOG.md. A What's New
|
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||||
# file for one would sit in the tree F-Droid and Play read, so none is wanted.
|
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||||
if [ "$(bash scripts/version_info.sh channel)" = beta ]; then
|
|
||||||
echo "Beta $VERSION: no store What's New (betas only ship to Codeberg)."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Play rejects a longer "What's New" and F-Droid truncates it in-client.
|
# Play rejects a longer "What's New" and F-Droid truncates it in-client.
|
||||||
MAX_CHARS=${MAX_CHARS:-500}
|
MAX_CHARS=${MAX_CHARS:-500}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# The one place that turns the committed versionName into a versionCode and a
|
|
||||||
# release channel. The release and beta pipelines, the changelog sync and the
|
|
||||||
# store-listing check all read it from here, so the scheme can't drift between
|
|
||||||
# them.
|
|
||||||
#
|
|
||||||
# versionName channel versionCode
|
|
||||||
# X.Y.Z (< 1.1.0) stable X*10000 + Y*100 + Z (legacy)
|
|
||||||
# X.Y.Z-beta.N beta X*1000000 + Y*10000 + Z*100 + N (N = 1..98)
|
|
||||||
# X.Y.Z (>= 1.1.0) stable X*1000000 + Y*10000 + Z*100 + 99
|
|
||||||
#
|
|
||||||
# A beta's code sits below its own stable release and above everything before
|
|
||||||
# it, so a beta install updates in place to the next beta and then to the
|
|
||||||
# stable version. Every 1.0.x keeps the legacy code it already shipped with;
|
|
||||||
# the first new-scheme code (1.1.0-beta.1 -> 1010001) is above all of them.
|
|
||||||
#
|
|
||||||
# scripts/version_info.sh print version, version_code, base_version,
|
|
||||||
# channel and prerelease as key=value lines
|
|
||||||
# (ready for $GITHUB_OUTPUT)
|
|
||||||
# scripts/version_info.sh <key> print just that value
|
|
||||||
# scripts/version_info.sh --check also fail unless the committed versionCode
|
|
||||||
# matches; the official F-Droid repo builds
|
|
||||||
# the tag as committed, so it must
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.." # repo root
|
|
||||||
|
|
||||||
GRADLE="app/build.gradle.kts"
|
|
||||||
NAME=$(grep -oP 'versionName\s*=\s*"\K[^"]+' "$GRADLE" || true)
|
|
||||||
COMMITTED=$(grep -oP 'versionCode\s*=\s*\K[0-9]+' "$GRADLE" || true)
|
|
||||||
[ -n "$NAME" ] || { echo "No versionName in $GRADLE" >&2; exit 1; }
|
|
||||||
|
|
||||||
if [[ ! "$NAME" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-beta\.([0-9]+))?$ ]]; then
|
|
||||||
echo "versionName '$NAME' is neither X.Y.Z nor X.Y.Z-beta.N" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
MAJOR=$((10#${BASH_REMATCH[1]})); MINOR=$((10#${BASH_REMATCH[2]})); PATCH=$((10#${BASH_REMATCH[3]}))
|
|
||||||
BETA=${BASH_REMATCH[5]:+$((10#${BASH_REMATCH[5]}))}
|
|
||||||
BASE="$MAJOR.$MINOR.$PATCH"
|
|
||||||
|
|
||||||
if [ "$MINOR" -gt 99 ] || [ "$PATCH" -gt 99 ]; then
|
|
||||||
echo "versionName '$NAME': MINOR and PATCH each have room for 0-99" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
LEGACY=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
|
||||||
if [ -n "$BETA" ]; then
|
|
||||||
if [ "$LEGACY" -lt 10100 ]; then
|
|
||||||
echo "versionName '$NAME': betas start at 1.1.0; 1.0.x codes have no room for them" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$BETA" -lt 1 ] || [ "$BETA" -gt 98 ]; then
|
|
||||||
echo "versionName '$NAME': beta number must be 1-98 (99 is the stable release)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
CHANNEL=beta
|
|
||||||
CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + BETA ))
|
|
||||||
elif [ "$LEGACY" -lt 10100 ]; then
|
|
||||||
CHANNEL=stable
|
|
||||||
CODE=$LEGACY
|
|
||||||
else
|
|
||||||
CHANNEL=stable
|
|
||||||
CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + 99 ))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Betas are always pre-releases; so was everything before 1.0.0.
|
|
||||||
if [ "$CHANNEL" = beta ] || [ "$MAJOR" = 0 ]; then PRERELEASE=true; else PRERELEASE=false; fi
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
"")
|
|
||||||
printf 'version=%s\nversion_code=%s\nbase_version=%s\nchannel=%s\nprerelease=%s\n' \
|
|
||||||
"$NAME" "$CODE" "$BASE" "$CHANNEL" "$PRERELEASE"
|
|
||||||
;;
|
|
||||||
--check)
|
|
||||||
if [ "$COMMITTED" != "$CODE" ]; then
|
|
||||||
echo "ERROR: $GRADLE has versionCode = ${COMMITTED:-<none>}, but versionName '$NAME' needs $CODE." >&2
|
|
||||||
echo "Set versionCode = $CODE (see docs/RELEASING.md for the scheme)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "OK: versionName $NAME -> versionCode $CODE ($CHANNEL)."
|
|
||||||
;;
|
|
||||||
version) echo "$NAME" ;;
|
|
||||||
version_code) echo "$CODE" ;;
|
|
||||||
base_version) echo "$BASE" ;;
|
|
||||||
channel) echo "$CHANNEL" ;;
|
|
||||||
prerelease) echo "$PRERELEASE" ;;
|
|
||||||
*) echo "Unknown key '$1'" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
Reference in New Issue
Block a user