Author SHA1 Message Date
makiolaj 7fde7ae290 fix: restore the floret glance dependency lost in the rebase
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 10m34s
2026-10-05 18:58:03 +02:00
makiolaj 5d273e05c7 chore: 1.1.0-beta.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Failing after 2m31s
2026-10-05 18:51:20 +02:00
makiolaj 59582b3641 docs(release): keep CHANGELOG entries under Unreleased while betas go out 2026-10-05 18:49:31 +02:00
makiolaj 3540d2fb7c chore: release 1.1.0 2026-10-05 18:49:15 +02:00
makiolaj 3812e950ca fix: remove double inset on the list editor's smart-lists row 2026-10-05 18:49:08 +02:00
makiolaj c6d62c36f4 fix(widget): regenerate picker previews to match the final widget designs 2026-10-05 18:49:08 +02:00
makiolaj a4fbbf43c6 feat: allow editing synced lists, not just device-only ones
The edit pencil was gated to local lists even though rename/delete already
worked end to end for synced collections. Read-only shares keep the icon
but show it disabled, with a hint on tap explaining why.

Refs #29
2026-10-05 18:49:08 +02:00
makiolaj d81e99084e fix: remove double inset on the list editor's Where row
Refs #30
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj 9a87837917 feat(widget): widget redesign and new Today/Up next/Lists/Week widgets (#33)
Reworks the home-screen widgets on top of #32.

- Tasks widget: agenda-style rows, a row-colour setting, an "All caught up" empty state
- Drops "At a glance" (it duplicated the Tasks widget) for four new widgets: Today (progress ring + today's tasks), Up next (the nearest due task, optionally from one list), Lists (adaptive shortcut grid), Week (day strip with counts)
- Ticking a task in any widget shows a done frame before the row goes away
- Shared Glance primitives live in floret-kit (`feat/glance-module`): progress ring, tiles, check button, header action pill, expressive shapes, done state

Today and Up next don't offer the transparent background, since their content isn't in containers and is unreadable on the wallpaper. No issue behind this one; it came out of reviewing the widgets on-device.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/33
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj 9630bd104d feat: let a widget show tasks from several lists (#32)
Widget config now multi-selects real lists (smart lists stay
single-select, mutually exclusive) and offers an optional "group by
list" sort that heads each list's tasks with its name.

Closes #25

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/32
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj 7c3b62eb0d feat: setting to default a new task's start to today (#31)
A new Settings → Task form toggle, off by default, pre-fills a fresh
task's start with the current moment instead of leaving it blank.

Closes #27

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/31
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj fe0c7d83a9 ci(release): beta releases as Codeberg-only pre-releases (#38)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 52s
### What this changes

Adds beta releases. Pushing a `release/*` branch whose committed `versionName` is `X.Y.Z-beta.N` makes the new `.gitea/workflows/beta.yaml` run the unit tests, build and sign the APK with the app key, and publish it as a **Codeberg pre-release** (APK + `.sha256`), plus a Gitea pre-release with the R8 mapping. F-Droid (self-hosted and official) and Play never get a beta; Obtainium only offers it with *Include prereleases* on.

- **`scripts/version_info.sh`** is the single source for `versionName` → `versionCode`, used by `release.yaml`, `beta.yaml`, the changelog sync and the store-listing check. From 1.1.0: `X*1000000 + Y*10000 + Z*100 + N` for betas (N = 1–98), `+ 99` for stable, so `1.1.0-beta.1` → `1010001`, `1.1.0` → `1010099`. All 1.0.x versions keep the legacy formula, so `release/v1.0.1` (code `10001`) stays valid.
- **Shared publish scripts:** `scripts/publish_codeberg_release.sh`, `scripts/publish_gitea_release.sh` and `scripts/release_notes.sh`, moved out of `release.yaml`. The stable path behaves as before.
- **Guards:**
  - CI fails a PR whose committed `versionCode` doesn't match its `versionName`.
  - CI fails a PR into `main` that carries a beta version.
  - `release.yaml`'s `detect` refuses a beta on `main` as a backstop.
  - `beta.yaml` refuses a beta of a version that has already shipped as stable.
  - Betas get no store What's New file.
- **Docs:** "Cutting a beta" and the versionCode table in `docs/RELEASING.md`; a note on beta tags in `docs/fdroid-official/README.md`; how to opt in to betas in the README.

### Why

To ship a test build of an upcoming version (e.g. 1.1.0) to opted-in testers before the stable release, without it reaching F-Droid or Play users.

### How it was tested

- `scripts/version_info.sh` against stable, beta, legacy and invalid version names.
- Both publish scripts against a mock forge API: create, re-run (PATCH plus asset replacement), Codeberg's 500-then-retry path, and the skip when no token is set.
- A scratch copy with `1.1.0-beta.1` committed: the version check passes, the changelog sync and `check_store_listing.py --complete` pass without a What's New, the PR-into-main guard trips, and a wrong `versionCode` is rejected.
- `sync_changelog_to_fastlane.sh` and `check_store_listing.py` (with and without `--complete`) still pass on the current `1.0.0`.
- All three workflow files parse as YAML.

Not run on the real runners yet. The first beta push is the live test of `beta.yaml`, which assumes a mirrored branch push starts a workflow on Gitea, the same way pushes to `main` already do.

### Checklist

- [x] No `versionName` / `versionCode` bump
- [x] No `values-*/strings.xml` touched
- [x] `CHANGELOG.md` not updated: this is release infrastructure, not a user-visible change

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/38
2026-10-05 18:47:57 +02:00
Jean-Luc Makiolaandmakiolaj f92f9bcdad chore(renovate): rebase conflicted PRs on every merge to main (#36)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 10s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 53s
Renovate only rebases while it runs, and it ran once a week, so merging one dependency PR left the others conflicted until the next Monday.

- `renovate.yml`: also run on push to `main` (mirror syncs fire push events, like `release.yaml`).
- `renovate.json5`: new PRs only in a Monday window (`* 4-6 * * 1`) around the weekly cron; on-merge runs only rebase conflicted PRs (`rebaseWhen: "conflicted"`).

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/36
2026-10-05 13:30:11 +02:00
Jean-Luc Makiola 53d5e2c5aa Translations update from Weblate (#26)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/26
2026-10-02 18:13:09 +02:00
Robin Schanbacher 6888965f5a Translated using Weblate (German)
Currently translated at 62.4% (359 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-01 21:35:13 +00:00
Robin Schanbacher f1ba21e7fa Translated using Weblate (German)
Currently translated at 57.3% (330 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-01 19:19:02 +00:00
Robin Schanbacher 8abc44bbdf Translated using Weblate (German)
Currently translated at 57.0% (328 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-09-30 20:57:48 +00:00
Robin Schanbacher a6e3a35f04 Translated using Weblate (German)
Currently translated at 45.0% (259 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-09-29 20:37:10 +00:00
Jean-Luc Makiolaandmakiolaj a3de9f3d93 docs(readme): show the store screenshots (#24)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Adds the five phone screenshots from `fastlane/metadata/android/en-US/images/phoneScreenshots/` to the README header, as a centered row beneath the badges.

The README references the fastlane files directly rather than copies, so the README and the store listings stay in sync. Each image has descriptive alt text.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/24
2026-09-28 14:11:27 +02:00
Jean-Luc Makiola c823243e53 Merge pull request 'docs: sync the F-Droid recipe with the submitted one' (#22) from docs/fdroid-recipe into main
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
2026-09-24 22:35:14 +02:00
81 changed files with 4368 additions and 881 deletions
+19 -1
View File
@@ -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
+206
View File
@@ -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
View File
@@ -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
+11 -2
View File
@@ -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.
+18
View File
@@ -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
+10
View File
@@ -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
+9 -6
View File
@@ -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)
+52
View File
@@ -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>
@@ -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>
+246 -6
View File
@@ -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>
+62 -1
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -5
View File
@@ -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")
+96
View File
@@ -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."
+76
View File
@@ -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
+29
View File
@@ -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
+9 -5
View File
@@ -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}
+87
View File
@@ -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