Compare commits
32 Commits
b266653e4e
...
fix/provid
| Author | SHA1 | Date | |
|---|---|---|---|
| 47cf99af32 | |||
| 26628dc0bb | |||
| d2e3832ef2 | |||
| 93857135b3 | |||
| 36beb2d0ad | |||
| bc70ed3a9f | |||
| 3f166ef5f0 | |||
| 41bd49826a | |||
| 05c75bafa7 | |||
| cfa25b9730 | |||
| 9d7fc64b0b | |||
| 4aa65edb45 | |||
| a9843e25b7 | |||
| 5f4711dabe | |||
| 6d3fbc05c1 | |||
| 721b411579 | |||
| 046b8f7e9e | |||
| 06cc9b1c8b | |||
| 2d366a7be3 | |||
| 52aeebcb53 | |||
| 8f6b85008c | |||
| eb1e530ce7 | |||
| 7516972e9f | |||
| 7f58f81fe1 | |||
| 976d496d21 | |||
| 245f1db536 | |||
| 623e533547 | |||
| 2e50356f81 | |||
| e6f503c02a | |||
| c53511196d | |||
| a8595e26b4 | |||
| 411e27659f |
23
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
23
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal 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:
|
||||||
27
.gitea/ISSUE_TEMPLATE/crash_report.md
Normal file
27
.gitea/ISSUE_TEMPLATE/crash_report.md
Normal 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)
|
||||||
|
```
|
||||||
16
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
16
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal 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 -->
|
||||||
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
19
.gitea/ISSUE_TEMPLATE/question.md
Normal 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:
|
||||||
@@ -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=""
|
||||||
|
|||||||
@@ -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,140 @@ 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'
|
||||||
|
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
||||||
|
# 0.3.0, 0.3.1 and 0.3.2 while never once publishing, which is how a
|
||||||
|
# crash-fix release reached F-Droid but not the Codeberg/Obtainium
|
||||||
|
# users who needed it. A broken mirror must fail the release loudly.
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||||
|
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||||
|
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
|
||||||
|
# Never mint the tag here. Gitea's push mirror owns getting it to
|
||||||
|
# Codeberg; this step's only job is to attach a release to a tag that
|
||||||
|
# has already landed. That split matters because every way of creating
|
||||||
|
# a tag from here — git push, or a release POST carrying
|
||||||
|
# target_commitish for a tag Codeberg lacks — is a ref WRITE, and ref
|
||||||
|
# writes are what fail on this repo ("cannot lock references" on push,
|
||||||
|
# an empty-bodied 500 on the API). Attaching to a tag that is already
|
||||||
|
# present needs no ref write and succeeds.
|
||||||
|
#
|
||||||
|
# So: wait for the mirror, verify, then attach. If the tag never shows
|
||||||
|
# up, fail — do NOT fall back to creating it, which is what produced
|
||||||
|
# the silent breakage across 0.2.1 through 0.3.2.
|
||||||
|
TAG_OK=""
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if [ "$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $TOKEN" "$API/tags/$TAG")" = "200" ]; then
|
||||||
|
TAG_OK=1; echo "Codeberg has $TAG (after ~$((i*10))s)"; break
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
if [ -z "$TAG_OK" ]; then
|
||||||
|
echo "Codeberg never received $TAG from the push mirror (waited 300s)." >&2
|
||||||
|
echo "Not creating it here: ref writes to this repo fail, so that" >&2
|
||||||
|
echo "would 500. Check the mirror, then re-run once the tag is there." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# No target_commitish: the tag exists, so the API must attach to it
|
||||||
|
# rather than resolve a commit and mint one.
|
||||||
|
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
|
||||||
|
import json, sys
|
||||||
|
tag, pre = sys.argv[1:3]
|
||||||
|
print(json.dumps({
|
||||||
|
"tag_name": tag,
|
||||||
|
"name": tag,
|
||||||
|
"body": open("release-notes.md").read(),
|
||||||
|
"draft": False,
|
||||||
|
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||||
|
"prerelease": pre == "true",
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
# Upsert (re-run safe): a release already attached to this tag is
|
||||||
|
# PATCHed in place, so re-running never disturbs a published release.
|
||||||
|
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||||
|
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."
|
||||||
|
|||||||
64
CHANGELOG.md
64
CHANGELOG.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
15
app/proguard-rules.pro
vendored
15
app/proguard-rules.pro
vendored
@@ -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.**
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormField
|
|||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderLeadFor
|
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -39,15 +39,17 @@ data class Settings(
|
|||||||
val bottomAddBar: Boolean = false,
|
val bottomAddBar: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
||||||
* overrides the global default (a null value = no reminder); absent = inherit.
|
* overrides the global default (an empty list = no reminder); absent =
|
||||||
|
* inherit. Agendula offers a single reminder, so each override is a
|
||||||
|
* one-element (or empty) list.
|
||||||
*/
|
*/
|
||||||
val perListReminderOverride: Map<Long, Int?> = emptyMap(),
|
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||||
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
||||||
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
||||||
) {
|
) {
|
||||||
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
||||||
fun reminderLeadFor(listId: Long): Int? =
|
fun reminderLeadFor(listId: Long): Int? =
|
||||||
perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes)
|
perListReminderOverride.reminderLeadsFor(listId, listOf(reminderLeadMinutes)).firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.reminders
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
@@ -45,7 +46,14 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||||
|
|
||||||
fun intent(context: Context, taskId: Long): Intent =
|
/**
|
||||||
Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId)
|
* [triggerAt] rides in the intent *data*, not just an extra: PendingIntent
|
||||||
|
* identity ignores extras, so two occurrences of the same recurring task
|
||||||
|
* would otherwise collapse into one alarm under FLAG_UPDATE_CURRENT.
|
||||||
|
*/
|
||||||
|
fun intent(context: Context, taskId: Long, triggerAt: Long): Intent =
|
||||||
|
Intent(context, DueReminderReceiver::class.java)
|
||||||
|
.setData("agendula://reminder/$taskId/$triggerAt".toUri())
|
||||||
|
.putExtra(EXTRA_TASK_ID, taskId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package de.jeanlucmakiola.agendula.data.reminders
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -20,10 +22,25 @@ import javax.inject.Inject
|
|||||||
class ProviderChangeReceiver : BroadcastReceiver() {
|
class ProviderChangeReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
@Inject lateinit var scheduler: ReminderScheduler
|
@Inject lateinit var scheduler: ReminderScheduler
|
||||||
|
@Inject lateinit var providerResolver: ProviderResolver
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
// The receiver has to stay exported to hear the provider's broadcast, and
|
||||||
|
// the sender holds no permission we could require — so validate the
|
||||||
|
// broadcast itself. Without this, any installed app can spam a full
|
||||||
|
// re-sync (an unbounded provider read) by firing a matching intent.
|
||||||
|
if (intent.action != Intent.ACTION_PROVIDER_CHANGED) return
|
||||||
|
val authority = providerResolver.resolve()?.authority ?: return
|
||||||
|
if (intent.data?.host != authority) return
|
||||||
|
// External sync can fire these in bursts; one re-sync per burst is plenty.
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
synchronized(Companion) {
|
||||||
|
if (now - lastSyncAt < MIN_SYNC_INTERVAL_MS) return
|
||||||
|
lastSyncAt = now
|
||||||
|
}
|
||||||
|
|
||||||
val pending = goAsync()
|
val pending = goAsync()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -33,4 +50,11 @@ class ProviderChangeReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MIN_SYNC_INTERVAL_MS = 10_000L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var lastSyncAt = -MIN_SYNC_INTERVAL_MS
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,35 +43,57 @@ class ReminderScheduler @Inject constructor(
|
|||||||
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
||||||
.getOrElse { return@withContext }
|
.getOrElse { return@withContext }
|
||||||
|
|
||||||
|
// One reminder per *occurrence*: the instances view yields a row per
|
||||||
|
// occurrence, all sharing a taskId, so this is a Set rather than a
|
||||||
|
// taskId-keyed Map — keying by task would collapse a daily recurring task
|
||||||
|
// down to one arbitrary reminder (the query is unsorted, so which one
|
||||||
|
// survived was provider-defined).
|
||||||
|
// Per-task leads, stored as Alarm property rows. One query for all of them.
|
||||||
|
val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() }
|
||||||
|
|
||||||
val desired = tasks
|
val desired = tasks
|
||||||
.filter { !it.isClosed && it.due != null }
|
.filter { !it.isClosed && it.due != null }
|
||||||
.mapNotNull { task ->
|
.mapNotNull { task ->
|
||||||
// The task's list may override the global lead, or opt out entirely
|
// A reminder set on the task itself wins; otherwise the task's list
|
||||||
// (override = null), in which case it gets no reminder at all.
|
// may override the global lead, or opt out entirely (override =
|
||||||
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
|
// null), in which case it gets no reminder at all.
|
||||||
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
|
val lead = perTask[task.taskId]
|
||||||
|
?: settings.reminderLeadFor(task.listId)
|
||||||
|
?: return@mapNotNull null
|
||||||
|
ScheduledReminder(
|
||||||
|
taskId = task.taskId,
|
||||||
|
triggerAt = task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.toMap()
|
// The lower bound trails `now` so a reminder missed while the device was
|
||||||
.filterValues { it in now..horizon }
|
// off still fires once on boot instead of being silently dropped —
|
||||||
|
// setExactAndAllowWhileIdle delivers a past trigger immediately. Anything
|
||||||
|
// already armed stays armed (the diff below), so it can't re-fire.
|
||||||
|
.filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
val previous = store.all()
|
val previous = store.all()
|
||||||
(previous.keys - desired.keys).forEach { cancel(it) }
|
(previous - desired).forEach { cancel(it) }
|
||||||
desired.forEach { (taskId, triggerAt) ->
|
(desired - previous).forEach { schedule(it) }
|
||||||
if (previous[taskId] != triggerAt) schedule(taskId, triggerAt)
|
|
||||||
}
|
|
||||||
store.replace(desired)
|
store.replace(desired)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
|
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
|
||||||
|
|
||||||
private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? {
|
private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? {
|
||||||
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
|
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags)
|
return PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
reminder.requestCode,
|
||||||
|
DueReminderReceiver.intent(context, reminder.taskId, reminder.triggerAt),
|
||||||
|
flags,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun schedule(taskId: Long, triggerAt: Long) {
|
private fun schedule(reminder: ScheduledReminder) {
|
||||||
val pi = pendingIntent(taskId, create = true) ?: return
|
val triggerAt = reminder.triggerAt
|
||||||
|
val pi = pendingIntent(reminder, create = true) ?: return
|
||||||
val am = alarmManager()
|
val am = alarmManager()
|
||||||
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||||
if (canExact) {
|
if (canExact) {
|
||||||
@@ -81,19 +103,21 @@ class ReminderScheduler @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancel(taskId: Long) {
|
private fun cancel(reminder: ScheduledReminder) {
|
||||||
pendingIntent(taskId, create = false)?.let {
|
pendingIntent(reminder, create = false)?.let {
|
||||||
alarmManager().cancel(it)
|
alarmManager().cancel(it)
|
||||||
it.cancel()
|
it.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun clearAll() {
|
private suspend fun clearAll() {
|
||||||
store.all().keys.forEach { cancel(it) }
|
store.all().forEach { cancel(it) }
|
||||||
store.replace(emptyMap())
|
store.replace(emptySet())
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
|
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
|
||||||
|
/** How long after its trigger a missed reminder is still worth firing. */
|
||||||
|
const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,25 +9,38 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remembers which task reminders are currently scheduled (taskId → trigger time),
|
* One armed alarm. A recurring task has many occurrences sharing a [taskId], so
|
||||||
* so [ReminderScheduler] can diff against a fresh computation and cancel only the
|
* the trigger time is part of the identity — keying by task alone would collapse
|
||||||
* alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings.
|
* a daily task down to a single reminder.
|
||||||
|
*/
|
||||||
|
data class ScheduledReminder(val taskId: Long, val triggerAt: Long) {
|
||||||
|
/**
|
||||||
|
* Request code for this alarm's PendingIntent. Derived from both fields so
|
||||||
|
* sibling occurrences don't share (and overwrite) one alarm slot.
|
||||||
|
*/
|
||||||
|
val requestCode: Int get() = (taskId * 31 + triggerAt).hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers which task reminders are currently armed, so [ReminderScheduler] can
|
||||||
|
* diff against a fresh computation and touch only the alarms that changed.
|
||||||
|
* Persisted in DataStore as a set of `taskId|trigger` strings.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ScheduledReminderStore @Inject constructor(
|
class ScheduledReminderStore @Inject constructor(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
) {
|
) {
|
||||||
suspend fun all(): Map<Long, Long> =
|
suspend fun all(): Set<ScheduledReminder> =
|
||||||
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
||||||
val parts = entry.split('|')
|
val parts = entry.split('|')
|
||||||
val id = parts.getOrNull(0)?.toLongOrNull()
|
val id = parts.getOrNull(0)?.toLongOrNull()
|
||||||
val at = parts.getOrNull(1)?.toLongOrNull()
|
val at = parts.getOrNull(1)?.toLongOrNull()
|
||||||
if (id != null && at != null) id to at else null
|
if (id != null && at != null) ScheduledReminder(id, at) else null
|
||||||
}.toMap()
|
}.toSet()
|
||||||
|
|
||||||
suspend fun replace(scheduled: Map<Long, Long>) {
|
suspend fun replace(scheduled: Set<ScheduledReminder>) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet()
|
prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import android.os.Looper
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
@@ -98,6 +99,50 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateInstance(instanceId: Long, form: TaskForm) {
|
||||||
|
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||||
|
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||||
|
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||||
|
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
|
||||||
|
val uri = TasksContract.propertiesUri(authority())
|
||||||
|
// Replace rather than update: the provider's AlarmHandler re-validates the
|
||||||
|
// whole row on every update, so a partial edit throws — and delete+insert
|
||||||
|
// means we never have to track property_id.
|
||||||
|
resolver.delete(
|
||||||
|
uri,
|
||||||
|
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
|
||||||
|
arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE),
|
||||||
|
)
|
||||||
|
if (minutesBeforeDue != null) {
|
||||||
|
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
|
||||||
|
?: throw TaskWriteFailedException("set alarm for task $taskId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun alarms(): Map<Long, Int> {
|
||||||
|
val uri = TasksContract.propertiesUri(authority())
|
||||||
|
val projection = arrayOf(Properties.TASK_ID, TasksContract.Alarm.MINUTES_BEFORE)
|
||||||
|
return resolver.query(
|
||||||
|
uri,
|
||||||
|
projection,
|
||||||
|
"${Properties.MIMETYPE} = ?",
|
||||||
|
arrayOf(TasksContract.Alarm.MIMETYPE),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
buildMap {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val id = reader.getLong(Properties.TASK_ID)
|
||||||
|
val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE)
|
||||||
|
if (id != null && minutes != null) put(id, minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
override fun setCompleted(taskId: Long, completed: Boolean) {
|
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||||
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||||
@@ -127,9 +172,16 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||||
override fun onChange(selfChange: Boolean) = onChange()
|
override fun onChange(selfChange: Boolean) = onChange()
|
||||||
}
|
}
|
||||||
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
|
// Register both or neither: if the second call throws, the first
|
||||||
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
// registration would otherwise leak (no AutoCloseable was handed back yet).
|
||||||
return AutoCloseable { resolver.unregisterContentObserver(observer) }
|
try {
|
||||||
|
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
|
||||||
|
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
runCatching { resolver.unregisterContentObserver(observer) }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Map<String, Any?>.toContentValues(): ContentValues {
|
private fun Map<String, Any?>.toContentValues(): ContentValues {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.retryWhen
|
||||||
|
|
||||||
|
private const val BASE_RETRY_MS = 1_000L
|
||||||
|
private const val MAX_RETRY_MS = 30_000L
|
||||||
|
|
||||||
|
/** 1s, 2s, 4s … capped at 30s, so a permanently-absent provider costs little. */
|
||||||
|
private fun retryDelayMs(attempt: Long): Long =
|
||||||
|
(BASE_RETRY_MS shl attempt.coerceAtMost(5).toInt()).coerceAtMost(MAX_RETRY_MS)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover a provider-backed flow without killing it.
|
||||||
|
*
|
||||||
|
* Provider reads fail for reasons that resolve on their own: the read permission
|
||||||
|
* isn't granted yet (first launch collects before the permission gate), or the
|
||||||
|
* provider app is mid-update. A terminal `catch` swallows the failure *and*
|
||||||
|
* cancels the upstream, so the flow never produces again — the screen stays empty
|
||||||
|
* until the process restarts, even after the user grants the permission.
|
||||||
|
*
|
||||||
|
* This emits [fallback] instead and keeps retrying with a capped backoff, so the
|
||||||
|
* collector recovers on its own once the provider becomes readable.
|
||||||
|
*/
|
||||||
|
fun <T> Flow<T>.recoveringFromProviderFailure(fallback: () -> T): Flow<T> =
|
||||||
|
retryWhen { _, attempt ->
|
||||||
|
emit(fallback())
|
||||||
|
delay(retryDelayMs(attempt))
|
||||||
|
true
|
||||||
|
}
|
||||||
@@ -38,7 +38,14 @@ object TaskMapper {
|
|||||||
listName = r.getString(Tasks.LIST_NAME),
|
listName = r.getString(Tasks.LIST_NAME),
|
||||||
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
||||||
parentId = r.getLong(Tasks.PARENT_ID),
|
parentId = r.getLong(Tasks.PARENT_ID),
|
||||||
isRecurring = r.getBoolean(Instances.IS_RECURRING),
|
// Derived from the rule columns rather than the `is_recurring` column
|
||||||
|
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
|
||||||
|
// absent on tasks.org's bundled provider (DB 22), where reading it
|
||||||
|
// would silently report every recurring task as one-off — and route
|
||||||
|
// its edits onto the series anchor.
|
||||||
|
isRecurring = r.getString(Tasks.RRULE) != null ||
|
||||||
|
r.getString(Tasks.RDATE) != null ||
|
||||||
|
r.getBoolean(Instances.IS_RECURRING),
|
||||||
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
||||||
created = instant(Tasks.CREATED),
|
created = instant(Tasks.CREATED),
|
||||||
lastModified = instant(Tasks.LAST_MODIFIED),
|
lastModified = instant(Tasks.LAST_MODIFIED),
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
|
||||||
|
|
||||||
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
||||||
object TaskProjections {
|
object TaskProjections {
|
||||||
@@ -18,31 +16,9 @@ object TaskProjections {
|
|||||||
Lists.ACCOUNT_TYPE,
|
Lists.ACCOUNT_TYPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Read from the `instances` view (inherits all task columns). */
|
// No `instances` projection on purpose: that read passes `projection = null`
|
||||||
val INSTANCES: Array<String> = arrayOf(
|
// (all columns), because the view's shape differs across provider versions —
|
||||||
Tasks.ID,
|
// tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list
|
||||||
Instances.TASK_ID,
|
// here would drift out of sync with the by-name mapper and quietly drop
|
||||||
Tasks.LIST_ID,
|
// columns it depends on. See AndroidTasksDataSource.queryInstances.
|
||||||
Tasks.TITLE,
|
|
||||||
Tasks.DESCRIPTION,
|
|
||||||
Tasks.LOCATION,
|
|
||||||
Tasks.URL,
|
|
||||||
Tasks.PRIORITY,
|
|
||||||
Tasks.STATUS,
|
|
||||||
Tasks.PERCENT_COMPLETE,
|
|
||||||
Tasks.COMPLETED,
|
|
||||||
Tasks.IS_ALLDAY,
|
|
||||||
Tasks.TZ,
|
|
||||||
Instances.INSTANCE_START,
|
|
||||||
Instances.INSTANCE_DUE,
|
|
||||||
Tasks.TASK_COLOR,
|
|
||||||
Tasks.LIST_COLOR,
|
|
||||||
Tasks.LIST_NAME,
|
|
||||||
Tasks.ACCOUNT_NAME,
|
|
||||||
Tasks.PARENT_ID,
|
|
||||||
Instances.IS_RECURRING,
|
|
||||||
Instances.DISTANCE_FROM_CURRENT,
|
|
||||||
Tasks.CREATED,
|
|
||||||
Tasks.LAST_MODIFIED,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.toICal
|
import de.jeanlucmakiola.agendula.domain.toICal
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
|
||||||
|
private fun Instant.forAllDay(allDay: Boolean): Instant =
|
||||||
|
if (!allDay) this
|
||||||
|
else Instant.fromEpochMilliseconds(
|
||||||
|
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
||||||
@@ -39,8 +51,17 @@ object TaskWriteMapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
|
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
|
||||||
put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
|
// All-day tasks are date-only in iCalendar. The provider reads them back
|
||||||
put(Tasks.DUE, form.due?.toEpochMilliseconds())
|
// through DateTime.toAllDay(), which drops the time-of-day and resolves the
|
||||||
|
// remaining date against UTC — so a local-midnight instant lands on the
|
||||||
|
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
|
||||||
|
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
|
||||||
|
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
|
||||||
|
// DUE and DURATION are mutually exclusive. The provider's Validating
|
||||||
|
// processor evaluates the *merged* row (supplied values over the stored
|
||||||
|
// ones), so writing DUE onto a task that already carries a DURATION throws
|
||||||
|
// "Only one of DUE or DURATION must be supplied." Clear it alongside.
|
||||||
|
put(Tasks.DURATION, null)
|
||||||
put(Tasks.PARENT_ID, form.parentId)
|
put(Tasks.PARENT_ID, form.parentId)
|
||||||
// The provider treats a null tz as local time; set it explicitly for
|
// The provider treats a null tz as local time; set it explicitly for
|
||||||
// timed tasks so the stored instant is unambiguous across zones.
|
// timed tasks so the stored instant is unambiguous across zones.
|
||||||
@@ -48,6 +69,16 @@ object TaskWriteMapper {
|
|||||||
put(Tasks.TZ, if (timed) tzId else null)
|
put(Tasks.TZ, if (timed) tzId else null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values for an update through the *instances* URI (a recurring occurrence).
|
||||||
|
* The provider clones the row into an override and strips list/recurrence
|
||||||
|
* fields as it goes, so LIST_ID and PARENT_ID are dropped here rather than
|
||||||
|
* written and silently ignored — moving one occurrence between lists or
|
||||||
|
* parents isn't a thing the override model expresses.
|
||||||
|
*/
|
||||||
|
fun instanceValues(form: TaskForm, tzId: String): Map<String, Any?> =
|
||||||
|
taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID
|
||||||
|
|
||||||
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
|
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
|
||||||
if (completed) {
|
if (completed) {
|
||||||
mapOf(
|
mapOf(
|
||||||
@@ -63,6 +94,19 @@ object TaskWriteMapper {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reminder for [taskId], as an Alarm property row. The provider's validator
|
||||||
|
* requires MINUTES_BEFORE, REFERENCE (non-negative) and ALARM_TYPE on every
|
||||||
|
* write, so all three are always present.
|
||||||
|
*/
|
||||||
|
fun alarmValues(taskId: Long, minutesBeforeDue: Int): Map<String, Any?> = mapOf(
|
||||||
|
Properties.TASK_ID to taskId,
|
||||||
|
Properties.MIMETYPE to Alarm.MIMETYPE,
|
||||||
|
Alarm.MINUTES_BEFORE to minutesBeforeDue,
|
||||||
|
Alarm.REFERENCE to Alarm.REFERENCE_DUE,
|
||||||
|
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
|
||||||
|
)
|
||||||
|
|
||||||
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
||||||
Lists.NAME to name.trim(),
|
Lists.NAME to name.trim(),
|
||||||
Lists.COLOR to color,
|
Lists.COLOR to color,
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ object TasksContract {
|
|||||||
const val IS_ALLDAY = "is_allday"
|
const val IS_ALLDAY = "is_allday"
|
||||||
const val TZ = "tz"
|
const val TZ = "tz"
|
||||||
const val RRULE = "rrule"
|
const val RRULE = "rrule"
|
||||||
|
const val RDATE = "rdate"
|
||||||
|
/** Set on an override row — the master occurrence this one replaces. */
|
||||||
|
const val ORIGINAL_INSTANCE_ID = "original_instance_id"
|
||||||
const val PARENT_ID = "parent_id"
|
const val PARENT_ID = "parent_id"
|
||||||
const val SORTING = "sorting"
|
const val SORTING = "sorting"
|
||||||
const val CREATED = "created"
|
const val CREATED = "created"
|
||||||
@@ -98,6 +101,50 @@ object TasksContract {
|
|||||||
const val IS_RECURRING = "is_recurring"
|
const val IS_RECURRING = "is_recurring"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */
|
||||||
|
object Properties {
|
||||||
|
const val PATH = "properties"
|
||||||
|
const val PROPERTY_ID = "property_id"
|
||||||
|
const val TASK_ID = "task_id"
|
||||||
|
const val MIMETYPE = "mimetype"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An alarm property row — a per-task reminder lead.
|
||||||
|
*
|
||||||
|
* Storage and sync format *only*: the provider fires nothing (its alarm
|
||||||
|
* scheduling is commented out and the internal `alarms` table is never
|
||||||
|
* populated), so [de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler]
|
||||||
|
* still arms the real AlarmManager alarm. Writing it here is what makes the
|
||||||
|
* lead survive a sync and show up in other OpenTasks clients.
|
||||||
|
*
|
||||||
|
* The columns are the generic `dataN` slots; the meanings below are the
|
||||||
|
* Alarm property's contract for them.
|
||||||
|
*/
|
||||||
|
object Alarm {
|
||||||
|
const val MIMETYPE = "vnd.android.cursor.item/alarm"
|
||||||
|
|
||||||
|
/** `data0` — minutes from the reference date; positive means *before* it. */
|
||||||
|
const val MINUTES_BEFORE = "data0"
|
||||||
|
|
||||||
|
/** `data1` — which date to count from. */
|
||||||
|
const val REFERENCE = "data1"
|
||||||
|
|
||||||
|
/** `data2` — optional message shown with the alarm. */
|
||||||
|
const val MESSAGE = "data2"
|
||||||
|
|
||||||
|
/** `data3` — alarm kind. Must be present, and non-zero to count as an alarm. */
|
||||||
|
const val ALARM_TYPE = "data3"
|
||||||
|
|
||||||
|
const val REFERENCE_DUE = 1
|
||||||
|
const val REFERENCE_START = 2
|
||||||
|
|
||||||
|
/** 0 (NOTHING) is excluded from the provider's `has_alarms` count — use MESSAGE. */
|
||||||
|
const val TYPE_MESSAGE = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}")
|
||||||
|
|
||||||
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
||||||
const val STATUS_NEEDS_ACTION = 0
|
const val STATUS_NEEDS_ACTION = 0
|
||||||
const val STATUS_IN_PROCESS = 1
|
const val STATUS_IN_PROCESS = 1
|
||||||
@@ -112,6 +159,15 @@ object TasksContract {
|
|||||||
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
|
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
|
||||||
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
|
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single occurrence. Updating through this URI is how a *recurring* task is
|
||||||
|
* edited: the provider clones the row into an override task
|
||||||
|
* (`original_instance_id` set, recurrence fields stripped) instead of moving
|
||||||
|
* the series anchor, which is what writing to `tasks/<id>` would do.
|
||||||
|
*/
|
||||||
|
fun instanceUri(authority: String, instanceId: Long): Uri =
|
||||||
|
Uri.parse("content://$authority/${Instances.PATH}/$instanceId")
|
||||||
|
|
||||||
/** Append the sync-adapter params required to write local-account rows. */
|
/** Append the sync-adapter params required to write local-account rows. */
|
||||||
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
|
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
|
||||||
uri.buildUpon()
|
uri.buildUpon()
|
||||||
|
|||||||
@@ -23,6 +23,25 @@ interface TasksDataSource {
|
|||||||
|
|
||||||
fun insertTask(form: TaskForm): Long
|
fun insertTask(form: TaskForm): Long
|
||||||
fun updateTask(taskId: Long, form: TaskForm)
|
fun updateTask(taskId: Long, form: TaskForm)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single occurrence of a recurring task, addressed by its *instance*
|
||||||
|
* row id. The provider forks an override task rather than moving the series
|
||||||
|
* anchor — which is what [updateTask] would do, since a recurring task's
|
||||||
|
* start/due are read from the instances view.
|
||||||
|
*/
|
||||||
|
fun updateInstance(instanceId: Long, form: TaskForm)
|
||||||
|
/**
|
||||||
|
* Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm
|
||||||
|
* property row. The provider never fires it — [de.jeanlucmakiola.agendula
|
||||||
|
* .data.reminders.ReminderScheduler] does — but persisting it here is what
|
||||||
|
* syncs the lead and shares it with other OpenTasks clients.
|
||||||
|
*/
|
||||||
|
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
|
||||||
|
|
||||||
|
/** Every task's reminder lead, by task id. One query, for the scheduler. */
|
||||||
|
fun alarms(): Map<Long, Int>
|
||||||
|
|
||||||
fun setCompleted(taskId: Long, completed: Boolean)
|
fun setCompleted(taskId: Long, completed: Boolean)
|
||||||
fun deleteTask(taskId: Long)
|
fun deleteTask(taskId: Long)
|
||||||
fun createLocalList(name: String, color: Int): Long
|
fun createLocalList(name: String, color: Int): Long
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ interface TasksRepository {
|
|||||||
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
||||||
suspend fun setCompleted(taskId: Long, completed: Boolean)
|
suspend fun setCompleted(taskId: Long, completed: Boolean)
|
||||||
suspend fun deleteTask(taskId: Long)
|
suspend fun deleteTask(taskId: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-task reminder lead in minutes before due, or `null` if the task has
|
||||||
|
* none (in which case the list's / global setting applies). Read when the edit
|
||||||
|
* form loads so saving can't silently drop it.
|
||||||
|
*/
|
||||||
|
suspend fun reminderFor(taskId: Long): Int?
|
||||||
suspend fun createLocalList(name: String, color: Int): Long
|
suspend fun createLocalList(name: String, color: Int): Long
|
||||||
|
|
||||||
/** Synchronous snapshot for the permission/onboarding gate. */
|
/** Synchronous snapshot for the permission/onboarding gate. */
|
||||||
|
|||||||
@@ -80,18 +80,39 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createTask(form: TaskForm): Long =
|
override suspend fun createTask(form: TaskForm): Long =
|
||||||
withContext(io) { dataSource.insertTask(form) }
|
withContext(io) {
|
||||||
|
val id = dataSource.insertTask(form)
|
||||||
|
form.reminderMinutesBeforeDue?.let { dataSource.setAlarm(id, it) }
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reminderFor(taskId: Long): Int? =
|
||||||
|
withContext(io) { runCatching { dataSource.alarms()[taskId] }.getOrNull() }
|
||||||
|
|
||||||
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
|
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
// Conflict-safe overwrite: re-read just before writing and bail if the
|
// Re-read just before writing: it settles the conflict check *and* tells
|
||||||
// provider's last_modified moved since the form captured it (external
|
// us which URI to write through.
|
||||||
// sync / another app). A null baseline means "force / overwrite anyway".
|
val current = dataSource.task(taskId)
|
||||||
|
// Conflict-safe overwrite: bail if the provider's last_modified moved
|
||||||
|
// since the form captured it (external sync / another app). A null
|
||||||
|
// baseline means "force / overwrite anyway".
|
||||||
if (expectedLastModified != null) {
|
if (expectedLastModified != null) {
|
||||||
val current = dataSource.task(taskId)?.lastModified
|
val seen = current?.lastModified
|
||||||
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
|
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
|
||||||
|
}
|
||||||
|
// Write the reminder first: forking a recurring occurrence copies the
|
||||||
|
// task's properties onto the new override row, so setting the alarm
|
||||||
|
// beforehand is what carries it across.
|
||||||
|
dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue)
|
||||||
|
// A recurring task's start/due come from the instances view, so writing
|
||||||
|
// them back to tasks/<id> would re-anchor the whole series. Going through
|
||||||
|
// the occurrence lets the provider fork an override instead.
|
||||||
|
if (current != null && current.isRecurring) {
|
||||||
|
dataSource.updateInstance(current.id, form)
|
||||||
|
} else {
|
||||||
|
dataSource.updateTask(taskId, form)
|
||||||
}
|
}
|
||||||
dataSource.updateTask(taskId, form)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
|
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All-day tasks are date-only in iCalendar. OpenTasks reads them back through
|
||||||
|
* `DateTime.toAllDay()`, which discards the time-of-day and resolves the
|
||||||
|
* remaining date against UTC — so the storage convention is **UTC midnight of
|
||||||
|
* the intended calendar date, with a null timezone**. Timed tasks, by contrast,
|
||||||
|
* are ordinary instants rendered in the device's zone.
|
||||||
|
*
|
||||||
|
* These two conventions disagree about which day a given instant is, which is
|
||||||
|
* why every all-day value needs an explicit conversion rather than a raw
|
||||||
|
* `Instant` passed straight through.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** UTC midnight of [date] — the storage form for an all-day value. */
|
||||||
|
fun allDayInstantOf(date: java.time.LocalDate): Instant =
|
||||||
|
Instant.fromEpochMilliseconds(date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar date this instant denotes: read in UTC for [allDay] values,
|
||||||
|
* in [zone] for timed ones.
|
||||||
|
*/
|
||||||
|
fun Instant.calendarDate(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalDate =
|
||||||
|
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
|
||||||
|
.atZone(if (allDay) ZoneOffset.UTC else zone)
|
||||||
|
.toLocalDate()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an instant across the two conventions when the all-day switch flips, so
|
||||||
|
* the day the user is looking at stays put. Without this, toggling all-day off
|
||||||
|
* turns a UTC-midnight value into "02:00" in Berlin (or the previous day, 19:00,
|
||||||
|
* in New York) — reading to the user as "the time reset itself".
|
||||||
|
*/
|
||||||
|
fun Instant.rebasedForAllDay(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): Instant =
|
||||||
|
if (allDay) allDayInstantOf(calendarDate(allDay = false, zone = zone))
|
||||||
|
else Instant.fromEpochMilliseconds(
|
||||||
|
calendarDate(allDay = true).atStartOfDay(zone).toInstant().toEpochMilli(),
|
||||||
|
)
|
||||||
@@ -48,6 +48,12 @@ data class Task(
|
|||||||
val listName: String?,
|
val listName: String?,
|
||||||
val accountName: String?,
|
val accountName: String?,
|
||||||
val parentId: Long?,
|
val parentId: Long?,
|
||||||
|
/**
|
||||||
|
* This row carries a recurrence rule, so [id] is one occurrence of a series
|
||||||
|
* and [start]/[due] are that occurrence's resolved times — *not* the master's
|
||||||
|
* anchor. Edits must go through the instances URI (see
|
||||||
|
* [de.jeanlucmakiola.agendula.data.tasks.TasksContract.instanceUri]).
|
||||||
|
*/
|
||||||
val isRecurring: Boolean,
|
val isRecurring: Boolean,
|
||||||
val distanceFromCurrent: Int?,
|
val distanceFromCurrent: Int?,
|
||||||
val created: Instant?,
|
val created: Instant?,
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||||
@@ -39,6 +43,18 @@ fun RootScreen(
|
|||||||
ActivityResultContracts.RequestMultiplePermissions(),
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
) { permissionViewModel.refresh() }
|
) { permissionViewModel.refresh() }
|
||||||
|
|
||||||
|
// Re-check on every resume, not just after the in-app request: the user may
|
||||||
|
// have granted the permission (or installed a provider) in system Settings and
|
||||||
|
// come back, and otherwise the gate would hold until the process restarts.
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) permissionViewModel.refresh()
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
|
||||||
when (permission.status) {
|
when (permission.status) {
|
||||||
ProviderStatus.NO_PROVIDER -> Gate(
|
ProviderStatus.NO_PROVIDER -> Gate(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.time.formatDateTime
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Clear
|
|
||||||
import androidx.compose.material.icons.rounded.Event
|
|
||||||
import androidx.compose.material3.DatePicker
|
|
||||||
import androidx.compose.material3.DatePickerDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TimePicker
|
|
||||||
import androidx.compose.material3.rememberDatePickerState
|
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import de.jeanlucmakiola.agendula.R
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
private val zone: ZoneId get() = ZoneId.systemDefault()
|
|
||||||
|
|
||||||
internal fun Instant.toLocalDate(): LocalDate =
|
|
||||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
|
|
||||||
|
|
||||||
internal fun Instant.toLocalTime(): LocalTime =
|
|
||||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
|
|
||||||
|
|
||||||
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
|
|
||||||
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A labelled date(-time) field for the edit form: a tonal row showing the
|
|
||||||
* current value (or nothing), tappable to pick a date and — unless [allDay] —
|
|
||||||
* a time. A clear affordance appears once a value is set. Emits `null` when
|
|
||||||
* cleared. Styled to match the app's rounded tonal family.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun DateTimeField(
|
|
||||||
label: String,
|
|
||||||
value: Instant?,
|
|
||||||
allDay: Boolean,
|
|
||||||
onChange: (Instant?) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
var showDatePicker by remember { mutableStateOf(false) }
|
|
||||||
var showTimePicker by remember { mutableStateOf(false) }
|
|
||||||
var pendingDate by remember { mutableStateOf<LocalDate?>(null) }
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
onClick = { showDatePicker = true },
|
|
||||||
shape = RoundedCornerShape(22.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (value != null) {
|
|
||||||
IconButton(onClick = { onChange(null) }) {
|
|
||||||
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDatePicker) {
|
|
||||||
val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()))
|
|
||||||
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
|
||||||
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
|
||||||
DatePickerDialog(
|
|
||||||
onDismissRequest = { showDatePicker = false },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
showDatePicker = false
|
|
||||||
val millis = dateState.selectedDateMillis ?: return@TextButton
|
|
||||||
val date = java.time.Instant.ofEpochMilli(millis)
|
|
||||||
.atZone(ZoneOffset.UTC).toLocalDate()
|
|
||||||
if (allDay) {
|
|
||||||
onChange(localToInstant(date, LocalTime.MIDNIGHT))
|
|
||||||
} else {
|
|
||||||
pendingDate = date
|
|
||||||
showTimePicker = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { Text(stringResource(android.R.string.ok)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showDatePicker = false }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { DatePicker(state = dateState) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showTimePicker) {
|
|
||||||
val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
|
||||||
val timeState = rememberTimePickerState(
|
|
||||||
initialHour = base.toLocalTime().hour,
|
|
||||||
initialMinute = base.toLocalTime().minute,
|
|
||||||
)
|
|
||||||
Dialog(onDismissRequest = { showTimePicker = false }) {
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
TimePicker(state = timeState)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
) {
|
|
||||||
TextButton(onClick = { showTimePicker = false }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
TextButton(onClick = {
|
|
||||||
showTimePicker = false
|
|
||||||
val date = pendingDate ?: return@TextButton
|
|
||||||
onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute)))
|
|
||||||
}) { Text(stringResource(android.R.string.ok)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zone helpers shared by the date/time pickers. All-day conversions live in
|
||||||
|
* [de.jeanlucmakiola.agendula.domain.AllDayTime] — these cover the timed case,
|
||||||
|
* where the device zone is the right frame of reference.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private val zone: ZoneId get() = ZoneId.systemDefault()
|
||||||
|
|
||||||
|
internal fun Instant.toLocalTime(): LocalTime =
|
||||||
|
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
|
||||||
|
|
||||||
|
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
|
||||||
|
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
|
||||||
@@ -54,7 +54,9 @@ fun ReminderLeadPicker(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
presets: List<Int> = REMINDER_PRESETS,
|
presets: List<Int> = REMINDER_PRESETS,
|
||||||
) {
|
) {
|
||||||
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
|
// Agendula is single-reminder: an override carries a one-element list, so
|
||||||
|
// take the single value for this single-select picker.
|
||||||
|
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes?.firstOrNull()
|
||||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
@@ -65,7 +67,7 @@ fun ReminderLeadPicker(
|
|||||||
val options = buildList {
|
val options = buildList {
|
||||||
if (allowInherit) add(ReminderOverride.Inherit)
|
if (allowInherit) add(ReminderOverride.Inherit)
|
||||||
if (allowNone) add(ReminderOverride.None)
|
if (allowNone) add(ReminderOverride.None)
|
||||||
presets.forEach { add(ReminderOverride.Minutes(it)) }
|
presets.forEach { add(ReminderOverride.Minutes(listOf(it))) }
|
||||||
}
|
}
|
||||||
val rowCount = options.size + 1 // + the custom row
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ fun ReminderLeadPicker(
|
|||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(ReminderOverride.Minutes(minutes))
|
onSelect(ReminderOverride.Minutes(listOf(minutes)))
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -168,5 +170,5 @@ private fun CustomReminderEditor(
|
|||||||
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
||||||
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes.first())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,13 +501,16 @@ private fun taskWhenLines(task: Task): Pair<String, String?>? {
|
|||||||
val due = task.due
|
val due = task.due
|
||||||
return when {
|
return when {
|
||||||
start != null && due != null -> {
|
start != null && due != null -> {
|
||||||
val sameDay = start.formatDate() == due.formatDate()
|
val allDay = task.isAllDay
|
||||||
val primary = if (sameDay) due.formatDate() else "${start.formatDate()} – ${due.formatDate()}"
|
val sameDay = start.formatDate(allDay) == due.formatDate(allDay)
|
||||||
val secondary = if (task.isAllDay) null else "${start.formatTime()} – ${due.formatTime()}"
|
val primary =
|
||||||
|
if (sameDay) due.formatDate(allDay)
|
||||||
|
else "${start.formatDate(allDay)} – ${due.formatDate(allDay)}"
|
||||||
|
val secondary = if (allDay) null else "${start.formatTime()} – ${due.formatTime()}"
|
||||||
primary to secondary
|
primary to secondary
|
||||||
}
|
}
|
||||||
due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime()
|
due != null -> due.formatDate(task.isAllDay) to if (task.isAllDay) null else due.formatTime()
|
||||||
start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime()
|
start != null -> start.formatDate(task.isAllDay) to if (task.isAllDay) null else start.formatTime()
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
@@ -11,7 +12,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -42,7 +42,7 @@ class TaskDetailViewModel @Inject constructor(
|
|||||||
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
|
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
|
||||||
}
|
}
|
||||||
.onStart { emit(TaskDetailUiState.Loading) }
|
.onStart { emit(TaskDetailUiState.Loading) }
|
||||||
.catch { emit(TaskDetailUiState.NotFound) }
|
.recoveringFromProviderFailure { TaskDetailUiState.NotFound }
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ import de.jeanlucmakiola.floret.time.formatTime
|
|||||||
import de.jeanlucmakiola.agendula.ui.common.localToInstant
|
import de.jeanlucmakiola.agendula.ui.common.localToInstant
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.agendula.ui.common.toLocalDate
|
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||||
|
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||||
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
|
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
|
||||||
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
@@ -178,7 +179,7 @@ private fun EditContent(
|
|||||||
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
|
var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showListPicker by rememberSaveable { mutableStateOf(false) }
|
var showListPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showParentPicker by rememberSaveable { mutableStateOf(false) }
|
var showParentPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -653,7 +654,7 @@ private fun ScheduleRow(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = value.formatDate(),
|
text = value.formatDate(allDay),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = valueColor,
|
color = valueColor,
|
||||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||||
@@ -689,12 +690,15 @@ private fun DateTimePickerFlow(
|
|||||||
onResult: (Instant) -> Unit,
|
onResult: (Instant) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var pendingDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
|
var pendingDate by rememberSaveable { mutableStateOf<java.time.LocalDate?>(null) }
|
||||||
var showTime by remember { mutableStateOf(false) }
|
var showTime by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
if (!showTime) {
|
if (!showTime) {
|
||||||
|
// M3's DatePicker speaks UTC millis. An all-day value is already UTC-based,
|
||||||
|
// a timed one is read in the device zone — calendarDate picks the right frame
|
||||||
|
// so the dialog opens on the day the rest of the UI shows.
|
||||||
val initialMillis = (initial ?: nowInstant())
|
val initialMillis = (initial ?: nowInstant())
|
||||||
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
.calendarDate(allDay).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
||||||
DatePickerDialog(
|
DatePickerDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -703,7 +707,7 @@ private fun DateTimePickerFlow(
|
|||||||
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
|
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
|
||||||
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
if (allDay) {
|
if (allDay) {
|
||||||
onResult(localToInstant(date, LocalTime.MIDNIGHT))
|
onResult(allDayInstantOf(date))
|
||||||
} else {
|
} else {
|
||||||
pendingDate = date
|
pendingDate = date
|
||||||
showTime = true
|
showTime = true
|
||||||
@@ -865,7 +869,7 @@ private fun ParentPickerSheet(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
title = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
||||||
position = positionOf(index, section.tasks.size),
|
position = positionOf(index, section.tasks.size),
|
||||||
summary = task.due?.formatDate(),
|
summary = task.due?.formatDate(task.isAllDay),
|
||||||
selected = task.taskId == selectedId,
|
selected = task.taskId == selectedId,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
onClick = { choose(task.taskId) },
|
onClick = { choose(task.taskId) },
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.populatedFields
|
import de.jeanlucmakiola.agendula.domain.populatedFields
|
||||||
|
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -70,6 +71,15 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
private var editingTaskId: Long? = null
|
private var editingTaskId: Long? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the form has already been populated. The host `LaunchedEffect`
|
||||||
|
* re-fires whenever the composition restarts — an Activity recreation
|
||||||
|
* (rotation, theme/font/display-size change, split-screen, unfolding) — while
|
||||||
|
* this ViewModel survives on the nav back stack. Without this guard the
|
||||||
|
* rebind would overwrite in-progress edits with the untouched provider row.
|
||||||
|
*/
|
||||||
|
private var bound = false
|
||||||
|
|
||||||
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
|
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
|
||||||
private var baselineLastModified: Instant? = null
|
private var baselineLastModified: Instant? = null
|
||||||
|
|
||||||
@@ -78,6 +88,8 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||||
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
|
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
|
||||||
|
if (bound) return
|
||||||
|
bound = true
|
||||||
editingTaskId = null
|
editingTaskId = null
|
||||||
baselineLastModified = null
|
baselineLastModified = null
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -103,6 +115,8 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Load an existing task for editing. */
|
/** Load an existing task for editing. */
|
||||||
fun bindEdit(taskId: Long) {
|
fun bindEdit(taskId: Long) {
|
||||||
|
if (bound && editingTaskId == taskId) return
|
||||||
|
bound = true
|
||||||
editingTaskId = taskId
|
editingTaskId = taskId
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
defaultFields = settingsPrefs.settings.first().defaultEditFields
|
defaultFields = settingsPrefs.settings.first().defaultEditFields
|
||||||
@@ -126,6 +140,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
priority = task.priority,
|
priority = task.priority,
|
||||||
parentId = task.parentId,
|
parentId = task.parentId,
|
||||||
percentComplete = task.percentComplete,
|
percentComplete = task.percentComplete,
|
||||||
|
reminderMinutesBeforeDue = repository.reminderFor(taskId),
|
||||||
lists = lists,
|
lists = lists,
|
||||||
parentCandidates = loadParents(task.listId, selfId = taskId),
|
parentCandidates = loadParents(task.listId, selfId = taskId),
|
||||||
),
|
),
|
||||||
@@ -178,7 +193,19 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
||||||
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
|
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
|
||||||
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
|
/**
|
||||||
|
* All-day and timed values use different conventions (UTC midnight vs. a real
|
||||||
|
* instant in the device zone), so the switch has to move the timestamps too —
|
||||||
|
* flipping the flag alone makes an all-day task read back as "02:00", which
|
||||||
|
* looks to the user like the time reset itself.
|
||||||
|
*/
|
||||||
|
fun onAllDayChange(value: Boolean) = update {
|
||||||
|
it.copy(
|
||||||
|
isAllDay = value,
|
||||||
|
start = it.start?.rebasedForAllDay(value),
|
||||||
|
due = it.due?.rebasedForAllDay(value),
|
||||||
|
)
|
||||||
|
}
|
||||||
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
||||||
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
|
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
|
||||||
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
|
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
|
||||||
|
|||||||
@@ -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 = {
|
||||||
ExtendedFloatingActionButton(
|
// The FAB would otherwise float over the search results.
|
||||||
onClick = onNewTask,
|
if (!searchActive) {
|
||||||
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
ExtendedFloatingActionButton(
|
||||||
text = { Text(stringResource(R.string.new_task)) },
|
onClick = onNewTask,
|
||||||
)
|
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||||
|
text = { Text(stringResource(R.string.new_task)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
when (val s = state) {
|
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
|
||||||
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
when (val s = state) {
|
||||||
ListsUiState.Failure -> CenteredMessage(stringResource(R.string.lists_failure), inner)
|
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||||
is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter)
|
ListsUiState.Failure ->
|
||||||
|
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,27 +243,372 @@ 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,
|
||||||
|
) {
|
||||||
|
// Search occupies the flexible space left of settings. Pulled into its
|
||||||
|
// own composable so AnimatedVisibility resolves to the plain overload —
|
||||||
|
// 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),
|
||||||
) {
|
) {
|
||||||
counts.chunked(2).forEach { row ->
|
Row(
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
modifier = Modifier.padding(start = 18.dp),
|
||||||
row.forEach { smart ->
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
SmartCard(
|
) {
|
||||||
count = smart,
|
BasicTextField(
|
||||||
modifier = Modifier.weight(1f),
|
value = query,
|
||||||
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) },
|
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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (row.size == 1) Spacer(Modifier.weight(1f))
|
}
|
||||||
|
// 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(
|
||||||
|
count = smart,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.floret.time.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
@@ -12,7 +13,6 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -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.
|
||||||
}.catch { emit(ListsUiState.Failure) }
|
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
||||||
|
) { lists, openTasks, completedTasks ->
|
||||||
|
buildContent(lists, openTasks, completedTasks) as ListsUiState
|
||||||
|
}.recoveringFromProviderFailure { 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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ import de.jeanlucmakiola.floret.components.positionOf
|
|||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
@@ -483,10 +484,10 @@ private fun RemindersScreen(
|
|||||||
if (showOffset) {
|
if (showOffset) {
|
||||||
ReminderLeadPicker(
|
ReminderLeadPicker(
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
selected = ReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
selected = ReminderOverride.Minutes(listOf(state.settings.reminderLeadMinutes)),
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
allowNone = false,
|
allowNone = false,
|
||||||
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
|
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes.first()) },
|
||||||
onDismiss = { showOffset = false },
|
onDismiss = { showOffset = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -504,14 +505,8 @@ private fun RemindersScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
||||||
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride {
|
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride =
|
||||||
val map = state.settings.perListReminderOverride
|
state.settings.perListReminderOverride.reminderOverrideFor(listId)
|
||||||
return when {
|
|
||||||
!map.containsKey(listId) -> ReminderOverride.Inherit
|
|
||||||
map[listId] == null -> ReminderOverride.None
|
|
||||||
else -> ReminderOverride.Minutes(map.getValue(listId)!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Row summary for a list: its override, or the inherited global default. */
|
/** Row summary for a list: its override, or the inherited global default. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -519,7 +514,7 @@ private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): S
|
|||||||
ReminderOverride.Inherit ->
|
ReminderOverride.Inherit ->
|
||||||
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
||||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes.first())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import de.jeanlucmakiola.agendula.data.prefs.Settings
|
|||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -33,8 +33,14 @@ class SettingsViewModel @Inject constructor(
|
|||||||
repository: TasksRepository,
|
repository: TasksRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// MainActivity collects this for the theme, above the permission gate and for
|
||||||
|
// the whole Activity lifetime — so the list flow must survive the pre-grant
|
||||||
|
// SecurityException and recover once permission is given, not die for good.
|
||||||
val state: StateFlow<SettingsUiState> =
|
val state: StateFlow<SettingsUiState> =
|
||||||
combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists ->
|
combine(
|
||||||
|
prefs.settings,
|
||||||
|
repository.taskLists().recoveringFromProviderFailure { emptyList() },
|
||||||
|
) { settings, lists ->
|
||||||
SettingsUiState(settings, lists)
|
SettingsUiState(settings, lists)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
@@ -12,7 +13,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -87,7 +87,10 @@ class TaskListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onStart { emit(TaskListUiState.Loading) }
|
.onStart { emit(TaskListUiState.Loading) }
|
||||||
.catch { emit(TaskListUiState.Failure) }
|
// Recover rather than terminate: a provider hiccup (mid-update,
|
||||||
|
// permission not yet granted) shows Failure but keeps retrying,
|
||||||
|
// so the screen heals itself instead of staying stuck.
|
||||||
|
.recoveringFromProviderFailure { TaskListUiState.Failure }
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
||||||
|
|
||||||
@@ -112,6 +115,9 @@ class TaskListViewModel @Inject constructor(
|
|||||||
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
|
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Without this an exception here escapes stateIn's coroutine, past
|
||||||
|
// viewModelScope's SupervisorJob, and crashes the process.
|
||||||
|
.recoveringFromProviderFailure { emptyMap() }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||||
|
|
||||||
/** The screen reports which expanded parents need their children fetched. */
|
/** The screen reports which expanded parents need their children fetched. */
|
||||||
|
|||||||
@@ -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">
|
||||||
<path
|
<group
|
||||||
android:fillColor="#00000000"
|
android:pivotX="256"
|
||||||
android:strokeColor="#FFFFFF"
|
android:pivotY="242.76"
|
||||||
android:strokeWidth="9"
|
android:scaleX="0.66"
|
||||||
android:strokeLineCap="round"
|
android:scaleY="0.66"
|
||||||
android:strokeLineJoin="round"
|
android:translateY="13.24">
|
||||||
android:pathData="M36,55 l13,13 l25,-27" />
|
<!-- Task card (rounded square, opening at the bottom-right for the bloom) -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="20"
|
||||||
|
android:strokeMiterLimit="12"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -50,6 +50,31 @@ class TaskMapperTest {
|
|||||||
assertThat(task.isSubtask).isTrue()
|
assertThat(task.isSubtask).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recurrence is detected from rrule when is_recurring is absent`() {
|
||||||
|
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column;
|
||||||
|
// reading it alone would report the series as one-off and send its edits
|
||||||
|
// to the master row, re-anchoring the whole thing.
|
||||||
|
val task = TaskMapper.task(
|
||||||
|
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")),
|
||||||
|
)
|
||||||
|
assertThat(task.isRecurring).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recurrence is detected from rdate alone`() {
|
||||||
|
val task = TaskMapper.task(
|
||||||
|
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")),
|
||||||
|
)
|
||||||
|
assertThat(task.isRecurring).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a plain task is not recurring`() {
|
||||||
|
val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
|
||||||
|
assertThat(task.isRecurring).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `falls back to instance id when task_id missing, and list color when no task color`() {
|
fun `falls back to instance id when task_id missing, and list color when no task color`() {
|
||||||
val task = TaskMapper.task(
|
val task = TaskMapper.task(
|
||||||
|
|||||||
@@ -85,6 +85,57 @@ class TaskWriteMapperTest {
|
|||||||
assertThat(values[Tasks.TZ]).isNull()
|
assertThat(values[Tasks.TZ]).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day timestamps are pinned to UTC midnight`() {
|
||||||
|
// 2026-07-20T22:00Z — i.e. local midnight on the 21st in Berlin (UTC+2).
|
||||||
|
// The provider resolves all-day dates against UTC, so storing this as-is
|
||||||
|
// would land the task on the 20th for anyone reading it back.
|
||||||
|
val berlinMidnight = Instant.fromEpochMilliseconds(1_784_412_000_000L)
|
||||||
|
val values = TaskWriteMapper.taskValues(
|
||||||
|
TaskForm(title = "Holiday", listId = 1L, start = berlinMidnight, due = berlinMidnight, isAllDay = true),
|
||||||
|
tzId = "Europe/Berlin",
|
||||||
|
)
|
||||||
|
|
||||||
|
val dayMs = 24L * 60 * 60 * 1000
|
||||||
|
assertThat(values[Tasks.DUE] as Long % dayMs).isEqualTo(0L)
|
||||||
|
assertThat(values[Tasks.DTSTART] as Long % dayMs).isEqualTo(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed timestamps are written untouched`() {
|
||||||
|
val at = Instant.fromEpochMilliseconds(1_784_412_345_678L)
|
||||||
|
val values = TaskWriteMapper.taskValues(
|
||||||
|
TaskForm(title = "Standup", listId = 1L, start = at, due = at),
|
||||||
|
tzId = "Europe/Berlin",
|
||||||
|
)
|
||||||
|
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L)
|
||||||
|
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duration is always cleared so it cannot collide with due`() {
|
||||||
|
// The provider validates the *merged* row and throws "Only one of DUE or
|
||||||
|
// DURATION must be supplied" if the stored row still carries a duration.
|
||||||
|
val values = TaskWriteMapper.taskValues(
|
||||||
|
TaskForm(title = "x", listId = 1L, due = Instant.fromEpochMilliseconds(5_000L)),
|
||||||
|
tzId = "UTC",
|
||||||
|
)
|
||||||
|
assertThat(values.containsKey(Tasks.DURATION)).isTrue()
|
||||||
|
assertThat(values[Tasks.DURATION]).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instance values drop list and parent, which an override cannot express`() {
|
||||||
|
val form = TaskForm(title = "x", listId = 4L, parentId = 7L, due = Instant.fromEpochMilliseconds(1_000L))
|
||||||
|
val values = TaskWriteMapper.instanceValues(form, tzId = "UTC")
|
||||||
|
|
||||||
|
assertThat(values.containsKey(Tasks.LIST_ID)).isFalse()
|
||||||
|
assertThat(values.containsKey(Tasks.PARENT_ID)).isFalse()
|
||||||
|
// …but still carries the edit itself.
|
||||||
|
assertThat(values[Tasks.TITLE]).isEqualTo("x")
|
||||||
|
assertThat(values[Tasks.DUE]).isEqualTo(1_000L)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `completion sets status, percent and timestamp, un-completion clears them`() {
|
fun `completion sets status, percent and timestamp, un-completion clears them`() {
|
||||||
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
|
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
|
||||||
@@ -97,6 +148,22 @@ class TaskWriteMapperTest {
|
|||||||
assertThat(undone[Tasks.COMPLETED]).isNull()
|
assertThat(undone[Tasks.COMPLETED]).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `alarm carries every column the provider's validator demands`() {
|
||||||
|
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
|
||||||
|
|
||||||
|
assertThat(values[TasksContract.Properties.TASK_ID]).isEqualTo(12L)
|
||||||
|
assertThat(values[TasksContract.Properties.MIMETYPE])
|
||||||
|
.isEqualTo("vnd.android.cursor.item/alarm")
|
||||||
|
assertThat(values[TasksContract.Alarm.MINUTES_BEFORE]).isEqualTo(30)
|
||||||
|
// REFERENCE must be present and non-negative, ALARM_TYPE present and
|
||||||
|
// non-zero (0 is excluded from the provider's has_alarms count).
|
||||||
|
assertThat(values[TasksContract.Alarm.REFERENCE]).isEqualTo(TasksContract.Alarm.REFERENCE_DUE)
|
||||||
|
assertThat(values[TasksContract.Alarm.ALARM_TYPE]).isEqualTo(TasksContract.Alarm.TYPE_MESSAGE)
|
||||||
|
// property_id must be absent or the insert is rejected.
|
||||||
|
assertThat(values.containsKey(TasksContract.Properties.PROPERTY_ID)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `local list uses the LOCAL account`() {
|
fun `local list uses the LOCAL account`() {
|
||||||
val values = TaskWriteMapper.localListValues("Inbox", 0x123)
|
val values = TaskWriteMapper.localListValues("Inbox", 0x123)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class AllDayTimeTest {
|
||||||
|
|
||||||
|
private val berlin = ZoneId.of("Europe/Berlin") // UTC+2 in July
|
||||||
|
private val newYork = ZoneId.of("America/New_York") // UTC-4 in July
|
||||||
|
private val julyTwentieth = LocalDate.of(2026, 7, 20)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day instant is UTC midnight of its date`() {
|
||||||
|
val instant = allDayInstantOf(julyTwentieth)
|
||||||
|
assertThat(instant.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0L)
|
||||||
|
assertThat(instant.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day date reads the same everywhere, unlike a timed one`() {
|
||||||
|
val allDay = allDayInstantOf(julyTwentieth)
|
||||||
|
// The whole point: zone must not change which day an all-day value denotes.
|
||||||
|
assertThat(allDay.calendarDate(allDay = true, zone = berlin)).isEqualTo(julyTwentieth)
|
||||||
|
assertThat(allDay.calendarDate(allDay = true, zone = newYork)).isEqualTo(julyTwentieth)
|
||||||
|
// Read as a timed value in New York it would slip to the 19th — the bug.
|
||||||
|
assertThat(allDay.calendarDate(allDay = false, zone = newYork)).isEqualTo(julyTwentieth.minusDays(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggling all-day off keeps the day and lands on local midnight`() {
|
||||||
|
val allDay = allDayInstantOf(julyTwentieth)
|
||||||
|
val timed = allDay.rebasedForAllDay(allDay = false, zone = berlin)
|
||||||
|
|
||||||
|
assertThat(timed.calendarDate(allDay = false, zone = berlin)).isEqualTo(julyTwentieth)
|
||||||
|
val local = java.time.Instant.ofEpochMilli(timed.toEpochMilliseconds()).atZone(berlin)
|
||||||
|
assertThat(local.toLocalTime()).isEqualTo(java.time.LocalTime.MIDNIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggling all-day on keeps the day the user was looking at`() {
|
||||||
|
// 2026-07-20T23:30 in Berlin — late enough that a naive UTC read slips a day.
|
||||||
|
val lateEvening = Instant.fromEpochMilliseconds(
|
||||||
|
julyTwentieth.atTime(23, 30).atZone(berlin).toInstant().toEpochMilli(),
|
||||||
|
)
|
||||||
|
val allDay = lateEvening.rebasedForAllDay(allDay = true, zone = berlin)
|
||||||
|
|
||||||
|
assertThat(allDay.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-tripping the toggle is stable`() {
|
||||||
|
val original = allDayInstantOf(julyTwentieth)
|
||||||
|
val there = original.rebasedForAllDay(allDay = false, zone = newYork)
|
||||||
|
val back = there.rebasedForAllDay(allDay = true, zone = newYork)
|
||||||
|
assertThat(back).isEqualTo(original)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
design/icon/agendula_icon.svg
Normal file
11
design/icon/agendula_icon.svg
Normal 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 |
@@ -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/`) |
|
||||||
|
|
||||||
|
|||||||
@@ -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 0–99. 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.
|
|
||||||
|
|||||||
0
fastlane/metadata/android/en-US/changelogs/.gitkeep
Normal file
0
fastlane/metadata/android/en-US/changelogs/.gitkeep
Normal file
30
fastlane/metadata/android/en-US/changelogs/200.txt
Normal file
30
fastlane/metadata/android/en-US/changelogs/200.txt
Normal 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.
|
||||||
|
|
||||||
5
fastlane/metadata/android/en-US/changelogs/201.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/201.txt
Normal 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.
|
||||||
|
|
||||||
5
fastlane/metadata/android/en-US/changelogs/202.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/202.txt
Normal 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.
|
||||||
|
|
||||||
14
fastlane/metadata/android/en-US/changelogs/300.txt
Normal file
14
fastlane/metadata/android/en-US/changelogs/300.txt
Normal 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.
|
||||||
|
|
||||||
5
fastlane/metadata/android/en-US/changelogs/301.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/301.txt
Normal 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.
|
||||||
|
|
||||||
6
fastlane/metadata/android/en-US/changelogs/302.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/302.txt
Normal 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.
|
||||||
|
|
||||||
1
fastlane/metadata/android/en-US/title.txt
Normal file
1
fastlane/metadata/android/en-US/title.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Agendula
|
||||||
Submodule floret-kit updated: 566caf4305...396e538903
5
release-notes.md
Normal file
5
release-notes.md
Normal 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.
|
||||||
|
|
||||||
71
scripts/check_reproducible_release.sh
Executable file
71
scripts/check_reproducible_release.sh
Executable 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."
|
||||||
48
scripts/fastlane_to_fdroid_localized.sh
Executable file
48
scripts/fastlane_to_fdroid_localized.sh
Executable 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'"
|
||||||
41
scripts/sync_changelog_to_fastlane.sh
Executable file
41
scripts/sync_changelog_to_fastlane.sh
Executable 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
49
scripts/verify-release.sh
Executable 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)."
|
||||||
Reference in New Issue
Block a user