Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fde7ae290 | ||
|
|
5d273e05c7 | ||
|
|
59582b3641 | ||
|
|
3540d2fb7c | ||
|
|
3812e950ca | ||
|
|
c6d62c36f4 | ||
|
|
a4fbbf43c6 | ||
|
|
d81e99084e | ||
|
|
9a87837917 | ||
|
|
9630bd104d | ||
|
|
7c3b62eb0d | ||
|
|
fe0c7d83a9 | ||
|
|
f92f9bcdad | ||
|
|
53d5e2c5aa | ||
|
|
6888965f5a | ||
|
|
f1ba21e7fa | ||
|
|
8abc44bbdf | ||
|
|
a6e3a35f04 | ||
|
|
a3de9f3d93 | ||
|
|
c823243e53 |
@@ -37,11 +37,29 @@ jobs:
|
||||
- name: Reproducible-release invariant
|
||||
run: bash scripts/check_reproducible_release.sh
|
||||
|
||||
# The committed versionName must parse (X.Y.Z or X.Y.Z-beta.N) and the
|
||||
# committed versionCode must be the one it derives: the official F-Droid
|
||||
# repo builds the tag as committed. And a beta must never reach main,
|
||||
# where the release pipeline would ship it to F-Droid and Play; betas are
|
||||
# cut from release/* branches (docs/RELEASING.md).
|
||||
- name: Committed version is well-formed
|
||||
env:
|
||||
BASE: ${{ github.base_ref }}
|
||||
run: |
|
||||
set -e
|
||||
bash scripts/version_info.sh --check
|
||||
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
|
||||
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
|
||||
echo "Set the stable version (and its versionCode) before merging into main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Also cheap, also always-on. Two failures in one: the script exits
|
||||
# non-zero if this version's changelog is over the 500-character limit,
|
||||
# and the porcelain check below catches a version with no committed
|
||||
# changelog, which would otherwise ship the CHANGELOG.md section instead
|
||||
# of a hand-written summary.
|
||||
# of a hand-written summary. A beta version passes both: it ships no
|
||||
# What's New, so the script writes nothing for it.
|
||||
- name: Changelog fits the stores, and is committed
|
||||
run: |
|
||||
set -e
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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
|
||||
+34
-177
@@ -7,7 +7,9 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
||||
# the release on Codeberg with the signed APK + a SHA-256 checksum as a
|
||||
# direct-download channel — the tag is an output of the pipeline, not its
|
||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||
# nothing.
|
||||
# nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
|
||||
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
|
||||
# one that reaches main.
|
||||
#
|
||||
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
|
||||
# and separate because Play can reject a good build for reasons the pipeline
|
||||
@@ -73,23 +75,25 @@ jobs:
|
||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||
run: |
|
||||
set -e
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
|
||||
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
|
||||
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
|
||||
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
|
||||
# versionName -> versionCode, channel and the pre-release flag (set
|
||||
# while MAJOR was 0) all come from the one script beta.yaml uses too.
|
||||
INFO=$(bash scripts/version_info.sh)
|
||||
echo "$INFO"
|
||||
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
|
||||
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "Manual dispatch — re-sign path, not a release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# 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
|
||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||
#
|
||||
@@ -196,8 +200,8 @@ jobs:
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
# The committed versionName is the source of truth. Pin versionCode to the
|
||||
# value derived from it so the published APK's code is always
|
||||
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
||||
# value scripts/version_info.sh derives from it, so the published APK's
|
||||
# code follows the scheme even if the committed code was forgotten.
|
||||
- name: Pin versionCode to versionName
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: |
|
||||
@@ -332,6 +336,8 @@ jobs:
|
||||
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
||||
# this commit, so the tag only ever marks a fully-shipped release (and a
|
||||
# failure before here leaves no tag, so re-running the workflow retries).
|
||||
# Also attaches the R8 mapping (best-effort) so user crash stacktraces
|
||||
# stay deobfuscatable. Notes = this version's CHANGELOG section.
|
||||
- name: Create tag + Gitea release
|
||||
if: env.IS_RELEASE == 'true'
|
||||
env:
|
||||
@@ -340,85 +346,17 @@ jobs:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
TAG="v$VERSION"
|
||||
# Notes = this version's CHANGELOG section.
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > release-notes.md
|
||||
sed -i -e '/./,$!d' release-notes.md
|
||||
if [ ! -s release-notes.md ]; then
|
||||
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
|
||||
fi
|
||||
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json
|
||||
import json, sys
|
||||
print(json.dumps({
|
||||
"tag_name": sys.argv[1],
|
||||
"target_commitish": sys.argv[2],
|
||||
"name": sys.argv[1],
|
||||
"body": open("release-notes.md").read(),
|
||||
"draft": False,
|
||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||
"prerelease": sys.argv[3] == "true",
|
||||
}))
|
||||
PY
|
||||
# Upsert (re-run safe): PATCH if a release for the tag already exists,
|
||||
# else POST a new one (which also creates the tag at target_commitish).
|
||||
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
||||
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
|
||||
if [ -n "$ID" ]; then
|
||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @payload.json "$API/releases/$ID")
|
||||
OK=200
|
||||
else
|
||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @payload.json "$API/releases")
|
||||
OK=201
|
||||
fi
|
||||
cat response.json
|
||||
if [ "$CODE" != "$OK" ]; then
|
||||
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Created/updated release $TAG at $SHA"
|
||||
|
||||
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
|
||||
# Attached to the release (it's not an APK, so it fits the no-binaries
|
||||
# rule). Best-effort: never fail a release over it.
|
||||
- name: Attach R8 mapping to Gitea release
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
run: |
|
||||
set -e
|
||||
MAP="app/build/outputs/mapping/release/mapping.txt"
|
||||
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
|
||||
TAG="v$VERSION"
|
||||
ASSET="mapping-${VERSION}.txt.gz"
|
||||
gzip -c "$MAP" > "/tmp/$ASSET"
|
||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
|
||||
# Replace any prior asset of the same name (re-run safe).
|
||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
|
||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$ASSET" \
|
||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
||||
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
||||
MAPPING=app/build/outputs/mapping/release/mapping.txt \
|
||||
bash scripts/publish_gitea_release.sh
|
||||
|
||||
# Publish the release on Codeberg, which is canonical for tags and
|
||||
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
|
||||
# to Gitea, but releases aren't git objects and don't sync in either
|
||||
# direction — so this step pushes the tag straight to Codeberg and creates
|
||||
# the release there over the API, attaching the signed APK plus a SHA-256
|
||||
# checksum as the direct-download channel for users who don't want
|
||||
# F-Droid. The APK is identical to the F-Droid one (same app key), so this
|
||||
# adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips
|
||||
# cleanly if unset.
|
||||
# releases (see docs/RELEASING.md): push the tag there and attach the
|
||||
# signed APK plus a SHA-256 checksum as the direct-download channel for
|
||||
# users who don't want F-Droid. The APK is identical to the F-Droid one
|
||||
# (same app key), so this adds no trust surface. Needs the
|
||||
# CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
||||
- name: Publish release to Codeberg
|
||||
if: env.IS_RELEASE == 'true'
|
||||
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
||||
@@ -431,91 +369,10 @@ jobs:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "${TOKEN:-}" ]; then
|
||||
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
||||
exit 0
|
||||
fi
|
||||
TAG="v$VERSION"
|
||||
APK="app/build/outputs/apk/release/app-release.apk"
|
||||
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
|
||||
ASSET_APK="agendula_v${VERSION}.apk"
|
||||
ASSET_SUM="${ASSET_APK}.sha256"
|
||||
cp "$APK" "/tmp/$ASSET_APK"
|
||||
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
||||
|
||||
# Release notes: reuse the section extracted for the Gitea release,
|
||||
# fall back to the CHANGELOG entry if that step's file is gone.
|
||||
if [ ! -s release-notes.md ]; then
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > release-notes.md
|
||||
sed -i -e '/./,$!d' release-notes.md
|
||||
fi
|
||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
||||
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the
|
||||
# mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
|
||||
# from Gitea (what 0.3.2 did) would wait forever. The tag this
|
||||
# pipeline minted on Gitea is in fact *deleted* by the next mirror
|
||||
# sync until Codeberg has it — so pushing it here is what makes it
|
||||
# durable on both forges.
|
||||
#
|
||||
# Pushing the ref first and attaching with NO target_commitish is
|
||||
# deliberate: a release POST carrying a target_commitish for a commit
|
||||
# or tag Codeberg hasn't received yet is what produced the
|
||||
# empty-bodied 500s. Attaching to a ref that already exists doesn't
|
||||
# need the API to write one.
|
||||
git tag -f "$TAG" "$SHA"
|
||||
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
|
||||
"refs/tags/$TAG"
|
||||
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
|
||||
import json, sys
|
||||
tag, pre = sys.argv[1:3]
|
||||
print(json.dumps({
|
||||
"tag_name": tag,
|
||||
"name": tag,
|
||||
"body": open("release-notes.md").read(),
|
||||
"draft": False,
|
||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||
"prerelease": pre == "true",
|
||||
}))
|
||||
PY
|
||||
# Create (or update) the release. Codeberg 500s on a POST/GET against a
|
||||
# tag it has only just received — the release request outruns the
|
||||
# indexing of the ref we pushed a moment ago — so a single attempt can
|
||||
# fail even though the very same call succeeds seconds later. Retry
|
||||
# with backoff, and PATCH in place if a release already exists (re-run
|
||||
# safe, so re-running never disturbs a published release). A 5xx body
|
||||
# still exits curl 0, so the loop, not `set -e`, controls the flow.
|
||||
ID=""
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
|
||||
if [ -n "$EXIST" ]; then
|
||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases/$EXIST"
|
||||
ID="$EXIST"; break
|
||||
fi
|
||||
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases")
|
||||
echo "release POST attempt $attempt HTTP $CODE"
|
||||
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
||||
[ -n "$ID" ] && break
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
|
||||
|
||||
# Attach APK + checksum, replacing any prior asset of the same name.
|
||||
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
|
||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$A" \
|
||||
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
||||
done
|
||||
echo "Published $TAG to Codeberg."
|
||||
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
||||
APK=app/build/outputs/apk/release/app-release.apk \
|
||||
bash scripts/publish_codeberg_release.sh
|
||||
|
||||
# Play takes an App Bundle, not the APK: a second artifact from the same
|
||||
# source and signing config. Play treats the release key only as the
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
name: Renovate
|
||||
|
||||
on:
|
||||
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
|
||||
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
|
||||
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
|
||||
# same trigger release.yaml relies on), so a merged Renovate PR is followed
|
||||
# within minutes by a run that rebases the sibling PRs it just conflicted —
|
||||
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
|
||||
# `schedule` window such a run only maintains existing branches
|
||||
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
|
||||
# to renovate/* branches, not main, so this cannot loop.
|
||||
push:
|
||||
branches: [main]
|
||||
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
|
||||
# in renovate.json5 — keep the two in step.
|
||||
schedule:
|
||||
- cron: '0 5 * * 1'
|
||||
# Manual run for an on-demand sweep from the Actions tab.
|
||||
|
||||
@@ -7,6 +7,24 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Four new home-screen widgets: Today, Up next, Lists and Week.
|
||||
- A widget can show tasks from several lists at once, optionally grouped by
|
||||
list.
|
||||
- A setting under Settings → Task form pre-fills a new task's start with today.
|
||||
|
||||
### Changed
|
||||
- The Tasks widget has agenda-style rows, an optional row colour and an "All
|
||||
caught up" state. Ticking a task in any widget shows it as done for a moment
|
||||
before it disappears.
|
||||
- The "At a glance" widget is gone; the Tasks and Today widgets cover what it
|
||||
showed.
|
||||
- Synced lists can be renamed and deleted, not only device-only ones. A
|
||||
read-only share shows the edit button greyed out and explains why on tap.
|
||||
|
||||
### Fixed
|
||||
- The list editor's Where and smart-lists rows no longer have double padding.
|
||||
|
||||
## [1.0.0] - 2026-09-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -20,6 +20,14 @@ required.</p>
|
||||
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" alt="Home screen: today's progress, overdue and upcoming tasks, and your lists" width="18%">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" alt="Onboarding: sync with your own CalDAV server" width="18%">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" alt="Upcoming list with a task expanded into its subtasks" width="18%">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" alt="Task detail: a monthly recurring task with a reminder and priority" width="18%">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" alt="Reminder notification with Done and Snooze actions" width="18%">
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
|
||||
@@ -111,6 +119,8 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository
|
||||
serves. For automatic updates from there, use
|
||||
**[Obtainium](https://github.com/ImranR98/Obtainium)** and
|
||||
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
|
||||
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
|
||||
|
||||
|
||||
@@ -26,12 +26,14 @@ android {
|
||||
// These committed values ARE the source of truth for a release: merging
|
||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 1.0.0 -> 10000). Releases were flagged as
|
||||
// pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the
|
||||
// pipeline graduates it on its own. See docs/RELEASING.md.
|
||||
versionCode = 10000
|
||||
versionName = "1.0.0"
|
||||
// release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
|
||||
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
|
||||
// versionCode is derived from versionName by scripts/version_info.sh
|
||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
||||
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 1010001
|
||||
versionName = "1.1.0-beta.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -244,6 +246,7 @@ dependencies {
|
||||
implementation(libs.floret.core.crash)
|
||||
implementation(libs.floret.identity)
|
||||
implementation(libs.floret.components)
|
||||
implementation(libs.floret.glance)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
|
||||
@@ -185,6 +185,58 @@
|
||||
android:resource="@xml/appwidget_info_tasks" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Today" home-screen widget (Glance): a progress ring for today's tasks. -->
|
||||
<receiver
|
||||
android:name=".widget.TodayWidgetReceiver"
|
||||
android:label="@string/widget_today_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_today" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Up next" home-screen widget (Glance): the single nearest due task. -->
|
||||
<receiver
|
||||
android:name=".widget.UpNextWidgetReceiver"
|
||||
android:label="@string/widget_up_next_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_up_next" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Lists" home-screen widget (Glance): one shortcut tile per list. -->
|
||||
<receiver
|
||||
android:name=".widget.ListsWidgetReceiver"
|
||||
android:label="@string/widget_lists_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_lists" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Week" home-screen widget (Glance): a seven-day strip of task counts. -->
|
||||
<receiver
|
||||
android:name=".widget.WeekWidgetReceiver"
|
||||
android:label="@string/widget_week_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_week" />
|
||||
</receiver>
|
||||
|
||||
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
|
||||
<activity
|
||||
android:name=".widget.TaskWidgetConfigActivity"
|
||||
|
||||
@@ -186,6 +186,7 @@ class MainActivity : ComponentActivity() {
|
||||
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
|
||||
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
||||
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
||||
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
||||
private const val SHARED_TITLE_LIMIT = 500
|
||||
private const val NO_OCCURRENCE = -1L
|
||||
|
||||
@@ -206,9 +207,14 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
/** The launcher shortcut's and Quick Settings tile's "New task". */
|
||||
fun newTaskIntent(context: Context): Intent =
|
||||
Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java)
|
||||
/**
|
||||
* The launcher shortcut's, Quick Settings tile's and a widget's "New
|
||||
* task". [listId] preset the list when it names exactly one; the data
|
||||
* URI keeps each widget's PendingIntent apart from the others.
|
||||
*/
|
||||
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
|
||||
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
|
||||
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
fun todayIntent(context: Context): Intent =
|
||||
@@ -233,7 +239,9 @@ class MainActivity : ComponentActivity() {
|
||||
?.let { return NavRequest.OpenAccount(it) }
|
||||
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
||||
when (intent.action) {
|
||||
ACTION_NEW_TASK -> return NavRequest.NewTask()
|
||||
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
||||
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
||||
)
|
||||
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
||||
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
||||
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
|
||||
|
||||
@@ -95,6 +95,8 @@ data class Settings(
|
||||
val pushEnabled: Boolean = true,
|
||||
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
|
||||
val hiddenFromSmartLists: Set<Long> = emptySet(),
|
||||
/** Pre-fill a new task's start with today's date. */
|
||||
val defaultStartToday: Boolean = false,
|
||||
) {
|
||||
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
|
||||
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
||||
@@ -153,6 +155,7 @@ class SettingsPrefs @Inject constructor(
|
||||
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
|
||||
.toSet()
|
||||
},
|
||||
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,6 +169,8 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
|
||||
|
||||
suspend fun setDefaultStartToday(enabled: Boolean) = dataStore.edit { it[DEFAULT_START_TODAY] = enabled }
|
||||
|
||||
suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
|
||||
|
||||
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
|
||||
@@ -282,6 +287,7 @@ class SettingsPrefs @Inject constructor(
|
||||
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
|
||||
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
||||
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
||||
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
// parent's subtask-progress reflects ALL its children — completed ones
|
||||
// included — even on smart lists that hide completed tasks themselves.
|
||||
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
|
||||
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
|
||||
// several lists are read in full and membership is filtered in-memory.
|
||||
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
|
||||
}
|
||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||
val all = SeriesCollapse.visible(dataSource.tasks(query))
|
||||
|
||||
@@ -8,6 +8,8 @@ enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
|
||||
/** What a task screen is showing: one real list, or a smart list. */
|
||||
sealed interface TaskFilter {
|
||||
data class OfList(val listId: Long) : TaskFilter
|
||||
/** Several lists shown together, e.g. one widget covering more than one list. */
|
||||
data class OfLists(val listIds: Set<Long>) : TaskFilter
|
||||
data class Smart(val list: SmartList) : TaskFilter
|
||||
}
|
||||
|
||||
@@ -21,6 +23,7 @@ object TaskFiltering {
|
||||
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
|
||||
when (filter) {
|
||||
is TaskFilter.OfList -> task.listId == filter.listId
|
||||
is TaskFilter.OfLists -> task.listId in filter.listIds
|
||||
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -122,10 +123,11 @@ class TaskEditViewModel @Inject constructor(
|
||||
val settings = settingsPrefs.settings.first()
|
||||
defaultFields = settings.defaultEditFields
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
// A read-only or vanished default would give the task a list it
|
||||
// A read-only or vanished default — or a widget's preset list, if it was
|
||||
// since deleted or turned read-only — would give the task a list it
|
||||
// cannot be saved to.
|
||||
val writable = lists.filter { it.acceptsWrites }
|
||||
val defaultList = presetListId
|
||||
val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } }
|
||||
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } }
|
||||
?: writable.firstOrNull { !it.isLocal }?.id
|
||||
?: writable.firstOrNull()?.id
|
||||
@@ -135,6 +137,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
isNew = true,
|
||||
autofocusTitle = settings.autofocusTitle,
|
||||
listId = defaultList,
|
||||
start = if (settings.defaultStartToday) Clock.System.now() else null,
|
||||
parentId = parentId,
|
||||
lists = lists,
|
||||
parentCandidates = loadParents(defaultList, selfId = null),
|
||||
|
||||
@@ -153,7 +153,6 @@ fun ListEditorSheet(
|
||||
GroupedRow(
|
||||
title = destination.label,
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
leading = { Icon(destinationIcon(destination), contentDescription = null) },
|
||||
trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) },
|
||||
onClick = { choosingDestination = true },
|
||||
@@ -176,7 +175,6 @@ fun ListEditorSheet(
|
||||
title = stringResource(R.string.list_in_smart_lists),
|
||||
summary = stringResource(R.string.list_in_smart_lists_hint),
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = !hiddenFromSmartLists,
|
||||
|
||||
@@ -52,7 +52,8 @@ fun AgendulaNavHost(
|
||||
nav.navigate(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)) { launchSingleTop = true }
|
||||
is NavRequest.OpenAccount ->
|
||||
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
|
||||
is NavRequest.NewTask -> nav.navigate(Dest.TaskEdit.buildNew(title = navRequest.title))
|
||||
is NavRequest.NewTask ->
|
||||
nav.navigate(Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title))
|
||||
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ object Dest {
|
||||
fun build(filter: TaskFilter): String = when (filter) {
|
||||
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
|
||||
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
|
||||
// Several lists at once only exists for the widget, which never routes here.
|
||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||
}
|
||||
|
||||
/** Reconstruct the filter from a destination's arguments. */
|
||||
|
||||
@@ -18,8 +18,11 @@ sealed interface NavRequest {
|
||||
/** One account's detail — where "sign in again" lives. */
|
||||
data class OpenAccount(val accountId: Long) : NavRequest
|
||||
|
||||
/** A new task, optionally titled with text shared from another app. */
|
||||
data class NewTask(val title: String? = null) : NavRequest
|
||||
/**
|
||||
* A new task, optionally titled with text shared from another app, or
|
||||
* preset to a list — a widget's own add button, when it shows one list.
|
||||
*/
|
||||
data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
|
||||
|
||||
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
|
||||
data class OpenSmart(val list: SmartList) : NavRequest
|
||||
|
||||
@@ -241,6 +241,7 @@ class SettingsViewModel @Inject constructor(
|
||||
fun setSnoozeMinutes(minutes: Int) = viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
|
||||
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
|
||||
fun setDefaultStartToday(enabled: Boolean) = viewModelScope.launch { prefs.setDefaultStartToday(enabled) }
|
||||
|
||||
/** Toggle whether [field] shows by default on a new task's edit form. */
|
||||
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
|
||||
|
||||
@@ -99,6 +99,18 @@ internal fun TaskFormScreen(
|
||||
},
|
||||
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_start_today),
|
||||
summary = stringResource(R.string.settings_default_start_today_hint),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.settings.defaultStartToday,
|
||||
onCheckedChange = viewModel::setDefaultStartToday,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setDefaultStartToday(!state.settings.defaultStartToday) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_bottom_add_bar),
|
||||
summary = stringResource(R.string.settings_bottom_add_bar_hint),
|
||||
|
||||
@@ -52,6 +52,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -139,9 +140,12 @@ fun TaskListScreen(
|
||||
val list = content?.list
|
||||
val listName = list?.name
|
||||
val listId = (filter as? TaskFilter.OfList)?.listId
|
||||
// Editing is offered for a device-only list. A collection that belongs to an
|
||||
// account is the server's to rename or remove, not ours.
|
||||
// Editing is offered for any writable list, device or synced — renaming and
|
||||
// recolouring a synced collection is a server write the same sheet already
|
||||
// handles. A read-only share keeps the icon but disables it: tapping it
|
||||
// explains why rather than the button silently not being there.
|
||||
var editingList by rememberSaveable { mutableStateOf(false) }
|
||||
var readOnlyHint by rememberSaveable { mutableStateOf(false) }
|
||||
val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle()
|
||||
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
|
||||
// The list this screen is about is gone; there is nothing left to show.
|
||||
@@ -152,6 +156,12 @@ fun TaskListScreen(
|
||||
viewModel.clearListWriteFailure()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(readOnlyHint) {
|
||||
if (readOnlyHint) {
|
||||
delay(4_000)
|
||||
readOnlyHint = false
|
||||
}
|
||||
}
|
||||
// One add affordance, never two: a real list with the setting on gets a pinned
|
||||
// bottom quick-add bar; everything else (incl. smart lists, which have no single
|
||||
// target list) gets the floating "New task" button.
|
||||
@@ -181,11 +191,20 @@ fun TaskListScreen(
|
||||
IconButton(onClick = { showSort = true }, enabled = content != null) {
|
||||
Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title))
|
||||
}
|
||||
if (list != null && list.isLocal) {
|
||||
IconButton(onClick = { editingList = true }) {
|
||||
if (list != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (list.isReadOnly) readOnlyHint = true else editingList = true
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Edit,
|
||||
contentDescription = stringResource(R.string.list_edit_title),
|
||||
tint = if (list.isReadOnly) {
|
||||
LocalContentColor.current.copy(alpha = 0.38f)
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -230,23 +249,28 @@ fun TaskListScreen(
|
||||
.height(SnackChipHeight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
// One chip, one anchor: the undo receipt takes precedence, and a
|
||||
// refused list write reports itself once the undo window is clear.
|
||||
// One chip, one anchor, in priority order: the undo receipt first,
|
||||
// then a refused list write once the undo window is clear, then the
|
||||
// read-only edit hint — the quietest of the three, so it never
|
||||
// outranks something the other two are actually reporting.
|
||||
val failure = listWriteFailure
|
||||
if (undo != null || failure == null) {
|
||||
SnackChip(
|
||||
visible = undo != null,
|
||||
when {
|
||||
undo != null -> SnackChip(
|
||||
visible = true,
|
||||
message = stringResource(
|
||||
if (undo is UndoReceipt.Completed) R.string.task_completed else R.string.task_deleted,
|
||||
),
|
||||
actionLabel = stringResource(R.string.undo),
|
||||
onAction = viewModel::undo,
|
||||
)
|
||||
} else {
|
||||
SnackChip(
|
||||
failure != null -> SnackChip(
|
||||
visible = true,
|
||||
message = stringResource(listWriteFailureMessage(failure)),
|
||||
)
|
||||
else -> SnackChip(
|
||||
visible = readOnlyHint,
|
||||
message = stringResource(R.string.list_read_only),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1148,6 +1172,8 @@ private fun CenteredMessage(text: String, inner: PaddingValues) {
|
||||
@Composable
|
||||
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
|
||||
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
|
||||
// Several lists at once only exists for the widget, which never routes here.
|
||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||
is TaskFilter.Smart -> stringResource(
|
||||
when (filter.list) {
|
||||
SmartList.TODAY -> R.string.smart_today
|
||||
|
||||
@@ -103,6 +103,8 @@ class TaskListViewModel @Inject constructor(
|
||||
}
|
||||
is TaskFilter.Smart ->
|
||||
tasks.map { TaskListUiState.Content(it) }
|
||||
// Several lists at once only exists for the widget, which never routes here.
|
||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||
}
|
||||
// Drop rows pending an undoable delete so the row vanishes on swipe
|
||||
// while the actual provider delete waits for the snackbar to commit,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.color.ColorProvider
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.unit.ColorProvider as GlanceColorProvider
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxHeight
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionButton
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.glance.WidgetTile
|
||||
import de.jeanlucmakiola.floret.glance.R as GlanceR
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* "Lists" home-screen widget: one shortcut tile per list, in the list's own
|
||||
* colour, showing its open count — navigation to a list rather than a
|
||||
* preview of its tasks, which is what [TaskWidget] already does.
|
||||
*/
|
||||
class ListsWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
// Exact, not Responsive: the grid is sized to the real placement so every
|
||||
// tile fits without scrolling.
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
|
||||
provideContent {
|
||||
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
|
||||
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
|
||||
val listIds = parseWidgetListIds(currentState(WIDGET_LIST_IDS_KEY))
|
||||
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
|
||||
val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
|
||||
FloretGlanceTheme(
|
||||
lightScheme = AgendulaLightFallback,
|
||||
darkScheme = AgendulaDarkFallback,
|
||||
dynamicColor = ready?.snapshot?.dynamicColor ?: true,
|
||||
mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
|
||||
) {
|
||||
ListsWidgetBody(data, background, listIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListsWidgetBody(data: WidgetStatsData?, background: WidgetBackground, listIds: Set<Long>?) {
|
||||
val context = LocalContext.current
|
||||
val ready = data as? WidgetStatsData.Ready
|
||||
val tiles = ready?.snapshot?.lists?.let { lists -> listIds?.let { ids -> lists.filter { it.id in ids } } ?: lists }
|
||||
val size = LocalSize.current
|
||||
WidgetSurface(background = background) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize().padding(Padding - Gap / 2)) {
|
||||
when {
|
||||
data == null -> Unit
|
||||
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable))
|
||||
tiles.isNullOrEmpty() -> WidgetMessage(context.getString(R.string.widget_lists_empty))
|
||||
else -> {
|
||||
val grid = listGrid(tiles.size, size.width - Padding * 2, size.height - Padding * 2)
|
||||
val rows = tiles.take(grid.columns * grid.rows).chunked(grid.columns)
|
||||
// Gaps are each tile's padding rather than Spacers, which would
|
||||
// eat into Glance's 10-children-per-Row/Column cap.
|
||||
rows.forEach { rowTiles ->
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
|
||||
rowTiles.forEach { tile ->
|
||||
// Glance has no margin — padding on the tile itself lands inside its fill.
|
||||
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(Gap / 2)) {
|
||||
ListTileCell(tile, grid, GlanceModifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ListGrid(val columns: Int, val rows: Int, val tileWidth: Dp, val tileHeight: Dp)
|
||||
|
||||
/**
|
||||
* Picks the column count whose tiles come closest to [TileAspect] at the
|
||||
* largest size, among those that fit [count] tiles into [width] x [height].
|
||||
* Tiles too narrow for the one-line layout must be tall enough to stack, so a
|
||||
* narrow widget shows fewer, readable tiles. Lists that don't fit are dropped
|
||||
* rather than scrolled.
|
||||
*/
|
||||
internal fun listGrid(count: Int, width: Dp, height: Dp): ListGrid {
|
||||
fun span(total: Dp, cells: Int) = (total - Gap * (cells - 1)) / cells
|
||||
fun maxRows(columns: Int): Int {
|
||||
val minHeight = if (span(width, columns) < MinOneLineTileWidth) MinNarrowTileHeight else MinTileHeight
|
||||
return ((height + Gap) / (minHeight + Gap)).toInt().coerceIn(1, MaxCells)
|
||||
}
|
||||
val maxColumns = ((width + Gap) / (MinTileWidth + Gap)).toInt().coerceIn(1, MaxCells)
|
||||
val best = (1..min(count, maxColumns))
|
||||
.map { columns -> columns to (count + columns - 1) / columns }
|
||||
.filter { (columns, rows) -> rows <= maxRows(columns) }
|
||||
.maxByOrNull { (columns, rows) -> min(span(width, columns).value / TileAspect, span(height, rows).value) }
|
||||
val (columns, rows) = best ?: (maxColumns to maxRows(maxColumns))
|
||||
return ListGrid(columns, rows, span(width, columns), span(height, rows))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListTileCell(tile: ListTile, grid: ListGrid, modifier: GlanceModifier) {
|
||||
val context = LocalContext.current
|
||||
val tint = ColorProvider(day = pastelize(tile.color, false), night = pastelize(tile.color, true))
|
||||
val onTint = GlanceColorProvider(Color.White)
|
||||
val showAdd = tile.acceptsWrites && grid.tileWidth >= MinTileWidthWithAdd
|
||||
WidgetTile(
|
||||
color = tint,
|
||||
modifier = modifier,
|
||||
onClick = actionStartActivity(MainActivity.listIntent(context, tile.id)),
|
||||
) {
|
||||
if (grid.tileWidth < MinOneLineTileWidth && grid.tileHeight >= MinNarrowTileHeight) {
|
||||
// Narrow: the name gets the full width and wraps; count and + share the bottom row.
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = tile.name,
|
||||
maxLines = if (grid.tileHeight >= MinTwoLineNameHeight) 2 else 1,
|
||||
style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "${tile.openCount}",
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
style = TextStyle(color = onTint, fontSize = 18.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
if (showAdd) ListAddButton(tile)
|
||||
}
|
||||
}
|
||||
} else if (grid.tileHeight >= MinStackedTileHeight) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = tile.name,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
if (showAdd) ListAddButton(tile)
|
||||
}
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
Text(
|
||||
text = "${tile.openCount}",
|
||||
style = TextStyle(color = onTint, fontSize = 18.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = tile.name,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
Text(
|
||||
text = "${tile.openCount}",
|
||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||
style = TextStyle(color = onTint, fontSize = 15.sp, fontWeight = FontWeight.Bold),
|
||||
)
|
||||
if (showAdd) ListAddButton(tile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListAddButton(tile: ListTile) {
|
||||
val context = LocalContext.current
|
||||
WidgetActionButton(
|
||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
action = actionStartActivity(MainActivity.newTaskIntent(context, tile.id)),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
size = 20.dp,
|
||||
)
|
||||
}
|
||||
|
||||
private val Padding = 8.dp
|
||||
private val Gap = 8.dp
|
||||
private val MinTileWidth = 72.dp
|
||||
private val MinTileHeight = 36.dp
|
||||
private val MinTileWidthWithAdd = 64.dp
|
||||
private val MinStackedTileHeight = 60.dp
|
||||
private val MinNarrowTileHeight = 52.dp
|
||||
private val MinTwoLineNameHeight = 70.dp
|
||||
private val MinOneLineTileWidth = 120.dp
|
||||
private const val TileAspect = 1.5f
|
||||
|
||||
/** Glance's per-Row/Column child cap. */
|
||||
private const val MaxCells = 10
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Host-facing receiver for [ListsWidget]; also keeps the midnight rollover armed. */
|
||||
class ListsWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = ListsWidget()
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.ActionParameters
|
||||
@@ -22,12 +23,10 @@ import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.background
|
||||
import androidx.glance.color.ColorProvider
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
@@ -44,20 +43,40 @@ import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextDecoration
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider as GlanceColorProvider
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetCheckButton
|
||||
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
||||
import de.jeanlucmakiola.floret.glance.WidgetDoneState
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
|
||||
import de.jeanlucmakiola.floret.time.formatTime
|
||||
import de.jeanlucmakiola.floret.glance.R as GlanceR
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* "Tasks" home-screen widget: one smart list (Today by default) or one real
|
||||
* list per instance, with a checkbox to complete each task in place.
|
||||
* "Tasks" home-screen widget: one smart list (Today by default) or one or more
|
||||
* real lists per instance, with a Calendar-agenda look — a plain section label
|
||||
* per group (by list, or by due bucket: Overdue / Today / Tomorrow / Later /
|
||||
* No date) above a run of standalone, fully-rounded task pills, each with a
|
||||
* trailing check to complete it in place.
|
||||
*
|
||||
* The data is collected live inside the composition rather than loaded once in
|
||||
* the `provideGlance` preamble: an `update` on a running session only
|
||||
@@ -68,200 +87,295 @@ class TaskWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override val sizeMode = SizeMode.Single
|
||||
override val sizeMode = SizeMode.Responsive(setOf(CompactSize, RegularSize))
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val stored = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[WIDGET_FILTER_KEY]
|
||||
val initialFilter = parseWidgetFilter(stored)
|
||||
val initial = runCatching { context.taskWidgetData(initialFilter).first() }
|
||||
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
|
||||
val initialFilter = parseWidgetFilter(state[WIDGET_FILTER_KEY])
|
||||
val initialGroupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
|
||||
val initial = runCatching { context.taskWidgetData(initialFilter, initialGroupByList).first() }
|
||||
.getOrDefault(TaskWidgetData.Unavailable)
|
||||
provideContent {
|
||||
val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY))
|
||||
val data by remember(filter) { context.taskWidgetData(filter) }
|
||||
.collectAsState(initial = if (filter == initialFilter) initial else null)
|
||||
val groupByList = currentState(WIDGET_GROUP_BY_LIST_KEY) ?: false
|
||||
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
|
||||
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
|
||||
val rowColor = parseWidgetRowColor(currentState(WIDGET_ROW_COLOR_KEY))
|
||||
val data by remember(filter, groupByList) { context.taskWidgetData(filter, groupByList) }
|
||||
.collectAsState(initial = if (filter == initialFilter && groupByList == initialGroupByList) initial else null)
|
||||
val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready
|
||||
AgendulaGlanceTheme(
|
||||
FloretGlanceTheme(
|
||||
lightScheme = AgendulaLightFallback,
|
||||
darkScheme = AgendulaDarkFallback,
|
||||
dynamicColor = ready?.dynamicColor ?: true,
|
||||
themeMode = ready?.themeMode ?: ThemeMode.SYSTEM,
|
||||
mode = ready?.effectiveThemeMode(themeOverride) ?: WidgetThemeMode.SYSTEM,
|
||||
) {
|
||||
TaskWidgetBody(filter, data)
|
||||
TaskWidgetBody(filter, data, background, rowColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val CompactSize = DpSize(110.dp, 110.dp)
|
||||
val RegularSize = DpSize(250.dp, 110.dp)
|
||||
}
|
||||
}
|
||||
|
||||
/** Completes the tapped task — just the one occurrence for a recurring series. */
|
||||
class CompleteTaskAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val taskId = parameters[TaskIdKey] ?: return
|
||||
val occurrence = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE }
|
||||
?.let { Instant.fromEpochMilliseconds(it) }
|
||||
val occurrenceMillis = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE }
|
||||
val key = WidgetCompletions.key(taskId, occurrenceMillis)
|
||||
if (!WidgetCompletions.start(key)) return
|
||||
val app = context.applicationContext
|
||||
// This callback is shared by every widget's rows, and glanceId only
|
||||
// tells us the tapped widget's instance, not which provider class it
|
||||
// belongs to — updating the wrong one by id would push this class's
|
||||
// RemoteViews into another widget's AppWidget slot. updateAll is
|
||||
// class-scoped, so redrawing every widget is the immediate, correct
|
||||
// option; it also covers every other open instance showing this task.
|
||||
// First the ticked frame, then the real completion a beat later.
|
||||
updateAllWidgets(app)
|
||||
delay(TICK_FRAME_MILLIS)
|
||||
runCatching {
|
||||
context.widgetEntryPoint().tasksRepository().setCompleted(taskId, occurrence, true)
|
||||
context.widgetEntryPoint().tasksRepository()
|
||||
.setCompleted(taskId, occurrenceMillis?.let { Instant.fromEpochMilliseconds(it) }, true)
|
||||
}
|
||||
TaskWidget().update(context.applicationContext, glanceId)
|
||||
updateAllWidgets(app)
|
||||
// Hold the ticked look until the store's change has redrawn the row away.
|
||||
delay(TICK_RELEASE_MILLIS)
|
||||
WidgetCompletions.finish(key)
|
||||
updateAllWidgets(app)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TaskIdKey = ActionParameters.Key<Long>("taskId")
|
||||
val OccurrenceKey = ActionParameters.Key<Long>("occurrenceStart")
|
||||
const val NO_OCCURRENCE = Long.MIN_VALUE
|
||||
private const val TICK_FRAME_MILLIS = 600L
|
||||
private const val TICK_RELEASE_MILLIS = 1500L
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) {
|
||||
private fun TaskWidgetBody(
|
||||
filter: TaskFilter,
|
||||
data: TaskWidgetData?,
|
||||
background: WidgetBackground,
|
||||
rowColor: WidgetRowColor,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val ready = data as? TaskWidgetData.Ready
|
||||
val title = when (filter) {
|
||||
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
|
||||
is TaskFilter.OfList -> (data as? TaskWidgetData.Ready)?.listName
|
||||
is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
|
||||
?: context.getString(R.string.widget_tasks_label)
|
||||
}
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.widgetBackground)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
Header(title, filter)
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
when (data) {
|
||||
null -> Unit
|
||||
TaskWidgetData.Unavailable -> Message(context.getString(R.string.widget_unavailable))
|
||||
is TaskWidgetData.Ready ->
|
||||
if (data.rows.isEmpty()) {
|
||||
Message(context.getString(R.string.widget_empty))
|
||||
} else {
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(data.rows.size) { index -> TaskRow(data.rows[index], data.use24Hour) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(title: String, filter: TaskFilter) {
|
||||
val context = LocalContext.current
|
||||
val open = when (filter) {
|
||||
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
|
||||
is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId)
|
||||
}
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(start = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier.defaultWeight().clickable(actionStartActivity(open)),
|
||||
)
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(40.dp)
|
||||
.cornerRadius(20.dp)
|
||||
.background(GlanceTheme.colors.primaryContainer)
|
||||
.clickable(actionStartActivity(MainActivity.newTaskIntent(context))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimaryContainer),
|
||||
modifier = GlanceModifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
||||
val open = actionStartActivity(
|
||||
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
|
||||
)
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(4.dp)
|
||||
.height(32.dp)
|
||||
.cornerRadius(2.dp)
|
||||
.background(ColorProvider(day = pastelize(row.color, false), night = pastelize(row.color, true))),
|
||||
) {}
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(40.dp)
|
||||
.clickable(
|
||||
actionRunCallback<CompleteTaskAction>(
|
||||
actionParametersOf(
|
||||
CompleteTaskAction.TaskIdKey to row.taskId,
|
||||
CompleteTaskAction.OccurrenceKey to
|
||||
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
|
||||
),
|
||||
),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_widget_checkbox),
|
||||
contentDescription = context.getString(R.string.widget_complete_task, title),
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
WidgetSurface(background = background) {
|
||||
Column(
|
||||
modifier = GlanceModifier.defaultWeight().padding(end = 8.dp, top = 4.dp, bottom = 4.dp)
|
||||
.clickable(open),
|
||||
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||
)
|
||||
dueText(context, row, use24Hour)?.let { due ->
|
||||
Text(
|
||||
text = due,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (row.isOverdue) GlanceTheme.colors.error
|
||||
else GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
)
|
||||
TaskWidgetHeader(title, filter, ready?.singleListId)
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
when (data) {
|
||||
null -> Unit
|
||||
TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable))
|
||||
is TaskWidgetData.Ready ->
|
||||
if (data.rows.isEmpty()) {
|
||||
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
|
||||
} else {
|
||||
val allSections = widgetSections(
|
||||
data.rows,
|
||||
data.groupByList,
|
||||
data.listNames,
|
||||
) { kind -> context.getString(dueBucketLabel(kind)) }
|
||||
// A lone group whose bucket repeats the widget's own smart list
|
||||
// (the "Today" smart list showing one "Today" bucket) earns no
|
||||
// place of its own. A lone group for any other bucket (a
|
||||
// real-list widget whose only bucket is "Tomorrow") still has to
|
||||
// say so — it's the one thing telling the rows apart from today.
|
||||
// Compared by due kind, not by rendered label text, since the two
|
||||
// labels come from separately translated string resources and
|
||||
// aren't guaranteed to read identically in every locale.
|
||||
val onlyHeader = allSections.singleOrNull { it is WidgetSection.Header } as? WidgetSection.Header
|
||||
val redundantDueKind = when ((filter as? TaskFilter.Smart)?.list) {
|
||||
SmartList.TODAY -> DueKind.TODAY
|
||||
SmartList.OVERDUE -> DueKind.PAST
|
||||
else -> null
|
||||
}
|
||||
val redundantHeader = onlyHeader != null && redundantDueKind != null &&
|
||||
onlyHeader.key == "due:${redundantDueKind.name}"
|
||||
val sections = if (redundantHeader) allSections.filterNot { it is WidgetSection.Header } else allSections
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(sections.size) { index ->
|
||||
when (val section = sections[index]) {
|
||||
is WidgetSection.Header -> SectionHeader(section.name)
|
||||
is WidgetSection.TaskItem -> {
|
||||
val container = if (rowColor == WidgetRowColor.BY_LIST) {
|
||||
ColorProvider(
|
||||
day = pastelize(section.row.color, false),
|
||||
night = pastelize(section.row.color, true),
|
||||
)
|
||||
} else {
|
||||
GlanceTheme.colors.surfaceVariant
|
||||
}
|
||||
// A LazyColumn item's host view isn't a linear layout on
|
||||
// its own, so the row and its gap need an explicit Column
|
||||
// to stack rather than overlap.
|
||||
Column {
|
||||
AgendaTaskRow(
|
||||
row = section.row,
|
||||
use24Hour = data.use24Hour,
|
||||
color = container,
|
||||
colored = rowColor == WidgetRowColor.BY_LIST,
|
||||
// The due-bucket header above already names the day
|
||||
// (Today / Tomorrow) whenever one is shown — and when
|
||||
// it isn't, that's only because it would have repeated
|
||||
// the title, which names the same day just as well.
|
||||
suppressDayLabel = !data.groupByList,
|
||||
)
|
||||
Spacer(GlanceModifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dueBucketLabel(kind: DueKind?): Int = when (kind) {
|
||||
DueKind.PAST -> R.string.widget_section_overdue
|
||||
DueKind.TODAY -> R.string.home_due_today
|
||||
DueKind.TOMORROW -> R.string.home_due_tomorrow
|
||||
DueKind.LATER -> R.string.widget_section_later
|
||||
null -> R.string.widget_section_no_date
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Message(text: String) {
|
||||
Box(
|
||||
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
private fun SectionHeader(name: String) {
|
||||
Text(
|
||||
text = name,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Long?) {
|
||||
val context = LocalContext.current
|
||||
val open = when (filter) {
|
||||
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
|
||||
is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId)
|
||||
// Several lists have no single list screen to open; a single selection
|
||||
// still opens straight to it, otherwise this just raises the app.
|
||||
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
|
||||
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
WidgetHeader(
|
||||
title = title,
|
||||
titleAction = actionStartActivity(open),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Today · 14:00", "Tomorrow", or the compact date; `null` without a due date. */
|
||||
private fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String? {
|
||||
/**
|
||||
* A Calendar-agenda-style task chip: its own fully-rounded pill (list colour
|
||||
* or a neutral tone, per [WidgetRowColor]), a leading check to complete it,
|
||||
* title and due to its right. [colored] pills use fixed light text/icon —
|
||||
* [pastelize][de.jeanlucmakiola.floret.components.pastelize] keeps them
|
||||
* mid-bright rather than pale, so the theme's own `onSurface` (tuned for the
|
||||
* neutral tonal fill) reads too dark against them.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaTaskRow(
|
||||
row: WidgetTaskRow,
|
||||
use24Hour: Boolean,
|
||||
color: GlanceColorProvider,
|
||||
colored: Boolean,
|
||||
suppressDayLabel: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
||||
val open = actionStartActivity(
|
||||
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
|
||||
)
|
||||
val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface
|
||||
val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant
|
||||
val onColorError = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.error
|
||||
val completing = WidgetCompletions.isCompleting(row)
|
||||
WidgetGroupedRow(position = Position.Alone, color = color) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
WidgetCheckButton(
|
||||
contentDescription = context.getString(R.string.widget_complete_task, title),
|
||||
action = actionRunCallback<CompleteTaskAction>(
|
||||
actionParametersOf(
|
||||
CompleteTaskAction.TaskIdKey to row.taskId,
|
||||
CompleteTaskAction.OccurrenceKey to
|
||||
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
|
||||
),
|
||||
),
|
||||
tint = onColorVariant,
|
||||
checked = completing,
|
||||
)
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight().clickable(open)) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (completing) onColorVariant else onColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = if (completing) TextDecoration.LineThrough else TextDecoration.None,
|
||||
),
|
||||
)
|
||||
dueText(context, row, use24Hour, suppressDayLabel)?.let { due ->
|
||||
Text(
|
||||
text = due,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (row.isOverdue) onColorError else onColorVariant,
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip's own due line — bare "14:00" when [suppressDayLabel] (the due-bucket
|
||||
* header above already says "Today"/"Tomorrow", Calendar-agenda style: the day
|
||||
* is the group, the chip only needs the time), otherwise "Today · 14:00" /
|
||||
* "Tomorrow", or the compact date for anything else. `null` without a due date,
|
||||
* or for an all-day task once its day is already said by the header.
|
||||
*/
|
||||
internal fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean, suppressDayLabel: Boolean): String? {
|
||||
val due = row.due ?: return null
|
||||
val relative = when (row.dueKind) {
|
||||
DueKind.TODAY -> context.getString(R.string.home_due_today)
|
||||
DueKind.TOMORROW -> context.getString(R.string.home_due_tomorrow)
|
||||
else -> null
|
||||
} ?: return due.formatDateTimeCompact(row.isAllDay, use24Hour)
|
||||
return if (row.isAllDay) relative else "$relative · ${due.formatTime(use24Hour)}"
|
||||
if (row.isAllDay) return if (suppressDayLabel) null else relative
|
||||
val time = due.formatTime(use24Hour)
|
||||
return if (suppressDayLabel) time else "$relative · $time"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Circle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -22,6 +28,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
@@ -40,17 +48,50 @@ import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Which widget provider a configure session is for — decides which sections [WidgetFilterPicker] shows. */
|
||||
internal enum class WidgetKind { TASKS, TODAY, UP_NEXT, LISTS, WEEK }
|
||||
|
||||
/** Reads [appWidgetId]'s provider class from the host, since that's the only thing naming which widget this is. */
|
||||
private fun widgetKindOf(context: Context, appWidgetId: Int): WidgetKind? {
|
||||
val provider = AppWidgetManager.getInstance(context).getAppWidgetInfo(appWidgetId)?.provider ?: return null
|
||||
return when (provider.className) {
|
||||
TaskWidgetReceiver::class.java.name -> WidgetKind.TASKS
|
||||
TodayWidgetReceiver::class.java.name -> WidgetKind.TODAY
|
||||
UpNextWidgetReceiver::class.java.name -> WidgetKind.UP_NEXT
|
||||
ListsWidgetReceiver::class.java.name -> WidgetKind.LISTS
|
||||
WeekWidgetReceiver::class.java.name -> WidgetKind.WEEK
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun widgetFor(kind: WidgetKind): GlanceAppWidget = when (kind) {
|
||||
WidgetKind.TASKS -> TaskWidget()
|
||||
WidgetKind.TODAY -> TodayWidget()
|
||||
WidgetKind.UP_NEXT -> UpNextWidget()
|
||||
WidgetKind.LISTS -> ListsWidget()
|
||||
WidgetKind.WEEK -> WeekWidget()
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks what one widget instance shows: a smart list or a real list. Runs when
|
||||
* the widget is placed (below API 31) and on "reconfigure" from the launcher.
|
||||
* Picks what one widget instance shows. [WidgetKind.TASKS] picks a smart
|
||||
* list or one or more real lists (with an optional "group by list" sort);
|
||||
* [WidgetKind.LISTS] picks which lists get a tile; [WidgetKind.UP_NEXT] can be
|
||||
* narrowed to one list; every other kind only
|
||||
* offers background and theme, since they're always the smart-list scope.
|
||||
* Runs when the widget is placed (below API 31) and on "reconfigure" from
|
||||
* the launcher.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
@@ -68,6 +109,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val kind = widgetKindOf(this, appWidgetId) ?: WidgetKind.TASKS
|
||||
enableEdgeToEdge()
|
||||
val glanceId = GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId)
|
||||
val listsFlow = runCatching { repository.taskLists() }.getOrDefault(emptyFlow())
|
||||
@@ -77,10 +119,19 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
val settings by settingsPrefs.settings.collectAsState(initial = Settings())
|
||||
val lists by remember { listsFlow }.collectAsState(initial = emptyList())
|
||||
var current by remember { mutableStateOf<TaskFilter?>(null) }
|
||||
var groupByList by remember { mutableStateOf(false) }
|
||||
var background by remember { mutableStateOf(WidgetBackground.SOLID) }
|
||||
var themeOverride by remember { mutableStateOf<WidgetThemeMode?>(null) }
|
||||
var rowColor by remember { mutableStateOf(WidgetRowColor.NEUTRAL) }
|
||||
var selectedListIds by remember { mutableStateOf<Set<Long>?>(null) }
|
||||
LaunchedEffect(glanceId) {
|
||||
current = parseWidgetFilter(
|
||||
getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)[WIDGET_FILTER_KEY],
|
||||
)
|
||||
val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
|
||||
current = parseWidgetFilter(state[WIDGET_FILTER_KEY])
|
||||
groupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
|
||||
background = parseWidgetBackground(state[WIDGET_BACKGROUND_KEY], kind)
|
||||
themeOverride = parseWidgetThemeOverride(state[WIDGET_THEME_KEY])
|
||||
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
|
||||
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
|
||||
}
|
||||
val dark = when (settings.themeMode) {
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
@@ -90,15 +141,40 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||
WidgetFilterPicker(
|
||||
kind = kind,
|
||||
lists = lists.filter { it.isVisible },
|
||||
dark = dark,
|
||||
selected = current ?: DEFAULT_WIDGET_FILTER,
|
||||
onSelect = { filter ->
|
||||
groupByList = groupByList,
|
||||
onGroupByListChange = { groupByList = it },
|
||||
background = background,
|
||||
onBackgroundChange = { background = it },
|
||||
themeOverride = themeOverride,
|
||||
onThemeOverrideChange = { themeOverride = it },
|
||||
rowColor = rowColor,
|
||||
onRowColorChange = { rowColor = it },
|
||||
selectedListIds = selectedListIds,
|
||||
onSelectedListIdsChange = { selectedListIds = it },
|
||||
onSave = { filter ->
|
||||
lifecycleScope.launch {
|
||||
updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) {
|
||||
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
|
||||
when (kind) {
|
||||
WidgetKind.TASKS -> {
|
||||
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
|
||||
it[WIDGET_GROUP_BY_LIST_KEY] = groupByList
|
||||
it[WIDGET_ROW_COLOR_KEY] = rowColor.name
|
||||
}
|
||||
WidgetKind.LISTS, WidgetKind.UP_NEXT -> {
|
||||
selectedListIds?.let { ids -> it[WIDGET_LIST_IDS_KEY] = ids.widgetListIdsStorageValue() }
|
||||
?: it.remove(WIDGET_LIST_IDS_KEY)
|
||||
}
|
||||
WidgetKind.TODAY, WidgetKind.WEEK -> Unit
|
||||
}
|
||||
it[WIDGET_BACKGROUND_KEY] = background.name
|
||||
themeOverride?.let { mode -> it[WIDGET_THEME_KEY] = mode.name }
|
||||
?: it.remove(WIDGET_THEME_KEY)
|
||||
}
|
||||
TaskWidget().update(this@TaskWidgetConfigActivity, glanceId)
|
||||
widgetFor(kind).update(this@TaskWidgetConfigActivity, glanceId)
|
||||
setResult(RESULT_OK, resultIntent(appWidgetId))
|
||||
finish()
|
||||
}
|
||||
@@ -115,40 +191,202 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
|
||||
@Composable
|
||||
private fun WidgetFilterPicker(
|
||||
kind: WidgetKind,
|
||||
lists: List<TaskList>,
|
||||
selected: TaskFilter,
|
||||
dark: Boolean,
|
||||
onSelect: (TaskFilter) -> Unit,
|
||||
groupByList: Boolean,
|
||||
onGroupByListChange: (Boolean) -> Unit,
|
||||
background: WidgetBackground,
|
||||
onBackgroundChange: (WidgetBackground) -> Unit,
|
||||
themeOverride: WidgetThemeMode?,
|
||||
onThemeOverrideChange: (WidgetThemeMode?) -> Unit,
|
||||
rowColor: WidgetRowColor,
|
||||
onRowColorChange: (WidgetRowColor) -> Unit,
|
||||
selectedListIds: Set<Long>?,
|
||||
onSelectedListIdsChange: (Set<Long>?) -> Unit,
|
||||
onSave: (TaskFilter) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
FullScreenPicker(title = stringResource(R.string.widget_config_title), onDismiss = onDismiss) {
|
||||
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
|
||||
WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
|
||||
val filter = TaskFilter.Smart(smart)
|
||||
val isSelected = filter == selected
|
||||
GroupedRow(
|
||||
title = stringResource(smartListLabel(smart)),
|
||||
position = positionOf(index, WIDGET_SMART_LISTS.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = { onSelect(filter) },
|
||||
)
|
||||
}
|
||||
if (lists.isNotEmpty()) {
|
||||
var showBackgroundPicker by remember { mutableStateOf(false) }
|
||||
var showThemePicker by remember { mutableStateOf(false) }
|
||||
var showRowColorPicker by remember { mutableStateOf(false) }
|
||||
// Smart lists and real lists are mutually exclusive; only real lists support
|
||||
// picking more than one. `selectedLists` empty + `selectedSmart` non-null is
|
||||
// a smart-list pick; the reverse (or both empty, before the stored filter
|
||||
// loads) is a list pick.
|
||||
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
|
||||
var selectedLists by remember(selected) {
|
||||
mutableStateOf(
|
||||
when (selected) {
|
||||
is TaskFilter.OfList -> setOf(selected.listId)
|
||||
is TaskFilter.OfLists -> selected.listIds
|
||||
is TaskFilter.Smart -> emptySet()
|
||||
},
|
||||
)
|
||||
}
|
||||
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || selectedLists.isNotEmpty()
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
|
||||
onDismiss = onDismiss,
|
||||
actions = {
|
||||
TextButton(
|
||||
enabled = canSave,
|
||||
onClick = {
|
||||
// The toggle row only shows (and so can only be turned off) with
|
||||
// more than one list selected; narrowing back down to one, or to
|
||||
// a smart list, has to clear it here instead; otherwise it's
|
||||
// stuck on with no way left to see or undo it.
|
||||
if (selectedLists.size <= 1 && groupByList) onGroupByListChange(false)
|
||||
selectedSmart?.let { onSave(TaskFilter.Smart(it)) }
|
||||
?: onSave(TaskFilter.OfLists(selectedLists))
|
||||
},
|
||||
) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
) {
|
||||
if (kind == WidgetKind.TASKS) {
|
||||
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
|
||||
WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
|
||||
val isSelected = smart == selectedSmart
|
||||
GroupedRow(
|
||||
title = stringResource(smartListLabel(smart)),
|
||||
position = positionOf(index, WIDGET_SMART_LISTS.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = { selectedSmart = smart; selectedLists = emptySet() },
|
||||
)
|
||||
}
|
||||
if (lists.isNotEmpty()) {
|
||||
GroupedSectionHeader(stringResource(R.string.lists_header))
|
||||
lists.forEachIndexed { index, list ->
|
||||
val isSelected = list.id in selectedLists
|
||||
GroupedRow(
|
||||
title = list.name,
|
||||
position = positionOf(index, lists.size),
|
||||
selected = isSelected,
|
||||
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = {
|
||||
selectedSmart = null
|
||||
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
|
||||
},
|
||||
)
|
||||
}
|
||||
if (selectedLists.size > 1) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_group_by_list),
|
||||
summary = stringResource(R.string.widget_config_group_by_list_hint),
|
||||
position = Position.Alone,
|
||||
trailing = { Switch(checked = groupByList, onCheckedChange = onGroupByListChange) },
|
||||
onClick = { onGroupByListChange(!groupByList) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
} else if (kind == WidgetKind.LISTS && lists.isNotEmpty()) {
|
||||
GroupedSectionHeader(stringResource(R.string.lists_header))
|
||||
lists.forEachIndexed { index, list ->
|
||||
val filter = TaskFilter.OfList(list.id)
|
||||
val isSelected = filter == selected
|
||||
// `null` means "every list" (the default) until the user narrows it down.
|
||||
val isSelected = selectedListIds?.let { list.id in it } ?: true
|
||||
GroupedRow(
|
||||
title = list.name,
|
||||
position = positionOf(index, lists.size),
|
||||
selected = isSelected,
|
||||
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = { onSelect(filter) },
|
||||
onClick = {
|
||||
val current = selectedListIds ?: lists.map { it.id }.toSet()
|
||||
onSelectedListIdsChange(if (isSelected) current - list.id else current + list.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
} else if (kind == WidgetKind.UP_NEXT && lists.isNotEmpty()) {
|
||||
// Single choice: `null` is every list, otherwise a one-element set.
|
||||
val chosen = selectedListIds?.singleOrNull()
|
||||
GroupedSectionHeader(stringResource(R.string.lists_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_up_next_any_list),
|
||||
position = positionOf(0, lists.size + 1),
|
||||
selected = chosen == null,
|
||||
trailing = if (chosen == null) ({ SelectedCheck() }) else null,
|
||||
onClick = { onSelectedListIdsChange(null) },
|
||||
)
|
||||
lists.forEachIndexed { index, list ->
|
||||
val isSelected = list.id == chosen
|
||||
GroupedRow(
|
||||
title = list.name,
|
||||
position = positionOf(index + 1, lists.size + 1),
|
||||
selected = isSelected,
|
||||
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = { onSelectedListIdsChange(setOf(list.id)) },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_background),
|
||||
summary = stringResource(backgroundLabel(background)),
|
||||
position = Position.Top,
|
||||
onClick = { showBackgroundPicker = true },
|
||||
)
|
||||
if (kind == WidgetKind.TASKS) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_theme),
|
||||
summary = stringResource(themeOverrideLabel(themeOverride)),
|
||||
position = Position.Middle,
|
||||
onClick = { showThemePicker = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_row_color),
|
||||
summary = stringResource(rowColorLabel(rowColor)),
|
||||
position = Position.Bottom,
|
||||
onClick = { showRowColorPicker = true },
|
||||
)
|
||||
} else {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_theme),
|
||||
summary = stringResource(themeOverrideLabel(themeOverride)),
|
||||
position = Position.Bottom,
|
||||
onClick = { showThemePicker = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showBackgroundPicker) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.widget_config_background),
|
||||
options = kind.backgrounds(),
|
||||
selected = background,
|
||||
label = { stringResource(backgroundLabel(it)) },
|
||||
summary = { stringResource(backgroundHint(it)) },
|
||||
onSelect = onBackgroundChange,
|
||||
onDismiss = { showBackgroundPicker = false },
|
||||
)
|
||||
}
|
||||
if (showThemePicker) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.widget_config_theme),
|
||||
options = listOf<WidgetThemeMode?>(null, WidgetThemeMode.LIGHT, WidgetThemeMode.DARK),
|
||||
selected = themeOverride,
|
||||
label = { stringResource(themeOverrideLabel(it)) },
|
||||
onSelect = onThemeOverrideChange,
|
||||
onDismiss = { showThemePicker = false },
|
||||
)
|
||||
}
|
||||
if (showRowColorPicker) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.widget_config_row_color),
|
||||
options = WidgetRowColor.entries,
|
||||
selected = rowColor,
|
||||
label = { stringResource(rowColorLabel(it)) },
|
||||
summary = { stringResource(rowColorHint(it)) },
|
||||
onSelect = onRowColorChange,
|
||||
onDismiss = { showRowColorPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,3 +396,32 @@ internal fun smartListLabel(list: SmartList): Int = when (list) {
|
||||
SmartList.OVERDUE -> R.string.smart_overdue
|
||||
else -> R.string.smart_all
|
||||
}
|
||||
|
||||
private fun backgroundLabel(background: WidgetBackground): Int = when (background) {
|
||||
WidgetBackground.SOLID -> R.string.widget_config_background_solid
|
||||
WidgetBackground.TINTED -> R.string.widget_config_background_tinted
|
||||
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
|
||||
}
|
||||
|
||||
private fun backgroundHint(background: WidgetBackground): Int = when (background) {
|
||||
WidgetBackground.SOLID -> R.string.widget_config_background_solid_hint
|
||||
WidgetBackground.TINTED -> R.string.widget_config_background_tinted_hint
|
||||
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent_hint
|
||||
}
|
||||
|
||||
private fun themeOverrideLabel(mode: WidgetThemeMode?): Int = when (mode) {
|
||||
null -> R.string.widget_config_theme_follow_app
|
||||
WidgetThemeMode.LIGHT -> R.string.widget_config_theme_light
|
||||
WidgetThemeMode.DARK -> R.string.widget_config_theme_dark
|
||||
WidgetThemeMode.SYSTEM -> R.string.widget_config_theme_follow_app
|
||||
}
|
||||
|
||||
private fun rowColorLabel(rowColor: WidgetRowColor): Int = when (rowColor) {
|
||||
WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral
|
||||
WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list
|
||||
}
|
||||
|
||||
private fun rowColorHint(rowColor: WidgetRowColor): Int = when (rowColor) {
|
||||
WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral_hint
|
||||
WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list_hint
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 [TaskWidget]; also keeps the midnight rollover armed. */
|
||||
class TaskWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
@@ -11,17 +12,17 @@ class TaskWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
/** Self-heal on the system's `updatePeriodMillis` wake-up, should the alarm have been dropped. */
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -36,7 +36,9 @@ class TaskWidgetUpdater @Inject constructor(
|
||||
scope.launch(Dispatchers.IO) {
|
||||
requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() }
|
||||
}
|
||||
scope.launch(Dispatchers.IO) { runCatching { WidgetRolloverScheduler.sync(context) } }
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) }
|
||||
}
|
||||
// The process may have been started by a change it never heard about.
|
||||
requestRefresh()
|
||||
}
|
||||
@@ -48,7 +50,8 @@ class TaskWidgetUpdater @Inject constructor(
|
||||
|
||||
private suspend fun redraw() {
|
||||
runCatching {
|
||||
if (WidgetRolloverScheduler.hasPlacedWidgets(context)) TaskWidget().updateAll(context)
|
||||
if (!WidgetRollover.hasPlacedWidgets(context, WIDGET_RECEIVER_CLASSES)) return@runCatching
|
||||
updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
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
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Host-facing receiver for [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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
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
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Host-facing receiver for [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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Host-facing receiver for [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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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,12 +4,15 @@ import android.content.Context
|
||||
import android.text.format.DateFormat
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||
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.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -21,19 +24,81 @@ import kotlin.time.Instant
|
||||
/** Per-instance Glance state key holding the widget's [TaskFilter] ([widgetStorageValue]). */
|
||||
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)
|
||||
|
||||
/** 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. */
|
||||
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
|
||||
* binder transaction limit however long the list is.
|
||||
* binder transaction limit however long the list is. Lower than the old flat
|
||||
* 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 = 60
|
||||
internal const val MAX_WIDGET_ROWS = 40
|
||||
|
||||
internal fun TaskFilter.widgetStorageValue(): String = when (this) {
|
||||
is TaskFilter.OfList -> "list:$listId"
|
||||
is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}"
|
||||
is TaskFilter.Smart -> "smart:${list.name}"
|
||||
}
|
||||
|
||||
@@ -43,6 +108,9 @@ internal fun parseWidgetFilter(raw: String?): TaskFilter {
|
||||
return when {
|
||||
value.startsWith("list:") ->
|
||||
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:") ->
|
||||
WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) }
|
||||
else -> null
|
||||
@@ -69,6 +137,7 @@ internal fun isOverdue(due: Instant, allDay: Boolean, now: Instant, today: Local
|
||||
|
||||
data class WidgetTaskRow(
|
||||
val taskId: Long,
|
||||
val listId: Long,
|
||||
val occurrenceStart: Instant?,
|
||||
val title: String,
|
||||
val due: Instant?,
|
||||
@@ -84,52 +153,121 @@ sealed interface TaskWidgetData {
|
||||
|
||||
data class Ready(
|
||||
val filter: TaskFilter,
|
||||
/** The real list's name; `null` for a smart list or a list that no longer exists. */
|
||||
val listName: String?,
|
||||
/** Names of the real lists [rows] can belong to, by list id. */
|
||||
val listNames: Map<Long, String>,
|
||||
val rows: List<WidgetTaskRow>,
|
||||
/** Group [rows] under a header per list (only meaningful with several lists). */
|
||||
val groupByList: Boolean,
|
||||
val dynamicColor: Boolean,
|
||||
val themeMode: ThemeMode,
|
||||
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. A subtask whose
|
||||
* parent is also shown is left out, as on the list screen.
|
||||
* Open rows for [tasks], sorted the way the app sorts them, then grouped by list
|
||||
* when [groupByList] is on (stable, so each list's own order survives). A
|
||||
* subtask whose parent is also shown is left out, as on the list screen.
|
||||
*/
|
||||
internal fun widgetRows(
|
||||
tasks: List<Task>,
|
||||
comparator: Comparator<Task>,
|
||||
now: Instant,
|
||||
zone: ZoneId,
|
||||
groupByList: Boolean,
|
||||
listNames: Map<Long, String>,
|
||||
): List<WidgetTaskRow> {
|
||||
val open = tasks.filterNot { it.isClosed }
|
||||
val present = open.mapTo(HashSet()) { it.taskId }
|
||||
val today = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate()
|
||||
return open
|
||||
val sorted = open
|
||||
.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
|
||||
.sortedWith(comparator)
|
||||
.take(MAX_WIDGET_ROWS)
|
||||
.map { task ->
|
||||
WidgetTaskRow(
|
||||
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,
|
||||
)
|
||||
.let {
|
||||
if (groupByList) {
|
||||
it.sortedWith(compareBy({ task -> listNames[task.listId].orEmpty() }, { task -> task.listId }))
|
||||
} else it
|
||||
}
|
||||
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
|
||||
* repository's flows observe the store; a store that can't be read (permission
|
||||
* revoked, provider uninstalled) reads as [TaskWidgetData.Unavailable].
|
||||
*/
|
||||
internal fun Context.taskWidgetData(filter: TaskFilter): Flow<TaskWidgetData> {
|
||||
internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): Flow<TaskWidgetData> {
|
||||
val ep = widgetEntryPoint()
|
||||
val repository = ep.tasksRepository()
|
||||
return combine(
|
||||
@@ -137,10 +275,19 @@ internal fun Context.taskWidgetData(filter: TaskFilter): Flow<TaskWidgetData> {
|
||||
repository.taskLists(),
|
||||
ep.settingsPrefs().settings,
|
||||
) { tasks, lists, settings ->
|
||||
val listNames = lists.associate { it.id to it.name }
|
||||
val data: TaskWidgetData = TaskWidgetData.Ready(
|
||||
filter = filter,
|
||||
listName = (filter as? TaskFilter.OfList)?.let { f -> lists.firstOrNull { it.id == f.listId }?.name },
|
||||
rows = widgetRows(settings.visibleIn(filter, tasks), settings.sortOrder.comparator(), Clock.System.now(), ZoneId.systemDefault()),
|
||||
listNames = listNames,
|
||||
rows = widgetRows(
|
||||
settings.visibleIn(filter, tasks),
|
||||
settings.sortOrder.comparator(),
|
||||
Clock.System.now(),
|
||||
ZoneId.systemDefault(),
|
||||
groupByList,
|
||||
listNames,
|
||||
),
|
||||
groupByList = groupByList,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
themeMode = settings.themeMode,
|
||||
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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) }
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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,56 +1,54 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Every home-screen widget's receiver class — the set [WidgetRollover] checks for placed instances. */
|
||||
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 the task widget when its day or its language goes stale, and keeps the
|
||||
* midnight rollover armed. Task data changes don't come through here — see
|
||||
* Redraws every widget when its day or language goes stale, and keeps the
|
||||
* midnight rollover armed — the app-specific glue around
|
||||
* [FloretWidgetUpdateReceiver]. Task data changes don't come through here — see
|
||||
* [TaskWidgetUpdater].
|
||||
*
|
||||
* Exported for the system broadcasts; an extra redraw from another app is harmless.
|
||||
*/
|
||||
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
|
||||
override val rolloverAction = ACTION_WIDGET_ROLLOVER
|
||||
override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES
|
||||
|
||||
companion object {
|
||||
const val ACTION_ROLLOVER = "de.jeanlucmakiola.agendula.widget.ROLLOVER"
|
||||
override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
|
||||
|
||||
private val REARM_ONLY_ACTIONS = setOf(
|
||||
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
|
||||
override suspend fun redraw(context: Context) {
|
||||
updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
<?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"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/widget_preview_primary_container" />
|
||||
<solid android:color="@android:color/white" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?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>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,213 @@
|
||||
<?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,69 +7,66 @@
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:padding="12dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="4dp"
|
||||
tools:ignore="HardcodedText,UseCompoundDrawables,UseAppTint">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
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:layout_marginStart="8dp"
|
||||
android:maxLines="1"
|
||||
android:text="@string/smart_today"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:textColor="@color/widget_preview_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
android:textSize="16sp" />
|
||||
<ImageView
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:padding="9dp"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="28dp"
|
||||
android:padding="5dp"
|
||||
android:contentDescription="@null"
|
||||
android:background="@drawable/preview_add_bg"
|
||||
android:src="@drawable/ic_widget_add"
|
||||
android:tint="@color/widget_preview_on_primary_container" />
|
||||
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>
|
||||
|
||||
<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: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="4dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:padding="6dp"
|
||||
android:contentDescription="@null"
|
||||
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: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="Water the plants"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="14sp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Today"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -77,36 +74,38 @@
|
||||
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: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="4dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:padding="6dp"
|
||||
android:contentDescription="@null"
|
||||
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: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:textSize="14sp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Today · 10:30"
|
||||
android:maxLines="1"
|
||||
android:text="10:30"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
@@ -116,36 +115,38 @@
|
||||
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: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="4dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:padding="6dp"
|
||||
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: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="Pick up groceries"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
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:maxLines="1"
|
||||
android:text="18:00"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,55 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,256 @@
|
||||
<?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="new_task">Neue Aufgabe</string>
|
||||
<string name="lists_failure">Deine Aufgaben konnten nicht gelesen werden.</string>
|
||||
<string name="lists_empty">Keine Aufgaben-Listen bisher. Füge eine in deiner Aufgaben-App hinzu oder mit der + Schaltfläche.</string>
|
||||
<string name="lists_empty">Keine Aufgaben-Listen bisher.</string>
|
||||
<string name="smart_today">Heute</string>
|
||||
<string name="smart_overdue">Überfällig</string>
|
||||
<string name="smart_upcoming">Anstehend</string>
|
||||
@@ -99,23 +99,23 @@
|
||||
<string name="reminder_channel_name">Aufgaben Erinnerungen</string>
|
||||
<string name="reminder_channel_desc">Benachrichtigungen für fällige Aufgaben.</string>
|
||||
<string name="onboarding_no_provider_title">Eine Task-App wird benötigt</string>
|
||||
<string name="onboarding_no_provider_body">Agendula zeigt und bearbeitet die Aufgaben, die in OpenTasks oder tasks.org gespeichert sind und per DAVx5 synchronisiert werden. Installiere eine davon, um zu starten.</string>
|
||||
<string name="onboarding_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_permission_title">Erlaube Zugriff auf deine Aufgaben</string>
|
||||
<string name="onboarding_permission_body">Agendula benötigt die Berechtigung, deine Aufgaben zu lesen und zu schreiben. Das ist die einzige Berechtigung, die es jemals anfragt.</string>
|
||||
<string name="onboarding_permission_button">Zugriff gewähren</string>
|
||||
<string name="reminder_onboarding_title">Verpasse nichts, was fällig ist</string>
|
||||
<string name="reminder_onboarding_body">Aufgaben-Apps senden selbst keine Erinnerungen, daher liefert Agendula sie für dich. Schalte sie ein, um eine Benachrichtigung zu erhalten, wenn eine Aufgabe fällig ist.</string>
|
||||
<string name="reminder_onboarding_body">Agendula benachrichtigt dich, wenn etwas fällig wird, also muss nichts in deinem Kopf leben.</string>
|
||||
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
|
||||
<string name="reminder_onboarding_skip_button">Nicht jetzt</string>
|
||||
<string name="reminder_benefit_delivery_title">Agendula erinnert dich</string>
|
||||
<string name="reminder_benefit_delivery_body">Es plant eine Benachrichtigung für jede Aufgabe mit einem Fälligkeitsdatum.</string>
|
||||
<string name="reminder_benefit_delivery_body">Jede Aufgaben mit Fälligkeitsdatum bekommt seine eigene Benachrichtigung.</string>
|
||||
<string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string>
|
||||
<string name="reminder_benefit_timing_body">Wähle in den Einstellungen, wie weit im Voraus du erinnert werden möchtest.</string>
|
||||
<string name="reminder_benefit_timing_body">Wähle, wie weit im Voraus du erinnert werden möchtest — für alles oder pro Liste.</string>
|
||||
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
|
||||
<string name="reminder_benefit_reversible_body">Schalte Erinnerungen aus, wann immer du willst — es ist nur ein Schalter.</string>
|
||||
<string name="settings_title">Einstellungen</string>
|
||||
<string name="settings_section_appearance">Darstellung</string>
|
||||
<string name="settings_appearance_subtitle">Theme und Farbe</string>
|
||||
<string name="settings_appearance_subtitle">Theme, Farbe und Zeitformat</string>
|
||||
<string name="settings_section_task_form">Aufgaben-Formular</string>
|
||||
<string name="settings_task_form_subtitle">Standard Felder, Liste und Unter-Aufgaben</string>
|
||||
<string name="settings_reminders_subtitle">Benachrichtigungen für fällige Aufgaben</string>
|
||||
@@ -169,4 +169,244 @@
|
||||
</plurals>
|
||||
<string name="settings_translate">Hilf beim Übersetzen</string>
|
||||
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
|
||||
<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>
|
||||
|
||||
@@ -6,4 +6,6 @@
|
||||
<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_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>
|
||||
|
||||
@@ -6,4 +6,6 @@
|
||||
<color name="widget_preview_primary">#E2BAC9</color>
|
||||
<color name="widget_preview_primary_container">#583B49</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>
|
||||
|
||||
@@ -6,4 +6,6 @@
|
||||
<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_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>
|
||||
|
||||
@@ -430,6 +430,8 @@
|
||||
<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_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_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
|
||||
|
||||
@@ -755,9 +757,68 @@
|
||||
<!-- Home-screen widget -->
|
||||
<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_empty">Nothing to do here</string>
|
||||
<string name="widget_empty">All caught up</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_complete_task">Complete %1$s</string>
|
||||
<string name="widget_config_title">Show in widget</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>
|
||||
|
||||
@@ -8,4 +8,6 @@
|
||||
<color name="widget_preview_primary">#6A4E5B</color>
|
||||
<color name="widget_preview_primary_container">#F6D9E5</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>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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,6 +10,9 @@ fun testTask(
|
||||
status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||
priority: Priority = Priority.NONE,
|
||||
due: Instant? = null,
|
||||
isAllDay: Boolean = false,
|
||||
completedAt: Instant? = null,
|
||||
parentId: Long? = null,
|
||||
): Task = Task(
|
||||
taskId = id,
|
||||
listId = listId,
|
||||
@@ -22,14 +25,14 @@ fun testTask(
|
||||
percentComplete = null,
|
||||
start = null,
|
||||
due = due,
|
||||
isAllDay = false,
|
||||
isAllDay = isAllDay,
|
||||
timeZone = null,
|
||||
completedAt = null,
|
||||
completedAt = completedAt,
|
||||
listColor = 0,
|
||||
taskColor = null,
|
||||
listName = null,
|
||||
accountName = null,
|
||||
parentId = null,
|
||||
parentId = parentId,
|
||||
isRecurring = false,
|
||||
occurrenceStart = null,
|
||||
distanceFromCurrent = 0,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
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.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSorting
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||
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 java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
@@ -27,7 +30,9 @@ class WidgetDataTest {
|
||||
|
||||
@Test
|
||||
fun `every offered filter round-trips through storage`() {
|
||||
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } + TaskFilter.OfList(42)
|
||||
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } +
|
||||
TaskFilter.OfList(42) +
|
||||
TaskFilter.OfLists(setOf(1L, 2L, 3L))
|
||||
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
|
||||
}
|
||||
|
||||
@@ -38,7 +43,10 @@ class WidgetDataTest {
|
||||
|
||||
@Test
|
||||
fun `garbage and smart lists the widget does not offer fall back to Today`() {
|
||||
listOf("", "list:", "list:-3", "list:abc", "smart:COMPLETED", "smart:nope", "whatever").forEach {
|
||||
listOf(
|
||||
"", "list:", "list:-3", "list:abc", "lists:", "lists:abc", "lists:-1,-2",
|
||||
"smart:COMPLETED", "smart:nope", "whatever",
|
||||
).forEach {
|
||||
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
|
||||
}
|
||||
}
|
||||
@@ -80,6 +88,8 @@ class WidgetDataTest {
|
||||
TaskSorting.DEFAULT,
|
||||
now,
|
||||
newYork,
|
||||
groupByList = false,
|
||||
listNames = emptyMap(),
|
||||
)
|
||||
assertThat(rows.map { it.title }).containsExactly("soon", "later").inOrder()
|
||||
}
|
||||
@@ -89,13 +99,135 @@ class WidgetDataTest {
|
||||
val parent = testTask(id = 1, title = "parent")
|
||||
val child = testTask(id = 2, title = "child").copy(parentId = 1)
|
||||
val orphan = testTask(id = 3, title = "orphan").copy(parentId = 99)
|
||||
val rows = widgetRows(listOf(parent, child, orphan), TaskSorting.DEFAULT, now, newYork)
|
||||
val rows = widgetRows(
|
||||
listOf(parent, child, orphan),
|
||||
TaskSorting.DEFAULT,
|
||||
now,
|
||||
newYork,
|
||||
groupByList = false,
|
||||
listNames = emptyMap(),
|
||||
)
|
||||
assertThat(rows.map { it.title }).containsExactly("parent", "orphan")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rows are capped`() {
|
||||
val many = (1L..(MAX_WIDGET_ROWS + 10L)).map { testTask(id = it, title = "t$it") }
|
||||
assertThat(widgetRows(many, TaskSorting.DEFAULT, now, newYork)).hasSize(MAX_WIDGET_ROWS)
|
||||
assertThat(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+92
-21
@@ -11,9 +11,12 @@ carries no APK assets.
|
||||
Codeberg is the canonical forge; Gitea is build infrastructure. See
|
||||
[Two forges, one repo](#two-forges-one-repo) for how the two are wired.
|
||||
|
||||
While Agendula is pre-1.0 (`versionName` starts with `0.`), every release is
|
||||
flagged as a **pre-release**. This happens automatically and graduates to a
|
||||
stable release at `1.0.0` — no manual toggling.
|
||||
Before a stable release you can ship **betas** (`X.Y.Z-beta.N`) from the
|
||||
release branch. They go to **Codeberg only**, flagged as pre-releases; F-Droid
|
||||
and Play never see them. See [Cutting a beta](#cutting-a-beta).
|
||||
|
||||
While Agendula was pre-1.0 (`versionName` started with `0.`), every release was
|
||||
flagged as a **pre-release** automatically.
|
||||
|
||||
---
|
||||
|
||||
@@ -22,16 +25,30 @@ stable release at `1.0.0` — no manual toggling.
|
||||
A release is defined by the `versionName`/`versionCode` committed in
|
||||
`app/build.gradle.kts` — **not** by a hand-pushed tag:
|
||||
|
||||
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `0.2.0`)
|
||||
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`0.2.0` → `200`,
|
||||
`1.3.4` → `10304`)
|
||||
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `1.1.0`), or
|
||||
`MAJOR.MINOR.PATCH-beta.N` for a beta (e.g. `1.1.0-beta.2`)
|
||||
- `versionCode` is derived from it by `scripts/version_info.sh`:
|
||||
|
||||
So `MINOR` and `PATCH` each have room for 0–99. 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).
|
||||
| `versionName` | `versionCode` | Example |
|
||||
| --- | --- | --- |
|
||||
| `X.Y.Z` below 1.1.0 (legacy) | `X*10000 + Y*100 + Z` | `1.0.1` → `10001` |
|
||||
| `X.Y.Z-beta.N` (N = 1–98) | `X*1000000 + Y*10000 + Z*100 + N` | `1.1.0-beta.2` → `1010002` |
|
||||
| `X.Y.Z` from 1.1.0 | `X*1000000 + Y*10000 + Z*100 + 99` | `1.1.0` → `1010099` |
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
@@ -39,16 +56,19 @@ re-running the workflow safely retries).
|
||||
|
||||
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
|
||||
that branch, before it reaches `main`.
|
||||
that branch, before it reaches `main`. To ship betas first, follow
|
||||
[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
|
||||
`## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading
|
||||
and the next `## [` becomes the Gitea and Codeberg release notes. The
|
||||
heading **must** match the version exactly.
|
||||
3. **Bump the committed `versionName`** (and `versionCode`) in
|
||||
`app/build.gradle.kts`. **This bump is what triggers the release** when the
|
||||
3. **Bump the committed `versionName`** (and `versionCode`, which
|
||||
`scripts/version_info.sh` prints) in `app/build.gradle.kts`. **This bump is what triggers the release** when the
|
||||
branch merges to `main`.
|
||||
Then write this version's **"What's New"** by hand:
|
||||
`fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt`, one per
|
||||
`fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt` (e.g.
|
||||
`1010099.txt` for 1.1.0), one per
|
||||
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
|
||||
copy of the CHANGELOG.md section. `scripts/sync_changelog_to_fastlane.sh`
|
||||
@@ -79,6 +99,48 @@ re-running the workflow safely 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
|
||||
|
||||
CI and release are split so a change is built once on its PR and only does
|
||||
@@ -94,10 +156,12 @@ release work when a merge actually cuts a release:
|
||||
`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
|
||||
reported.
|
||||
- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC plus
|
||||
`workflow_dispatch`, **Gitea**) — the dependency sweep. Runs the pinned
|
||||
Renovate image as its job container and targets Codeberg's API; the cadence
|
||||
lives here, not in `renovate.json5`, so the two don't double-gate.
|
||||
- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC, on push to
|
||||
`main`, plus `workflow_dispatch`, **Gitea**) — the dependency sweep. Runs the
|
||||
pinned Renovate image as its job container and targets Codeberg's API. New
|
||||
PRs only open in the Monday `schedule` window in `renovate.json5` (keep it
|
||||
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
|
||||
`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
|
||||
@@ -110,6 +174,12 @@ release work when a merge actually cuts a release:
|
||||
**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
|
||||
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
|
||||
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
|
||||
@@ -123,7 +193,8 @@ 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
|
||||
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
|
||||
adds no trust surface. It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset,
|
||||
adds no trust surface. The logic lives in `scripts/publish_codeberg_release.sh`,
|
||||
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
|
||||
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
|
||||
|
||||
@@ -42,7 +42,9 @@ 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
|
||||
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. Only a change to the
|
||||
(`AutoUpdateMode`), so there is no per-release work there. Beta tags
|
||||
(`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
|
||||
commit hash, never a tag.
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
### 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,12 @@
|
||||
### 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,12 @@
|
||||
### 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,12 @@
|
||||
### 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: db4be68dad...cb109c0e82
@@ -177,6 +177,7 @@ floret-core-locale = { group = "de.jeanlucmakiola.floret", name = "core-locale"
|
||||
floret-core-crash = { group = "de.jeanlucmakiola.floret", name = "core-crash" }
|
||||
floret-identity = { group = "de.jeanlucmakiola.floret", name = "identity" }
|
||||
floret-components = { group = "de.jeanlucmakiola.floret", name = "components" }
|
||||
floret-glance = { group = "de.jeanlucmakiola.floret", name = "glance" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
+10
-3
@@ -52,9 +52,16 @@
|
||||
prConcurrentLimit: 5,
|
||||
prHourlyLimit: 0,
|
||||
|
||||
// Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml,
|
||||
// Mondays) — no internal `schedule` here, so the two don't double-gate and
|
||||
// silently skip a run.
|
||||
// New branches/PRs only in the Monday window (UTC), which brackets the weekly
|
||||
// cron in .gitea/workflows/renovate.yml with slack for a late start — keep
|
||||
// the two in step, or the sweep lands outside the window and finds nothing.
|
||||
// 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
|
||||
// .forgejo/workflows (Codeberg) and the release/renovate jobs in
|
||||
|
||||
@@ -16,6 +16,7 @@ without setup.
|
||||
"""
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -120,10 +121,11 @@ def check_images(locale_dir):
|
||||
|
||||
|
||||
def version_code():
|
||||
gradle = (ROOT / "app/build.gradle.kts").read_text()
|
||||
name = re.search(r'versionName\s*=\s*"([^"]+)"', gradle).group(1)
|
||||
parts = (name.split(".") + ["0", "0"])[:3]
|
||||
return int(parts[0]) * 10000 + int(parts[1]) * 100 + int(parts[2])
|
||||
"""The committed version's code, or None for a beta (it ships no What's New)."""
|
||||
def info(key):
|
||||
return subprocess.run(["bash", str(ROOT / "scripts/version_info.sh"), key],
|
||||
check=True, capture_output=True, text=True).stdout.strip()
|
||||
return None if info("channel") == "beta" else int(info("version_code"))
|
||||
|
||||
|
||||
def shipped_languages():
|
||||
@@ -183,7 +185,7 @@ def main():
|
||||
err(images / "featureGraphic.png", "required for Play (1024x500, no alpha)")
|
||||
if len(list((images / "phoneScreenshots").glob("*"))) < 2:
|
||||
err(images / "phoneScreenshots", "Play requires at least 2 phone screenshots")
|
||||
for loc in REQUIRED_LOCALES:
|
||||
for loc in REQUIRED_LOCALES if current is not None else []:
|
||||
if not (META / loc / "changelogs" / f"{current}.txt").exists():
|
||||
err(META / loc / "changelogs" / f"{current}.txt", "no What's New for this version")
|
||||
|
||||
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/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."
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/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
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/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,11 +11,15 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
|
||||
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
|
||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
VERSION=$(bash scripts/version_info.sh version)
|
||||
VERSION_CODE=$(bash scripts/version_info.sh version_code)
|
||||
|
||||
# Betas only ship to Codeberg, whose notes come from CHANGELOG.md. A What's New
|
||||
# file for one would sit in the tree F-Droid and Play read, so none is wanted.
|
||||
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.
|
||||
MAX_CHARS=${MAX_CHARS:-500}
|
||||
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/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