19 Commits

Author SHA1 Message Date
bc70ed3a9f release: cut 0.3.2 — get the crash fix onto Codeberg
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 11m59s
2026-07-20 19:11:55 +02:00
3f166ef5f0 release: cut 0.3.2 — get the crash fix onto Codeberg
No app-code change from 0.3.1: this exists to re-run the release pipeline
now that the Codeberg publish step is fixed. 0.3.1's APK reached F-Droid but
its Codeberg release 500'd, so anyone installing from Codeberg or Obtainium
is still on the crashing 0.3.0 — cutting 0.3.2 gets them the fix and proves
the workflow fix in the only way that counts, a real release.

Chosen over hand-patching the 0.3.1 release onto Codeberg: same outcome for
users, minus a manual APK upload, and it actually exercises the pipeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:11:48 +02:00
41bd49826a ci(release): create the Codeberg release while the tag is absent
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
2026-07-20 19:10:56 +02:00
05c75bafa7 ci(release): create the Codeberg release while the tag is absent
Revert 4aa65ed's approach. It read the Forgejo 500 as being caused by
target_commitish, and pushed the tag to Codeberg first so the release could
attach to an existing tag without one. That inverted the actual failure:
Forgejo 500s on POST /releases precisely WHEN the tag already exists, so
pre-pushing it guarantees the error it was meant to avoid.

The second half then can't recover — a bare tag is not a release, so
GET /releases/tags 404s and the upsert has no id, which is the
"Could not resolve Codeberg release id" that ended the 0.3.1 run.

0.3.0 published fine because it POSTed while the tag was still unsynced and
let the API mint tag + release together. Do that deliberately instead of by
luck: if no release owns the tag, delete the mirrored tag, then POST with
target_commitish. Branches mirror reliably (main was already at the release
commit when 0.3.1 failed), so the commit is present and only the raced-in
tag was in the way. Deleting it is safe exactly because no release owns it.

An existing release still takes the PATCH path and skips the delete, so
re-runs never disturb something already published.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:06:36 +02:00
cfa25b9730 release: cut 0.3.1 — fix the launch crash in minified builds (#1)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 11m31s
2026-07-20 18:36:47 +02:00
9d7fc64b0b release: cut 0.3.1 — fix the launch crash in minified builds
R8 kept androidx.work.impl.WorkDatabase_Impl but pruned its no-arg
constructor: nothing calls it directly, Room only reaches it reflectively.
Room then threw InstantiationException, surfacing as "Failed to create an
instance of androidx.work.impl.WorkDatabase". WorkManager builds that
database from a startup ContentProvider, so 0.3.0 died before any of our
code ran — every install, every launch.

We don't depend on WorkManager directly; it arrives via Glance. AGP 9's
stricter R8 is what tipped this over, which is why 0.3.0 was the first
release to hit it.

Keep the Room no-arg constructor, and the ListenableWorker constructor
alongside it — same pruning hazard on the path WorkManager uses to
instantiate workers by name, which would have bitten once a Glance widget
update actually ran.

Reproduced the reporter's stack trace frame-for-frame on a releaseTest
build, then confirmed it launches clean afterwards. Fixes #1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:35:41 +02:00
4aa65edb45 ci(release): push tag to Codeberg before creating the release
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
The first fix didn't help: the pipeline creates the tag via the Gitea
API, and the push mirror (sync_on_commit only fires on real git pushes)
doesn't propagate an API-created tag promptly. So the Codeberg release
POST still raced the mirror and 500'd on a commit/tag Codeberg hadn't
received (0.2.1 and 0.3.0 both shipped everywhere but Codeberg).

Push the tag straight to Codeberg from the runner (guaranteed present),
then attach the release to that existing tag with no target_commitish —
which is what Forgejo 500s on. Race-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:48:29 +02:00
a9843e25b7 release: cut 0.3.0
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m27s
Ships the M5 reminders onboarding + Settings screen alongside the reworked
overview (Today progress ring, live Upcoming preview, unfurling search) and
the real launcher mark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:25:14 +02:00
5f4711dabe ui: move the action press flourish onto the shape, spin the gear
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been cancelled
The press animation turned the glyph; put it on the scalloped cookie
container instead and make it stronger (scale 1→0.82, rotate 0→40°). The
glyph holds upright via a counter-rotation, so the shape spins while a
magnifier or list icon stays readable. New spinIcon opts a glyph into its own
quarter turn — the settings gear uses it, so it reads as a gear cranking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
6d3fbc05c1 home: unfurl search from a fixed search icon, drop the title
Rework the top bar into a custom row with no app title (the launcher icon
already names the app). Settings stays pinned at the right and the search
action sits just left of it; neither moves. Tapping search unfurls a pill
leftward from the magnifier (expandHorizontally anchored at the end) holding
the auto-focused query field and a clear button, with the search icon
remaining as the bar's fixed trailing icon. Results render over the home
content as you type; a blank query leaves the home screen visible. Tapping
the icon again or system back closes search; the FAB hides while searching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
721b411579 icon: centre the launcher mark on the canvas
The first pass shifted the mark off-centre. Scale 0.66 about the canvas
centre, and centre the task CARD (not the card+bloom bounding box, which the
overhanging bloom badge drags low): pivot the Y-scale at the card's centre
(y=242.76) and translate +13.24 so the card sits dead-centre, horizontally
and vertically, with the bloom badging out to the lower-right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
046b8f7e9e home: move search behind a top-bar action, not an always-on bar
The inline search bar was permanently visible. Replace it with a search
action button (a 6-sided cookie shape, sibling to the settings cookie) to
the left of settings: the search bar is absent until tapped, then opens
expanded and auto-focused, covering the home content with live results.
Back arrow or system back closes it; the FAB hides only while searching.
Pass windowInsets = 0 so the bar, already below the app bar, does not
re-apply the status-bar inset and float with a large top gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
06cc9b1c8b icon: real launcher mark — task card, check, calendula bloom
Replace the placeholder check-mark foreground with the finished agendula
mark (converted from design/icon/agendula_icon.svg): a rounded line-art task
card with a check, plus a small Calendula bloom badge in the open
bottom-right corner — the sibling of Calendula's calendar mark.

Strokes render in Calendula's off-white (#FAF6F0) over agendula's existing
plum background (#7A5C6B, the hue-rotated counterpart of Calendula's slate),
so the two apps read as a family while staying distinct. Scaled 0.66 to
match Calendula's footprint and ~2.8dp stroke weight; reused as the
<monochrome> slot for themed icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
2d366a7be3 home: inline expanding search over all tasks
Add a Material 3 SearchBar overlaying the top of the overview. Collapsed it
is a "Search tasks" bar below the title row; tapping expands it in place to
cover the home content with live results, filtering every task (open and
completed) by title, case-insensitive. The leading icon flips to a back
arrow while expanded, a clear button empties the query, and the FAB hides so
it does not float over the results. Results reuse the upcoming preview row.

ListsViewModel.Content now carries allTasks (open + completed) as the search
corpus; filtering stays in memory so the provider query is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
52aeebcb53 home: Today progress ring + live Upcoming preview
Rework the overview's 2x2 smart grid into a daily-momentum layout:

- Promote Today into a full-width hero with an M3 Expressive
  CircularWavyProgressIndicator over "x of y done" for tasks due today.
  Empty/all-done states read as a calm finished state, not a bare 0.
- Drop the white "Upcoming 0" tile (it shouted loudest while carrying the
  least) in favour of a live preview of the next few upcoming tasks, each a
  slim row with the quiet meta line and a tap-through to the task.
- Overdue + All fall back to a 2-up of the existing tonal tiles.

ListsViewModel now combines a Smart(COMPLETED) flow so the ring can count
the tasks already ticked off today (the open smart lists drop them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
8f6b85008c release: cut 0.2.2
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been cancelled
Re-cut after 0.2.1's Codeberg publish failed (500 on pre-synced tag,
fixed in the previous commit). Validates the fix end to end and ships
the Codeberg direct-download channel (signed APK + SHA-256).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:14:57 +02:00
eb1e530ce7 ci(release): fix Codeberg publish 500 on pre-synced tag
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
The push mirror (sync_on_commit) syncs the tag to Codeberg before the
publish step runs, and Forgejo returns HTTP 500 on POST /releases with a
target_commitish when the tag already exists — so the release was never
created (v0.2.1 shipped to Gitea/F-Droid but not Codeberg). Only pass
target_commitish when the tag isn't mirrored yet; otherwise attach the
release to the existing tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:07:37 +02:00
7516972e9f release: cut 0.2.1
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m13s
Publish releases to the Codeberg mirror (signed APK + SHA-256 checksum)
as a direct-download channel. Bumps versionName to 0.2.1; the pipeline
mints the v0.2.1 tag on merge to main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:53:49 +02:00
7f58f81fe1 ci: adopt the modern calendula pipeline + Codeberg mirror
Port Calendula's current CI/release pipeline:

- ci.yaml: pull_request-triggered, change-scope classification
  (docs/metadata-only PRs skip the Android build but still report a
  green CI), and a reproducible-release invariant guard.
- release.yaml: the committed versionName is the source of truth — a
  bump reaching main triggers the release, which builds, signs,
  publishes to the F-Droid repo, then mints the vX.Y.Z tag + Gitea
  release and mirrors it to Codeberg with the signed APK + SHA-256
  checksum. workflow_dispatch runs the re-sign-only recovery path.
- Gitea releases are flagged as pre-releases while MAJOR is 0.
- build.gradle.kts: reproducible-release invariants (vcsInfo,
  dependenciesInfo) + a releaseTest variant for the on-device gate.
- fastlane/ becomes the single source of truth for store metadata;
  the localized F-Droid layout is generated from it at release time.
- Port scripts/, .gitea/ISSUE_TEMPLATE/, and rewrite docs/RELEASING.md
  for the versionName-in-main model; fix stale references elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:53:13 +02:00
33 changed files with 1565 additions and 358 deletions

View File

@@ -0,0 +1,23 @@
---
name: Bug report
about: Something doesn't work the way it should
title: ""
labels:
- bug
---
### What happened
### What you expected
### Steps to reproduce
1.
2.
3.
### Environment
- Agendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -0,0 +1,27 @@
---
name: Crash report
about: Report a crash. Agendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
title: "Crash: "
labels:
- bug
- crash
- priority:high
---
<!--
Thanks for reporting a crash in Agendula!
If the app prefilled this for you, the crash report is already below — just add
what you were doing and submit. Otherwise, paste the report from your clipboard
into the code block. The report contains only app/Android/device versions and the
stack trace — no personal data or calendar content.
-->
### What happened
### Crash report
```
(paste the crash report here)
```

View File

@@ -0,0 +1,16 @@
---
name: Feature request
about: Suggest an idea or improvement
title: ""
labels:
- feat
---
### What would you like Agendula to do?
### Why — what problem does it solve?
### Anything else
<!-- mockups, examples from other apps, alternatives you considered -->

View File

@@ -0,0 +1,19 @@
---
name: Question
about: Ask how something works or get help using Agendula
title: ""
labels:
- question
---
### Your question
### What you've tried
<!-- so far, if anything -->
### Context
- Agendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -1,18 +1,23 @@
name: CI name: CI
# One gate per pull request. Branch pushes no longer trigger CI on their own,
# so a change is built once on its PR (covering feature -> release/* and
# release/* -> main) instead of once per push and again on the merge to main.
# The merge itself is handled by release.yaml, which only does heavy work when
# the merge actually cuts a release.
on: on:
push: pull_request:
branches:
- '**'
tags-ignore:
- '**'
# Cancel superseded runs on the same branch. # Cancel superseded runs for the same PR.
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# Single job named `ci` so the required "CI" status check is always reported,
# even for docs-only PRs: those just skip the Android build and the job still
# succeeds (fast green check) instead of being filtered out and leaving the
# required check pending forever.
ci: ci:
runs-on: docker runs-on: docker
env: env:
@@ -22,15 +27,53 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# Full history so the base..HEAD diff below has a merge-base.
fetch-depth: 0
submodules: recursive submodules: recursive
# Cheap, always-on guard: the release build must stay reproducible for the
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
# change scope so a regression can't slip through on a "docs-only" PR.
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Decide whether anything that affects the app build changed. Docs,
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
# Gradle work below but still report a green `ci`.
- name: Classify change scope
id: scope
run: |
set -e
BASE="${{ github.base_ref }}"
# Full (not --depth=1) base fetch so the merge-base is present even when
# the PR branch forked several commits back; a shallow tip has no merge
# base with a divergent branch and `git diff base...HEAD` aborts.
git fetch --no-tags origin "$BASE"
MB=$(git merge-base "origin/$BASE" HEAD 2>/dev/null || true)
if [ -z "$MB" ]; then
# No common ancestor available — don't risk skipping the build.
echo "No merge base with origin/$BASE — running the full build to be safe."
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$MB" HEAD)
echo "Changed files:"; echo "$CHANGED"
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build."
fi
- name: Setup Java - name: Setup Java
if: steps.scope.outputs.code == 'true'
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
- name: Setup Android SDK - name: Setup Android SDK
if: steps.scope.outputs.code == 'true'
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with: with:
# Default ("tools platform-tools") drags in the Android Emulator # Default ("tools platform-tools") drags in the Android Emulator
@@ -38,12 +81,14 @@ jobs:
packages: '' packages: ''
- name: Setup Android SDK cache - name: Setup Android SDK cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: /opt/android-sdk path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0 key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages - name: Install Android SDK packages
if: steps.scope.outputs.code == 'true'
run: | run: |
yes | sdkmanager --licenses >/dev/null || true yes | sdkmanager --licenses >/dev/null || true
sdkmanager \ sdkmanager \
@@ -52,6 +97,7 @@ jobs:
"build-tools;36.0.0" "build-tools;36.0.0"
- name: Setup Gradle cache - name: Setup Gradle cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
@@ -62,21 +108,25 @@ jobs:
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
if: steps.scope.outputs.code == 'true'
run: chmod +x ./gradlew run: chmod +x ./gradlew
# No --no-daemon: the daemon lives only as long as this job container # No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration. # and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variant only) - name: Lint (debug variant only)
if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug run: ./gradlew lintDebug
- name: Unit tests - name: Unit tests
if: steps.scope.outputs.code == 'true'
run: ./gradlew testDebugUnitTest run: ./gradlew testDebugUnitTest
- name: Assemble debug APK - name: Assemble debug APK
if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug run: ./gradlew assembleDebug
- name: Trivy filesystem scan - name: Trivy filesystem scan
if: github.ref == 'refs/heads/main' if: steps.scope.outputs.code == 'true'
run: | run: |
set -e set -e
SUDO="" SUDO=""

View File

@@ -1,77 +1,94 @@
name: Release — F-Droid repo + Gitea release name: Release — F-Droid repo + Gitea/Codeberg release
# A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
# direct-download channel — the tag is an output of the pipeline, not its
# trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing.
#
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
# without building an APK or creating a release. Used for key rotation / repo
# recovery.
on: on:
push: push:
tags: branches: [main]
- '*'
workflow_dispatch: workflow_dispatch:
concurrency:
group: release
cancel-in-progress: false
jobs: jobs:
ci: # Cheap gate: resolve the version from the committed build.gradle and decide
# whether this push actually cuts a new release (no tag for it yet). Keeps the
# heavy job from running on every merge to main.
detect:
runs-on: docker runs-on: docker
env: outputs:
ANDROID_HOME: /opt/android-sdk is_release: ${{ steps.v.outputs.is_release }}
ANDROID_SDK_ROOT: /opt/android-sdk version: ${{ steps.v.outputs.version }}
version_code: ${{ steps.v.outputs.version_code }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
- name: Setup Java - name: Resolve version and whether it is a new release
uses: actions/setup-java@v4 id: v
with: env:
distribution: 'zulu' TOKEN: ${{ secrets.GITHUB_TOKEN }}
java-version: '17' API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
- 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: | run: |
yes | sdkmanager --licenses >/dev/null || true set -e
sdkmanager \ VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
"platform-tools" \ if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
"platforms;android-37.0" \ MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
"build-tools;36.0.0" 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)"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — re-sign path, not a release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
if [ "$STATUS" = "200" ]; then
echo "Tag v$VERSION already exists — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
else
echo "No tag for v$VERSION yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Gradle cache # Releases: build + sign + publish, then mint the tag and Gitea release.
uses: actions/cache@v4 # Also runs on manual dispatch, where it skips the build and just re-signs and
with: # re-uploads the existing index (recovery path).
path: | release:
~/.gradle/caches needs: detect
~/.gradle/wrapper if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# Lint already enforced on every push to main via ci.yaml.
# Release sanity only re-runs tests + a debug build to catch
# any tag-resolved drift (e.g. version code substitution issues).
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Assemble debug APK (sanity)
run: ./gradlew assembleDebug
build-and-deploy:
needs: ci
runs-on: docker runs-on: docker
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
IS_RELEASE: ${{ needs.detect.outputs.is_release }}
PRERELEASE: ${{ needs.detect.outputs.prerelease }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -125,31 +142,26 @@ jobs:
$SUDO apk add --no-cache jq $SUDO apk add --no-cache jq
fi fi
# Tag-only build steps. On a manual workflow_dispatch (ref = a branch, - name: Grant execute permission for gradlew
# not a tag) these are skipped: the job then just re-signs the existing run: chmod +x ./gradlew
# index with the configured repo key and re-uploads — used for key
# rotation / repo recovery without publishing a new APK. # The committed versionName is the source of truth. Pin versionCode to the
- name: Set version from git tag # value derived from it so the published APK's code is always
if: startsWith(github.ref, 'refs/tags/') # MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
- name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true'
run: | run: |
set -e set -e
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${RAW_TAG#v}"
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, VersionCode: $VERSION_CODE"
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts grep -E 'versionName|versionCode' app/build.gradle.kts
# Export for later steps (F-Droid changelog, mapping asset name).
echo "VERSION=$VERSION" >> "$GITHUB_ENV" # Test the exact commit being shipped (only on a real release).
echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV" - name: Unit tests
if: env.IS_RELEASE == 'true'
run: ./gradlew testDebugUnitTest
- name: Setup Android Keystore - name: Setup Android Keystore
if: startsWith(github.ref, 'refs/tags/') if: env.IS_RELEASE == 'true'
env: env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
@@ -164,11 +176,8 @@ jobs:
storeFile=upload-keystore.jks storeFile=upload-keystore.jks
EOF EOF
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build release APK - name: Build release APK
if: startsWith(github.ref, 'refs/tags/') if: env.IS_RELEASE == 'true'
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
- name: Setup F-Droid Server Tools - name: Setup F-Droid Server Tools
@@ -206,8 +215,7 @@ jobs:
set -euo pipefail set -euo pipefail
# Fail loudly if the repo key is not configured. NEVER auto-generate # Fail loudly if the repo key is not configured. NEVER auto-generate
# one: a fresh key changes the repo fingerprint and breaks every # one: a fresh key changes the repo fingerprint and breaks every
# user's pinned repo. (Replaces the old `fdroid update --create-key` # user's pinned repo.
# path, which silently rotated the key on a wiped server.)
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2 echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
echo "Refusing to continue — will not auto-generate a new repo key." >&2 echo "Refusing to continue — will not auto-generate a new repo key." >&2
@@ -220,42 +228,33 @@ jobs:
mkdir -p fdroid/repo/icons mkdir -p fdroid/repo/icons
- name: Copy new APK to repo - name: Copy new APK to repo
if: startsWith(github.ref, 'refs/tags/') if: env.IS_RELEASE == 'true'
run: | run: |
set -e set -e
mkdir -p fdroid/repo mkdir -p fdroid/repo
REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')"
if [ -z "$SAFE_REF_NAME" ]; then
SAFE_REF_NAME="${GITHUB_SHA:-manual}"
fi
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_${SAFE_REF_NAME}.apk"
- name: Copy metadata to F-Droid repo # Per-version "What's New": ensure this version's changelog exists in the
# fastlane tree (committed at release-cut time for the official repo; this
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
# the commit having happened). The transform below then carries it across.
- name: Ensure this version's changelog is in the fastlane tree
if: env.IS_RELEASE == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh
- name: Build F-Droid metadata from fastlane (single source of truth)
run: | run: |
mkdir -p fdroid/metadata mkdir -p fdroid/metadata
cp -r fdroid-metadata/* fdroid/metadata/ # App-level control file (Categories/License/links) for the self-hosted
# repo's `fdroid update`.
# Per-version "What's New" for F-Droid clients: the tag's CHANGELOG cp fdroid-metadata/de.jeanlucmakiola.agendula.yml fdroid/metadata/
# section written to changelogs/<versionCode>.txt (same extraction as the # Localized text + graphics + per-version changelogs come from the SAME
# Gitea release notes). en-US only — F-Droid falls back to it for locales # fastlane tree the official F-Droid repo harvests from source,
# without their own changelog. fdroid update bakes this into the index. # transformed into the F-Droid repo "localized" layout. One source of
- name: Generate F-Droid changelog for this version # truth, both channels.
if: startsWith(github.ref, 'refs/tags/') bash scripts/fastlane_to_fdroid_localized.sh \
run: | fastlane/metadata/android \
set -e fdroid/metadata/de.jeanlucmakiola.agendula
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > /tmp/changelog.txt
sed -i -e '/./,$!d' /tmp/changelog.txt
if [ ! -s /tmp/changelog.txt ]; then
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
fi
CL_DIR="fdroid/metadata/de.jeanlucmakiola.agendula/en-US/changelogs"
mkdir -p "$CL_DIR"
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
- name: Generate F-Droid Index - name: Generate F-Droid Index
run: | run: |
@@ -276,99 +275,47 @@ jobs:
SFTP SFTP
# Publish the signed repo/ plus metadata/ (descriptions, screenshots, # Publish the signed repo/ plus metadata/ (descriptions, screenshots,
# per-version changelogs) so changelog history survives across # per-version changelogs) so changelog history survives across
# releases. keystore.p12 and config.yml are NEVER uploaded, so they # releases. keystore.p12 and config.yml are NEVER uploaded.
# can't re-enter the web-served tree; nginx serves only repo/ anyway.
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/" sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable. # The APK is published and the index re-signed — now record the release.
# Attached to the Gitea release (it's not an APK, so it fits the # Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
# no-binaries rule). Best-effort: never fail a release over it. # this commit, so the tag only ever marks a fully-shipped release (and a
- name: Attach R8 mapping to Gitea release # failure before here leaves no tag, so re-running the workflow retries).
if: startsWith(github.ref, 'refs/tags/') - name: Create tag + Gitea release
continue-on-error: true if: env.IS_RELEASE == 'true'
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: | run: |
set -e set -e
MAP="app/build/outputs/mapping/release/mapping.txt" TAG="v$VERSION"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi # Notes = this version's CHANGELOG section.
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
ASSET="mapping-${VERSION:-$TAG}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
# The release is created by the gitea-release job; ensure it exists
# (idempotent) so this job doesn't race it to a 404.
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then
ID=$(curl -s -X POST -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \
"$API/releases" | jq -r '.id // empty')
fi
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"
# A Gitea release per tag, carrying the tag's CHANGELOG section as its
# notes. Deliberately no APK assets — distribution stays with the F-Droid
# repo; the release is the human-readable record. Gated on the tests-only
# ci job (not the deploy) so notes appear even if the F-Droid upload has
# an infrastructure hiccup.
gitea-release:
needs: ci
if: startsWith(github.ref, 'refs/tags/')
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Extract changelog section for this tag
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${TAG#v}"
# Everything between "## [<version>]" and the next "## [" heading.
awk -v ver="$VERSION" ' awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next } $0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 } /^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md flag' CHANGELOG.md > release-notes.md
# Trim leading blank lines.
sed -i -e '/./,$!d' release-notes.md sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi fi
echo "--- release notes ---" python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json
cat release-notes.md
- name: Create Gitea release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
python3 - "$TAG" <<'PY' > payload.json
import json, sys import json, sys
print(json.dumps({ print(json.dumps({
"tag_name": sys.argv[1], "tag_name": sys.argv[1],
"target_commitish": sys.argv[2],
"name": sys.argv[1], "name": sys.argv[1],
"body": open("release-notes.md").read(), "body": open("release-notes.md").read(),
"draft": False, "draft": False,
"prerelease": False, # Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": sys.argv[3] == "true",
})) }))
PY PY
# Upsert: the build-and-deploy job may have created a bare release # Upsert (re-run safe): PATCH if a release for the tag already exists,
# first (to attach the mapping asset), so PATCH the notes if it # else POST a new one (which also creates the tag at target_commitish).
# exists, otherwise POST a new one. Both paths are re-run safe.
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(python3 -c "import json,sys; d=json.load(open('existing.json')); print(d.get('id',''))" 2>/dev/null || true) ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
if [ -n "$ID" ]; then if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \ CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
@@ -382,6 +329,128 @@ jobs:
fi fi
cat response.json cat response.json
if [ "$CODE" != "$OK" ]; then if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)" echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1 exit 1
fi 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"
# Mirror the release to the Codeberg mirror as a direct-download channel
# for users who don't want F-Droid. Gitea already push-mirrors branches +
# tags to Codeberg, but releases aren't git objects so they don't sync —
# we create the release there over the API and attach the signed APK plus
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
# key), so this adds no trust surface. Best-effort: a Codeberg outage
# (it 504s under load) must never fail an already-published F-Droid
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
- name: Publish release to Codeberg
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
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
# Forgejo 500s on POST /releases when the tag ALREADY exists, and a
# bare tag is not a release — so GET /releases/tags then 404s and the
# upsert has no id to fall back on ("Could not resolve Codeberg
# release id", the 0.3.1 failure). Pushing the tag first therefore
# guarantees the 500 rather than avoiding it; 0.3.0 published because
# it POSTed while the tag was still absent. So: POST with the tag
# ABSENT and let the API mint tag + release together from
# target_commitish. Branches mirror reliably, so the commit is already
# on Codeberg — only a tag the mirror raced in ahead of us is in the
# way, and clearing it is safe precisely because no release owns it.
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then
curl -s -o /dev/null -w "codeberg tag DELETE HTTP %{http_code}\n" -X DELETE \
-H "Authorization: token $TOKEN" "$API/tags/$TAG"
fi
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > cb-payload.json
import json, sys
tag, sha, pre = sys.argv[1:4]
print(json.dumps({
"tag_name": tag,
# Recreate the tag as part of the release; the commit is on
# Codeberg already via the branch mirror.
"target_commitish": sha,
"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
# Upsert (re-run safe): a release that already exists is PATCHed in
# place — the delete above is skipped in that case, so re-running
# never disturbs a published release.
if [ -n "$ID" ]; 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/$ID"
else
curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
fi
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&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."

View File

@@ -1,28 +1,56 @@
# Changelog # Changelog
All notable changes to this project are documented here. The format follows All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/); the latest released git tag is [Keep a Changelog](https://keepachangelog.com/); the `versionName` committed in
the source of truth for version codes (see Calendula's `docs/RELEASING.md`). `app/build.gradle.kts` is the source of truth for a release (see
`docs/RELEASING.md`), and the `vX.Y.Z` tag is minted by the pipeline.
## [Unreleased] ## [Unreleased]
## [0.3.2] - 2026-07-20
### Fixed
- Releases reach the Codeberg download channel again. 0.3.1 published to
F-Droid but never appeared on Codeberg, so if you install from there — or
through Obtainium — this is the release that finally carries 0.3.0's
launch-crash fix. The app itself is unchanged from 0.3.1.
## [0.3.1] - 2026-07-20
### Fixed
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
release build stripped a constructor that the background-work scheduler needs
to open its database, and that happens before the app draws anything.
## [0.3.0] - 2026-07-19
### Added ### Added
- M5 reminders onboarding & polish: a one-time reminder onboarding step after the - Reminders: Agendula now delivers your due reminders itself. A one-time setup
provider grant explains that Agendula delivers due reminders itself and requests step explains this and asks for notification access, and a master switch in
`POST_NOTIFICATIONS` (API 33+). A new **Settings** screen (gear on the lists Settings turns the whole thing off again.
overview), structured after Calendula as a category hub with sliding sub-screens - A Settings screen, from the gear on the overview: appearance and theme, which
(`CollapsingScaffold` + grouped category rows + full-screen `OptionPicker`): fields the task form shows, your default list, and reminder defaults.
an About card, **Appearance** (theme, dynamic colour), **Task form** (which - The overview leads with Today — a progress ring showing how much of today
optional edit-form fields show by default, default list, the add-a-subtask-row you've finished — followed by a live preview of what's coming up next.
opt-out), and **Reminders** (a master enable switch that re-requests the - Search across every task, open or completed, from the top bar.
notification permission, a "when to remind" default offset, and — on Android 12 - A proper app icon.
only — an exact-alarm status row that deep-links to system settings).
- The reminders master switch gates the whole engine: turning it off clears every ### Changed
scheduled alarm and suppresses any that fire. - A tidier top bar: no app title, with search and settings pinned to the right.
- Expressive top-bar actions: a reusable `ShapedActionButton` wraps an action icon
in a tonal M3 Expressive `MaterialShapes` container (the settings entry uses the ## [0.2.2] - 2026-07-19
faceted Gem shape, tertiary tones), with a small press scale/rotate flourish.
Future top-bar actions pick their own shape from the `ActionShapes` registry. ### Fixed
- Release automation now reliably mirrors each release to the Codeberg mirror
(signed APK + SHA-256 checksum). The 0.2.1 attempt failed when the release tag
had already been synced to Codeberg.
## [0.2.1] - 2026-07-19
### Added
- Releases are now also published to the Codeberg mirror as a direct download:
each release carries the signed APK plus a SHA-256 checksum, for users who
don't use F-Droid.
## [0.2.0] - 2026-06-27 ## [0.2.0] - 2026-06-27

View File

@@ -30,11 +30,12 @@ would expose provider details to a ViewModel or the UI, it's in the wrong layer.
```sh ```sh
./gradlew :app:assembleDebug # build the debug APK ./gradlew :app:assembleDebug # build the debug APK
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine) ./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
./gradlew lintDebug # Android lint (CI runs this on every push) ./gradlew lintDebug # Android lint (CI runs this on every PR)
``` ```
CI (`.gitea/workflows/ci.yaml`) runs lint → unit tests → debug build on every CI (`.gitea/workflows/ci.yaml`) runs a reproducible-release invariant check,
push, so run these locally before opening a PR. Keep CI green. then lint → unit tests → debug build on every pull request, so run these locally
before opening a PR. Keep CI green.
## Where to put code ## Where to put code
@@ -75,8 +76,10 @@ push, so run these locally before opening a PR. Keep CI green.
- If your change shifts the architecture or completes a milestone, update - If your change shifts the architecture or completes a milestone, update
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md) [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
in the same PR. in the same PR.
- Don't bump `versionName` / `versionCode` by hand — the git tag drives those at - Don't bump `versionName` / `versionCode` in a regular PR — the committed
release time. `versionName` is bumped only when **cutting a release** (that bump reaching
`main` is what triggers the release; the pipeline then mints the tag). See
[`docs/RELEASING.md`](docs/RELEASING.md).
## Scope ## Scope

View File

@@ -23,13 +23,14 @@ android {
applicationId = "de.jeanlucmakiola.agendula" applicationId = "de.jeanlucmakiola.agendula"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
// The git tag is the single source of truth for released builds: at // These committed values ARE the source of truth for a release: merging
// release time .gitea/workflows/release.yaml derives both fields from // a bumped versionName into main triggers .gitea/workflows/release.yaml,
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH // which builds this version and then creates the matching vX.Y.Z tag +
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// default; keep them matching the latest released tag. See docs/RELEASING.md. // PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
versionCode = 200 // as a pre-release while MAJOR is 0. See docs/RELEASING.md.
versionName = "0.2.0" versionCode = 302
versionName = "0.3.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -47,6 +48,11 @@ android {
buildTypes { buildTypes {
release { release {
// Keep release builds reproducible for F-Droid: don't let AGP embed
// build-environment git metadata (META-INF/version-control-info.textproto),
// whose `revision`/path content varies by build machine and is the only
// thing that otherwise differs from a clean from-source rebuild.
vcsInfo { include = false }
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
proguardFiles( proguardFiles(
@@ -61,6 +67,22 @@ android {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
isMinifyEnabled = false isMinifyEnabled = false
} }
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
// and resource shrinking, but debug-signed and given its own applicationId
// suffix so it installs alongside both the production app (signed with the
// real key) and the debug build. Used to smoke-test a release candidate on
// a real device before merging to main — R8-only breakage and first-run/
// permission states don't surface in the unminified debug build, nor on a
// device that already holds the permission. Never published. See
// docs/RELEASING.md.
create("releaseTest") {
initWith(getByName("release"))
applicationIdSuffix = ".releasetest"
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
matchingFallbacks += "release"
}
} }
compileOptions { compileOptions {
@@ -73,6 +95,16 @@ android {
buildConfig = true buildConfig = true
} }
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
// a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any
// "extra signing block" — so leaving it in blocks publishing to the official
// repo. It lives in the signing block, not the zip entries, so disabling it
// doesn't change the build output (reproducibility is unaffected).
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
packaging { packaging {
resources { resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"

View File

@@ -2,5 +2,20 @@
-keep class dagger.hilt.** { *; } -keep class dagger.hilt.** { *; }
-keep @dagger.hilt.android.HiltAndroidApp class * -keep @dagger.hilt.android.HiltAndroidApp class *
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
# nothing calls it directly Room then throws InstantiationException, reported
# as "Failed to create an instance of ...". We pull Room in transitively via
# Glance -> WorkManager, whose WorkDatabase is built by WorkManagerInitializer
# at startup, so the app died on launch in every minified build (issue #1).
-keep class * extends androidx.room.RoomDatabase { <init>(); }
# WorkManager likewise looks its workers up by name and calls this constructor
# reflectively same pruning, but it only bites once a worker actually runs
# (Glance's widget updates), so keep it explicitly rather than wait for it.
-keep class * extends androidx.work.ListenableWorker {
<init>(android.content.Context, androidx.work.WorkerParameters);
}
# Compose Compiler may keep its own; defaults are fine # Compose Compiler may keep its own; defaults are fine
-dontwarn org.jetbrains.annotations.** -dontwarn org.jetbrains.annotations.**

View File

@@ -31,8 +31,10 @@ import androidx.graphics.shapes.RoundedPolygon
* its own [shape] and [containerColor], so a row of them reads as a set of * its own [shape] and [containerColor], so a row of them reads as a set of
* distinct little tokens rather than identical grey glyphs. * distinct little tokens rather than identical grey glyphs.
* *
* Pressing springs the icon down a touch and gives it a small turn — a light * Pressing springs the whole shape down and gives it a turn — the scalloped
* expressive flourish, no library motion APIs needed. * container is what dips and spins, while the glyph inside stays upright (a
* spinning magnifier or list icon would just read as wrong). [spinIcon] opts a
* glyph into turning too, for icons that read well mid-spin like the gear.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -46,15 +48,25 @@ fun ShapedActionButton(
contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer, contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer,
size: Dp = 40.dp, size: Dp = 40.dp,
iconSize: Dp = 22.dp, iconSize: Dp = 22.dp,
spinIcon: Boolean = false,
) { ) {
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState() val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.88f else 1f, label = "actionScale") // The shape (the scalloped cookie) turns and dips on press.
val rotation by animateFloatAsState(if (pressed) 24f else 0f, label = "actionRotation") val shapeRotation by animateFloatAsState(if (pressed) 40f else 0f, label = "shapeRotation")
val scale by animateFloatAsState(if (pressed) 0.82f else 1f, label = "shapeScale")
// The glyph's *net* turn: 0 keeps it upright, spinIcon gives it a quarter turn.
val iconRotation by animateFloatAsState(
if (pressed && spinIcon) 90f else 0f,
label = "iconRotation",
)
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier.size(size), modifier = modifier
.size(size)
.scale(scale)
.rotate(shapeRotation),
shape = shape.toShape(), shape = shape.toShape(),
color = containerColor, color = containerColor,
contentColor = contentColor, contentColor = contentColor,
@@ -64,10 +76,11 @@ fun ShapedActionButton(
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = contentDescription, contentDescription = contentDescription,
// Counter the container's turn so the glyph's net rotation is just
// [iconRotation] — upright by default, a quarter turn for the gear.
modifier = Modifier modifier = Modifier
.size(iconSize) .size(iconSize)
.scale(scale) .rotate(iconRotation - shapeRotation),
.rotate(rotation),
) )
} }
} }
@@ -82,4 +95,7 @@ fun ShapedActionButton(
object ActionShapes { object ActionShapes {
/** Settings — a 4-sided cookie (rounded, scalloped square). */ /** Settings — a 4-sided cookie (rounded, scalloped square). */
val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided
/** Search — a 6-sided cookie, the same family as [Settings] but distinct. */
val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided
} }

View File

@@ -1,8 +1,16 @@
package de.jeanlucmakiola.agendula.ui.lists package de.jeanlucmakiola.agendula.ui.lists
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -12,96 +20,152 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ListAlt import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.Upcoming import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.common.ActionShapes import de.jeanlucmakiola.agendula.ui.common.ActionShapes
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
import java.time.LocalDate
import java.time.ZoneId
/** /**
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live * Home: a Today progress hero (wavy ring over tasks due today), an Overdue + All
* counts, then the user's lists grouped by account. Tapping a card or row opens * 2-up of tonal tiles, a live preview of the next upcoming tasks, then the user's
* that task list; the FAB starts a new task. * lists grouped by account. Tapping a tile opens that smart list, an upcoming row
* opens that task, and the FAB starts a new task. The search action in the top bar
* slides open into a full-width field that filters every task by title.
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ListsScreen( fun ListsScreen(
onOpenFilter: (TaskFilter) -> Unit, onOpenFilter: (TaskFilter) -> Unit,
onOpenTask: (Long) -> Unit,
onNewTask: () -> Unit, onNewTask: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ListsViewModel = hiltViewModel(), viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() var query by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
val closeSearch = {
query = ""
searchActive = false
}
// System back closes search before leaving the screen.
BackHandler(enabled = searchActive, onBack = closeSearch)
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier,
topBar = { topBar = {
MediumTopAppBar( HomeTopBar(
title = { Text(stringResource(R.string.app_name)) }, searchActive = searchActive,
actions = { query = query,
ShapedActionButton( onQueryChange = { query = it },
shape = ActionShapes.Settings, onToggleSearch = { if (searchActive) closeSearch() else searchActive = true },
icon = Icons.Rounded.Settings, onOpenSettings = onOpenSettings,
contentDescription = stringResource(R.string.settings_title),
onClick = onOpenSettings,
modifier = Modifier.padding(end = 8.dp),
size = 48.dp,
iconSize = 26.dp,
)
},
scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
// The FAB would otherwise float over the search results.
if (!searchActive) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = onNewTask, onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = { Text(stringResource(R.string.new_task)) }, text = { Text(stringResource(R.string.new_task)) },
) )
}
}, },
) { inner -> ) { inner ->
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
when (val s = state) { when (val s = state) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
ListsUiState.Failure -> CenteredMessage(stringResource(R.string.lists_failure), inner) ListsUiState.Failure ->
is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter) CenteredMessage(stringResource(R.string.lists_failure), PaddingValues(0.dp))
is ListsUiState.Content -> {
ListsContent(
state = s,
onOpenFilter = onOpenFilter,
onOpenTask = onOpenTask,
topPadding = 0.dp,
bottomPadding = inner.calculateBottomPadding() + 96.dp,
)
// While searching with a non-blank query, results cover the home
// content; an empty query leaves the home content visible behind
// the open field.
if (searchActive) {
SearchResults(
query = query,
allTasks = s.allTasks,
onOpenTask = { taskId ->
closeSearch()
onOpenTask(taskId)
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
} }
} }
} }
@@ -109,17 +173,46 @@ fun ListsScreen(
@Composable @Composable
private fun ListsContent( private fun ListsContent(
state: ListsUiState.Content, state: ListsUiState.Content,
inner: PaddingValues,
onOpenFilter: (TaskFilter) -> Unit, onOpenFilter: (TaskFilter) -> Unit,
onOpenTask: (Long) -> Unit,
topPadding: androidx.compose.ui.unit.Dp,
bottomPadding: androidx.compose.ui.unit.Dp,
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = inner.calculateTopPadding(), top = topPadding,
bottom = inner.calculateBottomPadding() + 96.dp, bottom = bottomPadding,
), ),
) { ) {
item { SmartGrid(state.smartCounts, onOpenFilter) } item {
TodayHero(
done = state.todayDone,
total = state.todayTotal,
onClick = { onOpenFilter(TaskFilter.Smart(SmartList.TODAY)) },
)
}
// Overdue + All sit below the Today hero as a quieter 2-up; Upcoming is no
// longer a count tile — it becomes the live preview further down.
item {
SmartPairRow(
counts = state.smartCounts.filter {
it.smart == SmartList.OVERDUE || it.smart == SmartList.ALL
},
onOpenFilter = onOpenFilter,
)
}
if (state.upcoming.isNotEmpty()) {
item { SectionHeader(stringResource(R.string.smart_upcoming)) }
item {
UpcomingPreview(
tasks = state.upcoming,
onOpenTask = onOpenTask,
onViewAll = { onOpenFilter(TaskFilter.Smart(SmartList.UPCOMING)) },
)
}
}
if (state.groups.isEmpty()) { if (state.groups.isEmpty()) {
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) } item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
@@ -150,25 +243,370 @@ private fun ListsContent(
} }
} }
/**
* The home top bar. There is no title — the launcher icon already says which app
* this is. Settings is pinned at the right; the search action sits just left of it
* and stays put. Tapping search unfurls a pill to its left (the field grows out
* from the icon's side) while the search icon remains as the bar's fixed trailing
* icon, so neither action moves.
*/
@Composable @Composable
private fun SmartGrid(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) { private fun HomeTopBar(
Column( searchActive: Boolean,
modifier = Modifier.padding(horizontal = 16.dp), query: String,
verticalArrangement = Arrangement.spacedBy(8.dp), onQueryChange: (String) -> Unit,
onToggleSearch: () -> Unit,
onOpenSettings: () -> Unit,
) {
Surface(color = MaterialTheme.colorScheme.surface) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.height(72.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
counts.chunked(2).forEach { row -> // Search occupies the flexible space left of settings. Pulled into its
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { // own composable so AnimatedVisibility resolves to the plain overload —
row.forEach { smart -> // a RowScope receiver in here would make that call ambiguous.
SearchSlot(
searchActive = searchActive,
query = query,
onQueryChange = onQueryChange,
onToggleSearch = onToggleSearch,
modifier = Modifier.weight(1f),
)
ShapedActionButton(
shape = ActionShapes.Settings,
icon = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
onClick = onOpenSettings,
modifier = Modifier.padding(start = 8.dp),
size = 48.dp,
iconSize = 26.dp,
spinIcon = true,
)
}
}
}
/**
* The search action and the field it unfurls. The magnifier is pinned to the end
* (right) of this slot and never moves; tapping it toggles search. When active a
* pill expands leftward from it ([expandHorizontally] anchored at the end) holding
* the query field, so the icon reads as the bar's fixed trailing icon.
*/
@Composable
private fun SearchSlot(
searchActive: Boolean,
query: String,
onQueryChange: (String) -> Unit,
onToggleSearch: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier, contentAlignment = Alignment.CenterEnd) {
AnimatedVisibility(
visible = searchActive,
enter = expandHorizontally(tween(300), expandFrom = Alignment.End) + fadeIn(tween(280)),
exit = shrinkHorizontally(tween(220), shrinkTowards = Alignment.End) + fadeOut(tween(140)),
) {
SearchPill(
query = query,
onQueryChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
)
}
// Always on top, at the end — the bar's fixed search icon and toggle.
ShapedActionButton(
shape = ActionShapes.Search,
icon = Icons.Rounded.Search,
contentDescription = stringResource(R.string.home_search_hint),
onClick = onToggleSearch,
size = 48.dp,
iconSize = 26.dp,
)
}
}
/**
* The expanding search input pill: the query field (auto-focused on open) and a
* clear button once there is text. Its trailing 48dp is left empty for the search
* icon that [SearchSlot] overlays at the end.
*/
@Composable
private fun SearchPill(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.height(52.dp),
) {
Row(
modifier = Modifier.padding(start = 18.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(),
modifier = Modifier.weight(1f).focusRequester(focusRequester),
decorationBox = { innerField ->
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty()) {
Text(
stringResource(R.string.home_search_hint),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
innerField()
}
},
)
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
Icons.Rounded.Close,
contentDescription = stringResource(R.string.home_search_clear),
)
}
}
// Space reserved for the search icon SearchSlot overlays at the end.
Spacer(Modifier.width(48.dp))
}
}
}
/**
* Search results overlaying the home content: tasks whose title matches the query,
* across every list (completed included), open ones first. A blank query renders
* nothing so the home content shows through behind the open field.
*/
@Composable
private fun SearchResults(
query: String,
allTasks: List<Task>,
onOpenTask: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
if (query.isBlank()) return
val results = remember(query, allTasks) {
val q = query.trim()
allTasks
.filter { it.title.contains(q, ignoreCase = true) }
.sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() }))
}
Surface(modifier = modifier, color = MaterialTheme.colorScheme.surface) {
if (results.isEmpty()) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.TopCenter) {
Text(
text = stringResource(R.string.home_search_empty, query.trim()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(results, key = { it.id }) { task ->
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
}
}
}
}
}
/**
* The day's momentum: a wavy progress ring over "x of y done" for tasks due today.
* Tapping opens the Today list. When nothing is due today it drops the ring and
* reads as a calm, finished state rather than an empty 0.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun TodayHero(done: Int, total: Int, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val corner by animateDpAsState(if (pressed) 34.dp else 22.dp, label = "todayCorner")
val left = total - done
Surface(
onClick = onClick,
shape = RoundedCornerShape(corner),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
interactionSource = interaction,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(140.dp),
) {
Row(
modifier = Modifier.fillMaxSize().padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Rounded.Today, contentDescription = null, modifier = Modifier.size(20.dp))
Text(stringResource(R.string.smart_today), style = MaterialTheme.typography.titleMedium)
}
val headline = when {
total == 0 -> stringResource(R.string.home_today_empty)
left == 0 -> stringResource(R.string.home_today_all_done)
else -> stringResource(R.string.home_today_progress, done, total)
}
Text(headline, style = MaterialTheme.typography.headlineSmall)
if (total > 0 && left > 0) {
Text(
stringResource(R.string.home_today_remaining, left),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f),
)
}
}
if (total > 0) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(84.dp)) {
CircularWavyProgressIndicator(
progress = { done.toFloat() / total },
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.onPrimaryContainer,
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.22f),
)
Text("$done/$total", style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
/** Overdue + All as a 2-up row of the existing tonal tiles. */
@Composable
private fun SmartPairRow(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
counts.forEach { smart ->
SmartCard( SmartCard(
count = smart, count = smart,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) }, onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) },
) )
} }
if (row.size == 1) Spacer(Modifier.weight(1f)) if (counts.size == 1) Spacer(Modifier.weight(1f))
}
}
/** A grouped card previewing the next few upcoming tasks, with a "view all" tail. */
@Composable
private fun UpcomingPreview(
tasks: List<Task>,
onOpenTask: (Long) -> Unit,
onViewAll: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Column {
tasks.forEach { task ->
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
}
Surface(onClick = onViewAll, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.home_upcoming_view_all),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.weight(1f))
Icon(
Icons.Rounded.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
} }
} }
} }
}
}
/**
* One slim upcoming row: list-colour avatar, title, then a quiet meta line of the
* relative due date and (if set) a tinted priority flag — the same calm one-line
* treatment as the task list, minus the swipe machinery.
*/
@Composable
private fun UpcomingRow(task: Task, onClick: () -> Unit) {
val dark = isSystemInDarkTheme()
val muted = MaterialTheme.colorScheme.onSurfaceVariant
val metaStyle = MaterialTheme.typography.bodySmall
Surface(onClick = onClick, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ListColorChip(task.effectiveColor)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
task.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
upcomingDueLabel(task)?.let { Text(it, style = metaStyle, color = muted) }
if (task.priority != Priority.NONE) {
Text("·", style = metaStyle, color = muted)
Icon(
Icons.Rounded.Flag,
contentDescription = null,
tint = priorityAccent(task.priority, dark),
modifier = Modifier.size(13.dp),
)
Text(priorityLabel(task.priority), style = metaStyle, color = muted)
}
}
}
}
}
}
/** "Today" / "Tomorrow" for the near dates, else the compact date. */
@Composable
private fun upcomingDueLabel(task: Task): String? {
val due = task.due ?: return null
val zone = remember { ZoneId.systemDefault() }
val today = remember { LocalDate.now(zone) }
val dueDate = remember(due) {
java.time.Instant.ofEpochMilli(due.toEpochMilliseconds()).atZone(zone).toLocalDate()
}
return when (dueDate) {
today -> stringResource(R.string.home_due_today)
today.plusDays(1) -> stringResource(R.string.home_due_tomorrow)
else -> due.formatDateTimeCompact(task.isAllDay)
}
} }
private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color) private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color)

View File

@@ -29,9 +29,18 @@ sealed interface ListsUiState {
data class Content( data class Content(
val smartCounts: List<SmartCount>, val smartCounts: List<SmartCount>,
val groups: List<AccountGroup>, val groups: List<AccountGroup>,
/** Completed vs. total tasks *due today* — drives the progress ring. */
val todayDone: Int,
val todayTotal: Int,
/** The next few open tasks (due tomorrow onward) for the inline preview. */
val upcoming: List<Task>,
/** Every task (open and completed) — the corpus the home search filters. */
val allTasks: List<Task>,
) : ListsUiState ) : ListsUiState
} }
private const val UPCOMING_PREVIEW = 3
/** The home overview: smart lists with live counts, then user lists by account. */ /** The home overview: smart lists with live counts, then user lists by account. */
@HiltViewModel @HiltViewModel
class ListsViewModel @Inject constructor( class ListsViewModel @Inject constructor(
@@ -42,12 +51,19 @@ class ListsViewModel @Inject constructor(
combine( combine(
repository.taskLists(), repository.taskLists(),
repository.tasks(TaskFilter.Smart(SmartList.ALL)), repository.tasks(TaskFilter.Smart(SmartList.ALL)),
) { lists, openTasks -> // Open smart lists drop completed tasks, but the Today ring needs the
buildContent(lists, openTasks) as ListsUiState // ones already ticked off to show "x of y done", so read them too.
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
) { lists, openTasks, completedTasks ->
buildContent(lists, openTasks, completedTasks) as ListsUiState
}.catch { emit(ListsUiState.Failure) } }.catch { emit(ListsUiState.Failure) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
private fun buildContent(lists: List<TaskList>, openTasks: List<Task>): ListsUiState.Content { private fun buildContent(
lists: List<TaskList>,
openTasks: List<Task>,
completedTasks: List<Task>,
): ListsUiState.Content {
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
// Count only top-level tasks: a subtask is represented by its parent (and // Count only top-level tasks: a subtask is represented by its parent (and
// its progress chip), and an open subtask under a *completed* parent must // its progress chip), and an open subtask under a *completed* parent must
@@ -62,6 +78,21 @@ class ListsViewModel @Inject constructor(
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)), SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
SmartCount(SmartList.ALL, topLevel.size), SmartCount(SmartList.ALL, topLevel.size),
) )
// Today ring: completed vs. total tasks *due today*. The numerator is the
// top-level completed tasks whose due date falls in today's window; the
// denominator adds the still-open ones (the Today smart count above).
val openToday = count(SmartList.TODAY)
val completedDueToday = completedTasks.count {
!it.isSubtask && it.due != null && it.due >= todayStart && it.due < todayEnd
}
val todayTotal = openToday + completedDueToday
// Upcoming preview: the next handful of open tasks due tomorrow onward,
// already sorted by the repository's default ordering.
val upcoming = topLevel
.filter { TaskFiltering.matches(it, TaskFilter.Smart(SmartList.UPCOMING), todayStart, todayEnd) }
.take(UPCOMING_PREVIEW)
val openByList = topLevel.groupingBy { it.listId }.eachCount() val openByList = topLevel.groupingBy { it.listId }.eachCount()
val groups = lists val groups = lists
.groupBy { it.accountName } .groupBy { it.accountName }
@@ -72,6 +103,13 @@ class ListsViewModel @Inject constructor(
) )
} }
.sortedBy { it.accountName.lowercase() } .sortedBy { it.accountName.lowercase() }
return ListsUiState.Content(smartCounts, groups) return ListsUiState.Content(
smartCounts = smartCounts,
groups = groups,
todayDone = completedDueToday,
todayTotal = todayTotal,
upcoming = upcoming,
allTasks = openTasks + completedTasks,
)
} }
} }

View File

@@ -52,6 +52,7 @@ fun AgendulaNavHost(modifier: Modifier = Modifier) {
composable(Dest.LISTS) { composable(Dest.LISTS) {
ListsScreen( ListsScreen(
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) }, onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) },
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) }, onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
onOpenSettings = { nav.navigate(Dest.SETTINGS) }, onOpenSettings = { nav.navigate(Dest.SETTINGS) },
) )

View File

@@ -1,22 +1,89 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Agendula launcher icon foreground — PLACEHOLDER. Agendula launcher icon foreground.
A simple rounded check mark inside the 108dp adaptive-icon canvas Converted from design/icon/agendula_icon.svg (512x512 viewport): a rounded
(72dp safe zone). Deliberately not Calendula's calendar mark, so the two line-art task card with a check mark, plus a small Calendula bloom badge in
apps never look alike. Replace with real branding (a stylized agendula) the bottom-right corner — the sibling of Calendula's calendar mark.
when the design lands — see docs/PLAN.md §9.
Strokes render in off-white (#FAF6F0) over the plum background drawable
(drawable/ic_launcher_background.xml = @color/ic_launcher_background, the
hue-rotated counterpart of Calendula's slate). The same vector fills the
<monochrome> slot so Android 13+ themed-icon launchers can recolour it.
Centering / scale:
- Scale 0.66 about the canvas centre (matching Calendula's ~2.8dp stroke
weight and generous padding).
- The eye centres on the task CARD, not the card+bloom bounding box (the
bloom is a small badge that overhangs the bottom-right). So vertically we
centre the card itself: its geometric centre is y=242.76, so we pivot the
Y-scale there and translate +13.24 to drop that centre onto the canvas
centre (256). Horizontally the card already sits centred, so X scales
about 256 untouched.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="512"
android:viewportHeight="108"> android:viewportHeight="512">
<group
android:pivotX="256"
android:pivotY="242.76"
android:scaleX="0.66"
android:scaleY="0.66"
android:translateY="13.24">
<!-- Task card (rounded square, opening at the bottom-right for the bloom) -->
<path <path
android:fillColor="#00000000" android:strokeColor="#FFFAF6F0"
android:strokeColor="#FFFFFF" android:strokeWidth="20"
android:strokeWidth="9" android:strokeMiterLimit="12"
android:strokeLineCap="round" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeLineJoin="round"
android:pathData="M36,55 l13,13 l25,-27" /> android:pathData="M370.207 254.345V168.276C370.207 144.508 350.939 125.241 327.172 125.241H178.207C154.439 125.241 135.172 144.508 135.172 168.276V317.241C135.172 341.008 154.439 360.276 178.207 360.276H269.241" />
<!-- Check mark -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="20"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M193 245.569L231.822 287L320 199" />
<!-- Calendula bloom: petals around the centre -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M346.672 323.721C356.301 323.721 364.107 312.367 364.107 298.361C364.107 284.354 356.301 273 346.672 273C337.042 273 329.236 284.354 329.236 298.361C329.236 312.367 337.042 323.721 346.672 323.721Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M355.716 330.293C358.692 339.451 371.903 343.366 385.224 339.038C398.544 334.71 406.931 323.777 403.955 314.619C400.98 305.461 387.769 301.546 374.448 305.874C361.127 310.202 352.741 321.135 355.716 330.293Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M352.262 340.926C344.471 346.586 344.83 360.36 353.063 371.691C361.295 383.022 374.284 387.62 382.075 381.96C389.865 376.3 389.506 362.526 381.274 351.194C373.041 339.863 360.052 335.266 352.262 340.926Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M341.082 340.926C333.291 335.266 320.302 339.863 312.069 351.194C303.837 362.526 303.478 376.3 311.268 381.96C319.059 387.62 332.048 383.022 340.28 371.691C348.513 360.36 348.872 346.585 341.082 340.926Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M337.627 330.293C340.602 321.135 332.216 310.202 318.895 305.874C305.574 301.546 292.363 305.461 289.388 314.619C286.412 323.777 294.799 334.71 308.119 339.038C321.44 343.366 334.651 339.451 337.627 330.293Z" />
<!-- Bloom centre -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M346.672 342.742C351.924 342.742 356.182 338.484 356.182 333.232C356.182 327.979 351.924 323.721 346.672 323.721C341.419 323.721 337.161 327.979 337.161 333.232C337.161 338.484 341.419 342.742 346.672 342.742Z" />
</group>
</vector> </vector>

View File

@@ -129,6 +129,23 @@
<string name="smart_all">All</string> <string name="smart_all">All</string>
<string name="open_count">%1$d open</string> <string name="open_count">%1$d open</string>
<!-- Home: Today progress hero -->
<string name="home_today_progress">%1$d of %2$d done</string>
<string name="home_today_remaining">%1$d left</string>
<string name="home_today_all_done">All done 🎉</string>
<string name="home_today_empty">Nothing due today 🎉</string>
<!-- Home: Upcoming preview -->
<string name="home_upcoming_view_all">View all</string>
<string name="home_due_today">Today</string>
<string name="home_due_tomorrow">Tomorrow</string>
<!-- Home: search -->
<string name="home_search_hint">Search tasks</string>
<string name="home_search_clear">Clear search</string>
<string name="home_search_close">Close search</string>
<string name="home_search_empty">No tasks match “%1$s”</string>
<!-- Reminders --> <!-- Reminders -->
<string name="reminder_due_at">Due %1$s</string> <string name="reminder_due_at">Due %1$s</string>
<string name="reminder_channel_name">Task reminders</string> <string name="reminder_channel_name">Task reminders</string>

View File

@@ -0,0 +1,11 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" fill="white"/>
<path d="M370.207 254.345V168.276C370.207 144.508 350.939 125.241 327.172 125.241H178.207C154.439 125.241 135.172 144.508 135.172 168.276V317.241C135.172 341.008 154.439 360.276 178.207 360.276H269.241" stroke="black" stroke-width="20" stroke-miterlimit="12" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M193 245.569L231.822 287L320 199" stroke="black" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M346.672 323.721C356.301 323.721 364.107 312.367 364.107 298.361C364.107 284.354 356.301 273 346.672 273C337.042 273 329.236 284.354 329.236 298.361C329.236 312.367 337.042 323.721 346.672 323.721Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M355.716 330.293C358.692 339.451 371.903 343.366 385.224 339.038C398.544 334.71 406.931 323.777 403.955 314.619C400.98 305.461 387.769 301.546 374.448 305.874C361.127 310.202 352.741 321.135 355.716 330.293Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M352.262 340.926C344.471 346.586 344.83 360.36 353.063 371.691C361.295 383.022 374.284 387.62 382.075 381.96C389.865 376.3 389.506 362.526 381.274 351.194C373.041 339.863 360.052 335.266 352.262 340.926Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M341.082 340.926C333.291 335.266 320.302 339.863 312.069 351.194C303.837 362.526 303.478 376.3 311.268 381.96C319.059 387.62 332.048 383.022 340.28 371.691C348.513 360.36 348.872 346.585 341.082 340.926Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M337.627 330.293C340.602 321.135 332.216 310.202 318.895 305.874C305.574 301.546 292.363 305.461 289.388 314.619C286.412 323.777 294.799 334.71 308.119 339.038C321.44 343.366 334.651 339.451 337.627 330.293Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M346.672 342.742C351.924 342.742 356.182 338.484 356.182 333.232C356.182 327.979 351.924 323.721 346.672 323.721C341.419 323.721 337.161 327.979 337.161 333.232C337.161 338.484 341.419 342.742 346.672 342.742Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -243,7 +243,7 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
| UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) | | UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) |
| Other | DataStore, kotlinx-datetime, kotlinx-coroutines | | Other | DataStore, kotlinx-datetime, kotlinx-coroutines |
| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam | | Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam |
| Versioning | git tag is the source of truth; `versionCode = MAJOR*10000 + MINOR*100 + PATCH`, derived in CI at release. See [`RELEASING.md`](RELEASING.md). | | Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). |
| CI | Gitea workflows (`.gitea/workflows/ci.yaml`, `release.yaml`) | | CI | Gitea workflows (`.gitea/workflows/ci.yaml`, `release.yaml`) |
| Distribution | F-Droid (`fdroid-metadata/`) | | Distribution | F-Droid (`fdroid-metadata/`) |

View File

@@ -1,101 +1,163 @@
# Agendula — releasing # Agendula — releasing
Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) with a Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) with
human-readable **Gitea release** per tag. Both are produced automatically by a human-readable **Gitea release** per version. Both are produced automatically
`.gitea/workflows/release.yaml` when you push a tag. There are no APK assets on by `.gitea/workflows/release.yaml` when a **bumped `versionName` reaches `main`**
the Gitea release itself — distribution lives in the F-Droid repo; the release is the pipeline builds and publishes that version, then creates the matching
the changelog of record. `vX.Y.Z` tag and Gitea release itself. There are no APK assets on the Gitea
release: distribution lives in the F-Droid repo; the release is the changelog of
record.
While Agendula is pre-1.0 (`versionName` starts with `0.`), every Gitea release
is flagged as a **pre-release**. This happens automatically and graduates to a
stable release at `1.0.0` — no manual toggling.
--- ---
## The one source of truth: the git tag ## The source of truth: the committed version
The git tag drives the version. You do **not** hand-edit version numbers for a A release is defined by the `versionName`/`versionCode` committed in
release — CI substitutes them from the tag: `app/build.gradle.kts`**not** by a hand-pushed tag:
- `versionName` = the tag without a leading `v` (e.g. `v0.2.0` `0.2.0`). - `versionName` = `MAJOR.MINOR.PATCH` (e.g. `0.2.0`)
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (e.g. `0.2.0``200`, - `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`0.2.0``200`,
`1.3.4``10304`). `1.3.4``10304`)
The values committed in `app/build.gradle.kts` are just the local/dev default; So `MINOR` and `PATCH` each have room for 099. The release pipeline reads
keep them roughly matching the latest released tag, but the tag wins at release `versionName`, pins `versionCode` to the derived value, builds, publishes, and —
time. 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).
--- ---
## Cutting a release ## Cutting a release
1. **Update `CHANGELOG.md`.** Move items out of `[Unreleased]` into a new 1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
`## [X.Y.Z]` section. The release pipeline extracts everything between feature/fix branches for this release into it. Everything below happens on
`## [X.Y.Z]` and the next `## [` heading and uses it verbatim as both the that branch, before it reaches `main`.
Gitea release notes and the F-Droid per-version "What's New" 2. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items under a new
(`changelogs/<versionCode>.txt`). If no matching section exists, a fallback `## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading
line is used — so the heading **must** match the tag's version exactly. and the next `## [` becomes both the Gitea release notes and the F-Droid
2. **Commit** the changelog on `main`. per-version "What's New". The heading **must** match the version exactly.
3. **Tag and push:** 3. **Bump the committed `versionName`** (and `versionCode`) in
`app/build.gradle.kts`. **This bump is what triggers the release** when the
branch merges to `main`. Then run
```sh ```sh
git tag v0.2.0 scripts/sync_changelog_to_fastlane.sh
git push origin v0.2.0
``` ```
4. CI takes over (see below). Watch the run in Gitea Actions. and commit the generated
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` — this is what
makes the **official** F-Droid listing (which harvests the changelog from the
tagged source tree) show this version. The self-hosted pipeline regenerates it
regardless, so forgetting only affects the official listing.
4. **Verify the release build on a real device** — the mandatory gate:
```sh
scripts/verify-release.sh
```
It builds the `releaseTest` variant (same R8 config as `release`, debug-signed
with a `.releasetest` suffix so it installs alongside the real app) and resets
it to a first-run state. Then, on the device:
- launch from a **clean / permission-not-granted** state — the permission
screen must appear, no crash;
- grant tasks access — the task list must load;
- create a task with a due reminder and confirm the notification fires;
- exercise this release's headline changes.
Only proceed once all of that passes on-device.
5. **Merge `release/vX.Y.Z` into `main`.** That's it — no manual tagging. The
merge triggers `release.yaml`, which detects the new version, builds, signs,
publishes to F-Droid, and creates the `vX.Y.Z` tag + Gitea release.
> The `releaseTest` build type exists only for step 4 — it is never published.
> The pipeline always builds and signs the real `release` variant.
--- ---
## What CI does on a tag ## What the pipeline does
`release.yaml` runs three jobs: CI and release are split so a change is built once on its PR and only does
release work when a merge actually cuts a release:
| Job | Purpose | - **`ci.yaml`** (on `pull_request`) — the reproducible-release invariant guard
|---|---| (`scripts/check_reproducible_release.sh`), then lint + unit tests + a debug
| `ci` | Sanity gate: unit tests + a debug build (catches version-substitution drift). The other jobs depend on this. | assemble and a Trivy scan, once per PR. Docs/metadata-only PRs skip the Android
| `build-and-deploy` | Substitute version from the tag → build signed release APK → fetch the existing F-Droid repo from Hetzner → add the new APK + per-version changelog → `fdroid update -c` → upload `repo/` + `metadata/` back. Also attaches the R8 `mapping.txt.gz` to the Gitea release (best-effort) so crash stacktraces stay deobfuscatable. | build but still report a green `CI` check.
| `gitea-release` | Create/update the Gitea release for the tag, body = the extracted CHANGELOG section. Gated on `ci` only (not deploy), so notes still publish if the F-Droid upload hiccups. | - **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap
`detect` job reads `versionName` and checks whether a tag for it already
exists. Only when it doesn't does the `release` job run: unit tests on the
merged commit, pin `versionCode`, build & sign the release APK with the **app
key**, copy it into the F-Droid repo, generate the per-version changelog from
the fastlane tree, re-sign the index with the **repo key**, upload `repo/` +
`metadata/`, then create the `vX.Y.Z` tag + Gitea release (CHANGELOG section as
notes, flagged pre-release while `MAJOR` is 0), attach the R8 `mapping.txt`,
and mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
(both best-effort). Ordinary merges with no version bump fall through `detect`
and do nothing.
Both deploy and release steps are **re-run safe** (idempotent upserts), so a ### Codeberg direct-download channel
failed run can be retried.
Alongside F-Droid, each release is mirrored to the Codeberg repo
(`jlmakiola/agendula`) as a plain download for users who don't want F-Droid.
Gitea already **push-mirrors** branches and tags to Codeberg, but releases
aren't git objects and don't sync, so the pipeline 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.
The step is best-effort: a Codeberg outage never fails an already-published
F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset.
One-time setup: the Codeberg repo's **Releases** unit must be enabled and a
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope)
added to Gitea Actions.
### Manual re-sign / recovery
A manual `workflow_dispatch` of the release workflow runs a **re-sign-only**
path: `detect` reports it's not a release, so the `release` job skips the APK
build, the version pin, and tag/release creation, and just re-signs the existing
F-Droid index with the configured repo key and re-uploads. Use this for key
rotation or repo recovery without publishing a new app version.
--- ---
## Required CI secrets ## Secrets (Gitea → repo Settings → Actions → Secrets)
Configured in the Gitea repo settings; the workflow fails loudly if the F-Droid The workflow fails loudly if the F-Droid ones are missing — it will **never**
ones are missing (it will **never** auto-generate a repo key that would rotate auto-generate a repo key (that would rotate the repo fingerprint and break every
the repo fingerprint and break every user's pinned repo). user's pinned repo).
| Secret | Used for | | Secret | Purpose |
|---|---| | --- | --- |
| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | App signing keystore (the APK). | | `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | **App** signing key — signs the APK. Losing it means existing installs can't be updated. |
| `FDROID_KEYSTORE_BASE64`, `FDROID_CONFIG_BASE64` | The F-Droid **repo** signing key + `config.yml`. Never uploaded to the server. | | `FDROID_KEYSTORE_BASE64` | **F-Droid repo** signing key (`keystore.p12`, base64). Signs the repo index. |
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | SFTP target for the published `repo/` + `metadata/`. | | `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. |
| `GITHUB_TOKEN` | Gitea API (release create/patch, asset upload). | | `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. |
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — creates the mirrored Codeberg release + uploads the APK/checksum. Best-effort; if unset the Codeberg step skips. |
The repo signing key and `config.yml` come from secrets at build time and are The app key signs APKs; the repo key signs the index (its fingerprint is what
**never** pulled from or pushed back to the server, so they can't leak into the users pin). Neither key nor `config.yml` is ever uploaded to the server they
web-served tree (nginx serves only `repo/`). live only in CI secrets and are reconstructed in-runner (nginx serves only
`repo/`).
--- ---
## Key rotation / repo recovery ## F-Droid metadata (single source of truth)
A manual `workflow_dispatch` run (ref = a branch, not a tag) skips all the Store-listing text lives in **`fastlane/metadata/android/<locale>/`** — the same
tag-only build steps: it just re-signs the existing index with the configured tree the official F-Droid repo harvests from source. At release time
repo key and re-uploads. Use this to recover the repo or rotate infrastructure `scripts/fastlane_to_fdroid_localized.sh` transforms it into the F-Droid repo's
**without** publishing a new APK. "localized" layout, so there is no second copy to maintain. The app-level control
file (`Categories`/`License`/links) stays in
`fdroid-metadata/de.jeanlucmakiola.agendula.yml`. Per-version changelogs are
seeded into `fastlane/.../en-US/changelogs/<versionCode>.txt` by
`scripts/sync_changelog_to_fastlane.sh` (step 3 above) and carried across by the
transform.
--- ---
## Push CI (non-tag) ## Crash deobfuscation
Every push to any branch runs `.gitea/workflows/ci.yaml`: `lintDebug` → Each release attaches `mapping-<version>.txt.gz` (the R8 mapping) to its Gitea
`testDebugUnitTest` → `assembleDebug`, plus a Trivy filesystem scan on `main`. release. To deobfuscate a user stacktrace, download the mapping for that version
Keep this green before tagging. and run it through `retrace`.
---
## F-Droid metadata
App store listing lives in `fdroid-metadata/` (`de.jeanlucmakiola.agendula.yml`
plus `en-US/` summary/description). Per-version changelogs are generated into the
repo's `metadata/.../en-US/changelogs/<versionCode>.txt` from `CHANGELOG.md` at
release time; metadata is uploaded alongside `repo/` so changelog history
survives across releases.

View File

@@ -0,0 +1,30 @@
### Added
- M2 Material 3 Expressive UI — the app is fully navigable now: the
provider/permission onboarding gate, the lists overview (smart lists + user
lists grouped by account), and the task list (swipe-to-complete / -delete,
inline add, smart-list section headers), detail, and create/edit screens, all
wired to the M1 ViewModels.
- M3 detail/edit polish: a "Progress" slider (percent-complete, 5% detents,
written to `Tasks.PERCENT_COMPLETE`) and conflict-safe saves — `updateTask`
re-checks the provider's `last_modified` against the value captured when the
form loaded and surfaces an overwrite-or-cancel prompt instead of clobbering an
external change (e.g. a DAVx5 sync).
- M4 subtasks (UI): reparent — a full-width, searchable "Parent task" sheet on
the edit form, with candidates grouped by due-date section, files a task under
any active top-level task in its list (or "None" to promote it); switching list
clears the now-invalid parent. Tapping a subtask in the detail screen opens its
own detail, which shows a "Part of …" parent card. On the task list a parent has
a dedicated expand button that reveals its children as a nested grouped run,
ending with an inline "add a subtask" row (an opt-out toggle is planned for the
M6 settings screen).
- Priority is coloured by level (green / amber / red pastels) on the list, detail,
and edit screens.
### Fixed
- Overview open-counts now count top-level tasks only, so subtasks — and
especially open subtasks under a completed parent — no longer inflate a list's
"N open" or the smart-list counts.
- Swipe-to-delete now reveals its red background + icon as you drag (tracking the
live direction, not just the settled target), and a floating "Deleted · Undo"
chip defers the actual delete so it can be restored.

View File

@@ -0,0 +1,5 @@
### Added
- Releases are now also published to the Codeberg mirror as a direct download:
each release carries the signed APK plus a SHA-256 checksum, for users who
don't use F-Droid.

View File

@@ -0,0 +1,5 @@
### Fixed
- Release automation now reliably mirrors each release to the Codeberg mirror
(signed APK + SHA-256 checksum). The 0.2.1 attempt failed when the release tag
had already been synced to Codeberg.

View File

@@ -0,0 +1,14 @@
### Added
- Reminders: Agendula now delivers your due reminders itself. A one-time setup
step explains this and asks for notification access, and a master switch in
Settings turns the whole thing off again.
- A Settings screen, from the gear on the overview: appearance and theme, which
fields the task form shows, your default list, and reminder defaults.
- The overview leads with Today — a progress ring showing how much of today
you've finished — followed by a live preview of what's coming up next.
- Search across every task, open or completed, from the top bar.
- A proper app icon.
### Changed
- A tidier top bar: no app title, with search and settings pinned to the right.

View File

@@ -0,0 +1,5 @@
### Fixed
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
release build stripped a constructor that the background-work scheduler needs
to open its database, and that happens before the app draws anything.

View File

@@ -0,0 +1,6 @@
### Fixed
- Releases reach the Codeberg download channel again. 0.3.1 published to
F-Droid but never appeared on Codeberg, so if you install from there — or
through Obtainium — this is the release that finally carries 0.3.0's
launch-crash fix. The app itself is unchanged from 0.3.1.

View File

@@ -0,0 +1 @@
Agendula

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Reproducibility guard for the official F-Droid repo (de.jeanlucmakiola.agendula).
#
# F-Droid only republishes OUR signed binary if a from-source build reproduces it
# byte-for-byte and the binary carries no extra signing blocks. If any invariant
# below regresses, the official repo silently stalls on the last good version
# (fails safe — but you'd be stuck on an old release without noticing). So fail
# loudly here, on every PR.
#
# Each invariant guards against a known fdroiddata CI rejection cause (learned on
# the sibling Calendula repo's official-repo submission):
# 1. vcsInfo { include = false } — else AGP embeds env-dependent git
# metadata (META-INF/version-control-info.textproto) -> not reproducible.
# 2. no foojay toolchain resolver — F-Droid's offline source scanner
# rejects org.gradle.toolchains.foojay-resolver (it can fetch a JDK over
# the network at build time).
# 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency
# metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's
# binary scanner rejects as an extra signing block.
set -euo pipefail
APP="app/build.gradle.kts"
SETTINGS="settings.gradle.kts"
fail=0
# 1. AGP VCS-info must be disabled on the release build. -z reads the whole file
# as one record so the match can span newlines; [^}] keeps it inside the block.
if grep -Pzoq 'vcsInfo\s*\{[^}]*include\s*=\s*false' "$APP"; then
echo "OK: vcsInfo { include = false } — no env-dependent VCS metadata embedded."
else
echo "ERROR: '$APP' release build is missing 'vcsInfo { include = false }'." >&2
echo " AGP would embed version-control-info.textproto, breaking reproducibility." >&2
fail=1
fi
# 2. The foojay toolchain resolver must not be present in any Gradle script.
# This includes the floret-kit submodule: it's an included build (composite
# build via `includeBuild`), so F-Droid evaluates its Gradle scripts too when
# building from source — the same offline-scanner bar applies to it.
gradle_files=("$SETTINGS" "$APP")
[ -f build.gradle.kts ] && gradle_files+=(build.gradle.kts)
if [ -d floret-kit ]; then
while IFS= read -r f; do gradle_files+=("$f"); done \
< <(find floret-kit -name '*.gradle.kts' -not -path '*/build/*')
fi
if grep -qi 'foojay' "${gradle_files[@]}"; then
echo "ERROR: foojay toolchain resolver found in: $(grep -li foojay "${gradle_files[@]}" | tr '\n' ' ')" >&2
echo " F-Droid's source scanner rejects org.gradle.toolchains.foojay-resolver" >&2
echo " (it can fetch a JDK over the network). Remove the plugin." >&2
fail=1
else
echo "OK: no foojay toolchain resolver — offline build scanner stays happy."
fi
# 3. AGP dependency-metadata block must not be embedded in the APK.
if grep -Pzoq 'dependenciesInfo\s*\{[^}]*includeInApk\s*=\s*false' "$APP"; then
echo "OK: dependenciesInfo { includeInApk = false } — no extra APK signing block."
else
echo "ERROR: '$APP' is missing 'dependenciesInfo { includeInApk = false }'." >&2
echo " AGP would embed a 'Dependency metadata' block (0x504b4453) in the APK" >&2
echo " Signing Block, which F-Droid's binary scanner rejects." >&2
fail=1
fi
if [ "$fail" -ne 0 ]; then
echo >&2
echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2
echo "stall. Fix the above before merging." >&2
exit 1
fi
echo "All reproducible-release invariants hold."

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Single source of truth: fastlane/metadata/android/<locale>/ feeds BOTH the
# official F-Droid repo (harvested from source automatically) and the
# self-hosted repo. This script transforms the fastlane layout into the F-Droid
# "localized" layout that the self-hosted `fdroid update` consumes, so we don't
# maintain two copies.
#
# usage: fastlane_to_fdroid_localized.sh <fastlane_android_dir> <out_localized_dir>
# e.g. scripts/fastlane_to_fdroid_localized.sh \
# fastlane/metadata/android \
# fdroid/metadata/de.jeanlucmakiola.agendula
#
# Mapping (fastlane -> F-Droid repo localized):
# short_description.txt -> summary.txt
# full_description.txt -> description.txt
# title.txt -> name.txt
# images/icon.png -> icon.png
# images/phoneScreenshots/* -> phoneScreenshots/*
# changelogs/<versionCode>.txt -> changelogs/<versionCode>.txt
# (changelogs are seeded into the fastlane tree by
# scripts/sync_changelog_to_fastlane.sh.)
set -euo pipefail
SRC="${1:?need fastlane android dir, e.g. fastlane/metadata/android}"
OUT="${2:?need output localized dir, e.g. fdroid/metadata/<appid>}"
shopt -s nullglob
for locdir in "$SRC"/*/; do
loc="$(basename "$locdir")"
dst="$OUT/$loc"
mkdir -p "$dst"
[ -f "$locdir/short_description.txt" ] && cp "$locdir/short_description.txt" "$dst/summary.txt"
[ -f "$locdir/full_description.txt" ] && cp "$locdir/full_description.txt" "$dst/description.txt"
[ -f "$locdir/title.txt" ] && cp "$locdir/title.txt" "$dst/name.txt"
[ -f "$locdir/images/icon.png" ] && cp "$locdir/images/icon.png" "$dst/icon.png"
if [ -d "$locdir/images/phoneScreenshots" ]; then
mkdir -p "$dst/phoneScreenshots"
cp "$locdir"images/phoneScreenshots/* "$dst/phoneScreenshots/"
fi
# Per-version changelogs live in the same fastlane tree (see
# scripts/sync_changelog_to_fastlane.sh) and map straight across.
if [ -d "$locdir/changelogs" ]; then
mkdir -p "$dst/changelogs"
cp "$locdir"changelogs/* "$dst/changelogs/"
fi
done
echo "Built F-Droid localized metadata in '$OUT' from '$SRC'"

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Write the current version's CHANGELOG.md section into the fastlane changelog
# file that F-Droid harvests: fastlane/metadata/android/en-US/changelogs/<code>.txt
# (en-US is F-Droid's fallback locale, so it covers every language).
#
# Run this when cutting a release (after editing CHANGELOG.md and bumping
# versionName in app/build.gradle.kts) and COMMIT the result, so the OFFICIAL
# F-Droid repo — which reads the changelog from the tagged source tree — shows
# this version's "What's New". The self-hosted release pipeline also runs it so
# its changelog never depends on the file having been committed. Idempotent.
#
# Extraction matches the awk used for the Gitea release notes so all three
# (release notes, self-hosted changelog, official changelog) stay in sync.
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 ))
CL_DIR="fastlane/metadata/android/en-US/changelogs"
mkdir -p "$CL_DIR"
OUT="$CL_DIR/${VERSION_CODE}.txt"
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > "$OUT"
# Trim leading blank lines (same as the pipeline did).
sed -i -e '/./,$!d' "$OUT"
if [ ! -s "$OUT" ]; then
echo "See CHANGELOG.md for $VERSION." > "$OUT"
fi
CHARS=$(wc -m < "$OUT" | tr -d ' ')
echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
if [ "$CHARS" -gt 500 ]; then
echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2
fi

49
scripts/verify-release.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
#
# Build the release-candidate APK and install it on a connected device for the
# mandatory pre-release on-device check (see docs/RELEASING.md).
#
# It builds the `releaseTest` variant: the same R8 shrinking + obfuscation and
# resource shrinking as the published `release` build, but debug-signed and
# with a `.releasetest` applicationId suffix so it installs alongside the
# production and debug apps. This is what surfaces release-only breakage (R8
# stripping) and first-run states (permission not yet granted) that the
# unminified debug build — or a device that already holds the permission —
# silently hides.
#
# Usage: scripts/verify-release.sh
set -euo pipefail
cd "$(dirname "$0")/.."
PKG="de.jeanlucmakiola.agendula.releasetest"
APK="app/build/outputs/apk/releaseTest/app-releaseTest.apk"
echo "==> Building release-candidate APK (releaseTest, R8 minified)…"
./gradlew :app:assembleReleaseTest
echo "==> Installing $PKG"
adb install -r "$APK"
echo "==> Resetting to a first-run state (revoking tasks + notification permissions)…"
# Force the permission-not-granted state so the permission gate / onboarding is
# exercised every time — R8-only breakage and first-run crashes never show up in
# the unminified debug build, nor on a device that already holds the permission.
# Both tasks-provider permission sets are declared; revoke each so whichever the
# device's provider uses starts ungranted.
adb shell pm revoke "$PKG" org.dmfs.permission.READ_TASKS 2>/dev/null || true
adb shell pm revoke "$PKG" org.dmfs.permission.WRITE_TASKS 2>/dev/null || true
adb shell pm revoke "$PKG" org.tasks.permission.READ_TASKS 2>/dev/null || true
adb shell pm revoke "$PKG" org.tasks.permission.WRITE_TASKS 2>/dev/null || true
adb shell pm revoke "$PKG" android.permission.POST_NOTIFICATIONS 2>/dev/null || true
echo
echo "Installed and reset. Now verify ON THE DEVICE before releasing:"
echo " 1. Launch from a clean state — the permission screen must appear (no crash)."
echo " 2. Grant tasks access — the task list must load."
echo " 3. Create a task with a due reminder and confirm the notification fires."
echo " 4. Exercise the release's headline changes end to end."
echo
echo "Watch for crashes with: adb logcat -b crash"
echo "Only merge the release branch to main once all of the above pass on a device"
echo "(the merge is what publishes the release — see docs/RELEASING.md)."