Compare commits
46 Commits
v0.1.0
...
release/v0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f166ef5f0 | |||
| 41bd49826a | |||
| 05c75bafa7 | |||
| cfa25b9730 | |||
| 9d7fc64b0b | |||
| 4aa65edb45 | |||
| a9843e25b7 | |||
| 5f4711dabe | |||
| 6d3fbc05c1 | |||
| 721b411579 | |||
| 046b8f7e9e | |||
| 06cc9b1c8b | |||
| 2d366a7be3 | |||
| 52aeebcb53 | |||
| 8f6b85008c | |||
| eb1e530ce7 | |||
| 7516972e9f | |||
| 7f58f81fe1 | |||
| b266653e4e | |||
| 2f5012dc34 | |||
| ccc07e86c7 | |||
| 211450bc46 | |||
| 9d134be621 | |||
| 37e3c4e659 | |||
| 78632865f9 | |||
| e7a62e71df | |||
| 4c05c86e95 | |||
| 37662e83bb | |||
| 63c6191676 | |||
| 4994f5ec2c | |||
| e4c8dbf2c1 | |||
| 069ae38b2c | |||
| a5ddf16537 | |||
| 18e03e27b6 | |||
| 152226c1b2 | |||
| ec7b696eb9 | |||
| 76a0139fda | |||
| d84ac60757 | |||
| 2c457f3915 | |||
| e0c56a73e2 | |||
| 3db553da85 | |||
| 11f9649dd7 | |||
| 3397e57794 | |||
| 24cf8fe331 | |||
| a19b1373a4 | |||
| b196d9ebe0 |
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
|
||||
|
||||
# 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:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags-ignore:
|
||||
- '**'
|
||||
pull_request:
|
||||
|
||||
# Cancel superseded runs on the same branch.
|
||||
# Cancel superseded runs for the same PR.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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:
|
||||
runs-on: docker
|
||||
env:
|
||||
@@ -21,14 +26,54 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history so the base..HEAD diff below has a merge-base.
|
||||
fetch-depth: 0
|
||||
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
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
# Default ("tools platform-tools") drags in the Android Emulator
|
||||
@@ -36,12 +81,14 @@ jobs:
|
||||
packages: ''
|
||||
|
||||
- name: Setup Android SDK cache
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /opt/android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||
|
||||
- name: Install Android SDK packages
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager \
|
||||
@@ -50,6 +97,7 @@ jobs:
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Setup Gradle cache
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
@@ -60,21 +108,25 @@ jobs:
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
# No --no-daemon: the daemon lives only as long as this job container
|
||||
# and lets the following steps skip JVM startup + reconfiguration.
|
||||
- name: Lint (debug variant only)
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew lintDebug
|
||||
|
||||
- name: Unit tests
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Assemble debug APK
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Trivy filesystem scan
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: |
|
||||
set -e
|
||||
SUDO=""
|
||||
|
||||
@@ -1,78 +1,99 @@
|
||||
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:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
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
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
outputs:
|
||||
is_release: ${{ steps.v.outputs.is_release }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
version_code: ${{ steps.v.outputs.version_code }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
submodules: recursive
|
||||
|
||||
- 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
|
||||
- name: Resolve version and whether it is a new release
|
||||
id: v
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-37.0" \
|
||||
"build-tools;36.0.0"
|
||||
set -e
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
|
||||
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
|
||||
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
|
||||
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
|
||||
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
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: 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
|
||||
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||
# re-uploads the existing index (recovery path).
|
||||
release:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: docker
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
VERSION: ${{ needs.detect.outputs.version }}
|
||||
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||
IS_RELEASE: ${{ needs.detect.outputs.is_release }}
|
||||
PRERELEASE: ${{ needs.detect.outputs.prerelease }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
@@ -121,31 +142,26 @@ jobs:
|
||||
$SUDO apk add --no-cache jq
|
||||
fi
|
||||
|
||||
# Tag-only build steps. On a manual workflow_dispatch (ref = a branch,
|
||||
# not a tag) these are skipped: the job then just re-signs the existing
|
||||
# index with the configured repo key and re-uploads — used for key
|
||||
# rotation / repo recovery without publishing a new APK.
|
||||
- name: Set version from git tag
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
# The committed versionName is the source of truth. Pin versionCode to the
|
||||
# value derived from it so the published APK's code is always
|
||||
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
||||
- name: Pin versionCode to versionName
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: |
|
||||
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
|
||||
grep -E 'versionName|versionCode' app/build.gradle.kts
|
||||
# Export for later steps (F-Droid changelog, mapping asset name).
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV"
|
||||
|
||||
# Test the exact commit being shipped (only on a real release).
|
||||
- name: Unit tests
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Setup Android Keystore
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: env.IS_RELEASE == 'true'
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
@@ -160,11 +176,8 @@ jobs:
|
||||
storeFile=upload-keystore.jks
|
||||
EOF
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Build release APK
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Setup F-Droid Server Tools
|
||||
@@ -202,8 +215,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
# Fail loudly if the repo key is not configured. NEVER auto-generate
|
||||
# one: a fresh key changes the repo fingerprint and breaks every
|
||||
# user's pinned repo. (Replaces the old `fdroid update --create-key`
|
||||
# path, which silently rotated the key on a wiped server.)
|
||||
# user's pinned repo.
|
||||
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
|
||||
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
|
||||
@@ -216,42 +228,33 @@ jobs:
|
||||
mkdir -p fdroid/repo/icons
|
||||
|
||||
- name: Copy new APK to repo
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p fdroid/repo
|
||||
REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||||
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/floret_${SAFE_REF_NAME}.apk"
|
||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.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: |
|
||||
mkdir -p fdroid/metadata
|
||||
cp -r fdroid-metadata/* fdroid/metadata/
|
||||
|
||||
# Per-version "What's New" for F-Droid clients: the tag's CHANGELOG
|
||||
# section written to changelogs/<versionCode>.txt (same extraction as the
|
||||
# Gitea release notes). en-US only — F-Droid falls back to it for locales
|
||||
# without their own changelog. fdroid update bakes this into the index.
|
||||
- name: Generate F-Droid changelog for this version
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
set -e
|
||||
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.floret/en-US/changelogs"
|
||||
mkdir -p "$CL_DIR"
|
||||
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
|
||||
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
|
||||
# App-level control file (Categories/License/links) for the self-hosted
|
||||
# repo's `fdroid update`.
|
||||
cp fdroid-metadata/de.jeanlucmakiola.agendula.yml fdroid/metadata/
|
||||
# Localized text + graphics + per-version changelogs come from the SAME
|
||||
# fastlane tree the official F-Droid repo harvests from source,
|
||||
# transformed into the F-Droid repo "localized" layout. One source of
|
||||
# truth, both channels.
|
||||
bash scripts/fastlane_to_fdroid_localized.sh \
|
||||
fastlane/metadata/android \
|
||||
fdroid/metadata/de.jeanlucmakiola.agendula
|
||||
|
||||
- name: Generate F-Droid Index
|
||||
run: |
|
||||
@@ -272,97 +275,47 @@ jobs:
|
||||
SFTP
|
||||
# Publish the signed repo/ plus metadata/ (descriptions, screenshots,
|
||||
# per-version changelogs) so changelog history survives across
|
||||
# releases. keystore.p12 and config.yml are NEVER uploaded, so they
|
||||
# can't re-enter the web-served tree; nginx serves only repo/ anyway.
|
||||
# releases. keystore.p12 and config.yml are NEVER uploaded.
|
||||
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.
|
||||
# Attached to the Gitea 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: startsWith(github.ref, 'refs/tags/')
|
||||
continue-on-error: true
|
||||
# The APK is published and the index re-signed — now record the release.
|
||||
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
||||
# this commit, so the tag only ever marks a fully-shipped release (and a
|
||||
# failure before here leaves no tag, so re-running the workflow retries).
|
||||
- name: Create tag + Gitea release
|
||||
if: env.IS_RELEASE == 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
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="${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
|
||||
|
||||
- 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.
|
||||
TAG="v$VERSION"
|
||||
# Notes = this version's CHANGELOG section.
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > release-notes.md
|
||||
# Trim leading blank lines.
|
||||
sed -i -e '/./,$!d' release-notes.md
|
||||
if [ ! -s release-notes.md ]; then
|
||||
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
|
||||
fi
|
||||
echo "--- release notes ---"
|
||||
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
|
||||
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json
|
||||
import json, sys
|
||||
print(json.dumps({
|
||||
"tag_name": sys.argv[1],
|
||||
"target_commitish": sys.argv[2],
|
||||
"name": sys.argv[1],
|
||||
"body": open("release-notes.md").read(),
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||
"prerelease": sys.argv[3] == "true",
|
||||
}))
|
||||
PY
|
||||
# Upsert: the build-and-deploy job may have created a bare release
|
||||
# first (to attach the mapping asset), so PATCH the notes if it
|
||||
# exists, otherwise POST a new one. Both paths are re-run safe.
|
||||
# Upsert (re-run safe): PATCH if a release for the tag already exists,
|
||||
# else POST a new one (which also creates the tag at target_commitish).
|
||||
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
||||
ID=$(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
|
||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
@@ -376,6 +329,128 @@ jobs:
|
||||
fi
|
||||
cat response.json
|
||||
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
|
||||
fi
|
||||
echo "Created/updated release $TAG at $SHA"
|
||||
|
||||
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
|
||||
# Attached to the release (it's not an APK, so it fits the no-binaries
|
||||
# rule). Best-effort: never fail a release over it.
|
||||
- name: Attach R8 mapping to Gitea release
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
run: |
|
||||
set -e
|
||||
MAP="app/build/outputs/mapping/release/mapping.txt"
|
||||
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
|
||||
TAG="v$VERSION"
|
||||
ASSET="mapping-${VERSION}.txt.gz"
|
||||
gzip -c "$MAP" > "/tmp/$ASSET"
|
||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
|
||||
# Replace any prior asset of the same name (re-run safe).
|
||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
|
||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$ASSET" \
|
||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
||||
|
||||
# Mirror the release to the Codeberg mirror as a direct-download channel
|
||||
# for users who don't want F-Droid. Gitea already push-mirrors branches +
|
||||
# tags to Codeberg, but releases aren't git objects so they don't sync —
|
||||
# we create the release there over the API and attach the signed APK plus
|
||||
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
|
||||
# key), so this adds no trust surface. Best-effort: a Codeberg outage
|
||||
# (it 504s under load) must never fail an already-published F-Droid
|
||||
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
||||
- name: Publish release to Codeberg
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "${TOKEN:-}" ]; then
|
||||
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
||||
exit 0
|
||||
fi
|
||||
TAG="v$VERSION"
|
||||
APK="app/build/outputs/apk/release/app-release.apk"
|
||||
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
|
||||
ASSET_APK="agendula_v${VERSION}.apk"
|
||||
ASSET_SUM="${ASSET_APK}.sha256"
|
||||
cp "$APK" "/tmp/$ASSET_APK"
|
||||
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
||||
|
||||
# Release notes: reuse the section extracted for the Gitea release,
|
||||
# fall back to the CHANGELOG entry if that step's file is gone.
|
||||
if [ ! -s release-notes.md ]; then
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > release-notes.md
|
||||
sed -i -e '/./,$!d' release-notes.md
|
||||
fi
|
||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
||||
# Forgejo 500s on POST /releases when the tag ALREADY exists, and a
|
||||
# bare tag is not a release — so GET /releases/tags then 404s and the
|
||||
# upsert has no id to fall back on ("Could not resolve Codeberg
|
||||
# release id", the 0.3.1 failure). Pushing the tag first therefore
|
||||
# guarantees the 500 rather than avoiding it; 0.3.0 published because
|
||||
# it POSTed while the tag was still absent. So: POST with the tag
|
||||
# ABSENT and let the API mint tag + release together from
|
||||
# target_commitish. Branches mirror reliably, so the commit is already
|
||||
# on Codeberg — only a tag the mirror raced in ahead of us is in the
|
||||
# way, and clearing it is safe precisely because no release owns it.
|
||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||
if [ -z "$ID" ]; then
|
||||
curl -s -o /dev/null -w "codeberg tag DELETE HTTP %{http_code}\n" -X DELETE \
|
||||
-H "Authorization: token $TOKEN" "$API/tags/$TAG"
|
||||
fi
|
||||
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > cb-payload.json
|
||||
import json, sys
|
||||
tag, sha, pre = sys.argv[1:4]
|
||||
print(json.dumps({
|
||||
"tag_name": tag,
|
||||
# Recreate the tag as part of the release; the commit is on
|
||||
# Codeberg already via the branch mirror.
|
||||
"target_commitish": sha,
|
||||
"name": tag,
|
||||
"body": open("release-notes.md").read(),
|
||||
"draft": False,
|
||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||
"prerelease": pre == "true",
|
||||
}))
|
||||
PY
|
||||
# Upsert (re-run safe): a release that already exists is PATCHed in
|
||||
# place — the delete above is skipped in that case, so re-running
|
||||
# never disturbs a published release.
|
||||
if [ -n "$ID" ]; then
|
||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases/$ID"
|
||||
else
|
||||
curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases"
|
||||
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi
|
||||
|
||||
# Attach APK + checksum, replacing any prior asset of the same name.
|
||||
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
|
||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$A" \
|
||||
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
||||
done
|
||||
echo "Published $TAG to Codeberg."
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "floret-kit"]
|
||||
path = floret-kit
|
||||
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
||||
82
CHANGELOG.md
82
CHANGELOG.md
@@ -1,11 +1,89 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/); the latest released git tag is
|
||||
the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
|
||||
[Keep a Changelog](https://keepachangelog.com/); the `versionName` committed in
|
||||
`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]
|
||||
|
||||
## [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
|
||||
- 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.
|
||||
|
||||
## [0.2.2] - 2026-07-19
|
||||
|
||||
### 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
|
||||
|
||||
### 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.
|
||||
|
||||
## [0.1.0] - 2026-06-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing to Floret
|
||||
# Contributing to Agendula
|
||||
|
||||
Thanks for your interest in Floret — a Material 3 Expressive task app that's a
|
||||
Thanks for your interest in Agendula — a Material 3 Expressive task app that's a
|
||||
pure front-end over the OpenTasks `TaskContract` provider, with no own database
|
||||
or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
|
||||
(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
|
||||
@@ -30,11 +30,12 @@ would expose provider details to a ViewModel or the UI, it's in the wrong layer.
|
||||
```sh
|
||||
./gradlew :app:assembleDebug # build the debug APK
|
||||
./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
|
||||
push, so run these locally before opening a PR. Keep CI green.
|
||||
CI (`.gitea/workflows/ci.yaml`) runs a reproducible-release invariant check,
|
||||
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
|
||||
|
||||
@@ -75,12 +76,14 @@ push, so run these locally before opening a PR. Keep CI green.
|
||||
- If your change shifts the architecture or completes a milestone, update
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
|
||||
in the same PR.
|
||||
- Don't bump `versionName` / `versionCode` by hand — the git tag drives those at
|
||||
release time.
|
||||
- Don't bump `versionName` / `versionCode` in a regular PR — the committed
|
||||
`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
|
||||
|
||||
Floret stays true to its thesis: a front-end over **open** task backends
|
||||
Agendula stays true to its thesis: a front-end over **open** task backends
|
||||
(CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends
|
||||
(Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning
|
||||
a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's
|
||||
|
||||
14
README.md
14
README.md
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
<h1>Floret</h1>
|
||||
<h1>Agendula</h1>
|
||||
|
||||
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
|
||||
Reads, writes, and reminds — on top of an existing tasks provider, with no own
|
||||
@@ -13,14 +13,16 @@ sync stack.</p>
|
||||
|
||||
</div>
|
||||
|
||||
Floret is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula).
|
||||
Where Calendula is a pure front-end over Android's `CalendarContract`, Floret is
|
||||
Agendula is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula).
|
||||
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is
|
||||
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
|
||||
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
|
||||
database, no reinvented sync.
|
||||
|
||||
A Calendula flower head is botanically made of many small *florets* — the
|
||||
individual items that make up the bloom. Floret is those items: your tasks.
|
||||
The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for
|
||||
“things to be done” — given Calendula's `-ula` ending. Calendula keeps your days;
|
||||
Agendula keeps your to-dos. (A Calendula flower head is botanically a cluster of
|
||||
many small *florets* — so the two apps are florets of one bloom.)
|
||||
|
||||
> **Status: data layer done, UI in progress.** The full non-visual stack over
|
||||
> the `TaskContract` provider — provider resolution, live-updating reads,
|
||||
@@ -32,7 +34,7 @@ individual items that make up the bloom. Floret is those items: your tasks.
|
||||
|
||||
## Sync sources (by design)
|
||||
|
||||
Floret works with anything that writes to the tasks provider — **DAVx5**
|
||||
Agendula works with anything that writes to the tasks provider — **DAVx5**
|
||||
(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync
|
||||
adapter — because it builds on the provider, not on any one sync app. Google
|
||||
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
|
||||
|
||||
@@ -16,20 +16,21 @@ val keystoreProperties = Properties().apply {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.jeanlucmakiola.floret"
|
||||
namespace = "de.jeanlucmakiola.agendula"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.jeanlucmakiola.floret"
|
||||
applicationId = "de.jeanlucmakiola.agendula"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
// The git tag is the single source of truth for released builds: at
|
||||
// release time .gitea/workflows/release.yaml derives both fields from
|
||||
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
||||
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
||||
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
||||
versionCode = 100
|
||||
versionName = "0.1.0"
|
||||
// These committed values ARE the source of truth for a release: merging
|
||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
||||
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
||||
versionCode = 302
|
||||
versionName = "0.3.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -47,6 +48,11 @@ android {
|
||||
|
||||
buildTypes {
|
||||
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
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
@@ -61,6 +67,22 @@ android {
|
||||
applicationIdSuffix = ".debug"
|
||||
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 {
|
||||
@@ -73,6 +95,16 @@ android {
|
||||
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 {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
@@ -110,6 +142,7 @@ dependencies {
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
@@ -119,6 +152,12 @@ dependencies {
|
||||
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation("de.jeanlucmakiola.floret:core-time")
|
||||
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
implementation("de.jeanlucmakiola.floret:components")
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
|
||||
15
app/proguard-rules.pro
vendored
15
app/proguard-rules.pro
vendored
@@ -2,5 +2,20 @@
|
||||
-keep class dagger.hilt.** { *; }
|
||||
-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
|
||||
-dontwarn org.jetbrains.annotations.**
|
||||
|
||||
@@ -30,15 +30,16 @@
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".FloretApp"
|
||||
android:name=".AgendulaApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Floret"
|
||||
android:theme="@style/Theme.Agendula"
|
||||
tools:targetApi="35">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -51,6 +52,14 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Standalone crash-report surface; MainActivity routes here on a
|
||||
startup crash-loop. Not exported, kept out of recents. -->
|
||||
<activity
|
||||
android:name=".ui.crash.CrashReportActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<!-- Reminder alarm fires here (internal PendingIntent → not exported). -->
|
||||
<receiver
|
||||
android:name=".data.reminders.DueReminderReceiver"
|
||||
@@ -76,6 +85,17 @@
|
||||
<data android:scheme="content" android:host="org.dmfs.tasks" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Persists the per-app language on API < 33, where the platform
|
||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret
|
||||
package de.jeanlucmakiola.agendula
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.EntryPoint
|
||||
@@ -6,22 +6,35 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Application entry point. Registered as android:name=".FloretApp". Besides
|
||||
* Application entry point. Registered as android:name=".AgendulaApp". Besides
|
||||
* Hilt init, it kicks off a reminder re-sync on launch (independent of any UI),
|
||||
* so alarms reflect tasks synced while the app was closed.
|
||||
*/
|
||||
@HiltAndroidApp
|
||||
class FloretApp : Application() {
|
||||
class AgendulaApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Install first thing so startup crashes are captured too (privacy-
|
||||
// respecting, on-device; the user submits the report by hand).
|
||||
CrashReporter.install(
|
||||
this,
|
||||
CrashConfig(
|
||||
appLabel = getString(R.string.app_name),
|
||||
newIssueUrl = getString(R.string.report_issue_url),
|
||||
chooseIssueUrl = getString(R.string.report_issue_url),
|
||||
issueTitle = getString(R.string.crash_report_issue_title),
|
||||
),
|
||||
)
|
||||
val scheduler = EntryPointAccessors
|
||||
.fromApplication(this, ReminderEntryPoint::class.java)
|
||||
.reminderScheduler()
|
||||
114
app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt
Normal file
114
app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt
Normal file
@@ -0,0 +1,114 @@
|
||||
package de.jeanlucmakiola.agendula
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.agendula.data.demo.DemoSeeder
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
|
||||
* (replaceable) functional scaffold over the real data layer. Task-detail intent
|
||||
* routing for reminder taps lands with the full UI.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var demoSeeder: DemoSeeder
|
||||
|
||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||
// over the app on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
private var pendingCrashReport by mutableStateOf<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// If the app keeps crashing as it starts, the main UI can't be trusted
|
||||
// to come up. Route to the standalone report screen instead of
|
||||
// re-entering the crashing graph.
|
||||
if (CrashReporter.isCrashLoop(this)) {
|
||||
startActivity(Intent(this, CrashReportActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Surface a single captured crash as a dialog on the next launch.
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
|
||||
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
|
||||
// local (non-syncing) demo list once; no-op without the extra.
|
||||
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
|
||||
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
|
||||
}
|
||||
setContent {
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||
val darkTheme = when (ui.settings.themeMode) {
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
|
||||
RootScreen(modifier = Modifier.fillMaxSize())
|
||||
pendingCrashReport?.let { report ->
|
||||
CrashReportDialog(
|
||||
report = report,
|
||||
onSend = {
|
||||
submitCrashReport(this@MainActivity, report)
|
||||
CrashReporter.clearReport(this@MainActivity)
|
||||
pendingCrashReport = null
|
||||
},
|
||||
onDismiss = {
|
||||
CrashReporter.dismissPrompt(this@MainActivity)
|
||||
pendingCrashReport = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// A successful start breaks any loop; reset the timing trail so a later
|
||||
// ordinary crash isn't mistaken for a loop.
|
||||
CrashReporter.markHealthy(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||
private const val EXTRA_SEED = "agendula_seed"
|
||||
|
||||
/** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
|
||||
fun taskIntent(context: Context, taskId: Long): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
putExtra(EXTRA_TASK_ID, taskId)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package de.jeanlucmakiola.floret.data.demo
|
||||
package de.jeanlucmakiola.agendula.data.demo
|
||||
|
||||
import de.jeanlucmakiola.floret.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.domain.DayWindow
|
||||
import de.jeanlucmakiola.floret.domain.Priority
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -16,10 +16,10 @@ import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Debug-only one-shot sample data. Creates a **local, device-only** list
|
||||
* ("Floret Demo") — so nothing syncs to a CalDAV server — and fills it with
|
||||
* ("Agendula Demo") — so nothing syncs to a CalDAV server — and fills it with
|
||||
* tasks spread across overdue / today / upcoming / no-date / completed so every
|
||||
* smart list shows content. Idempotent: skips if the demo list already exists.
|
||||
* Triggered from [de.jeanlucmakiola.floret.MainActivity] only in debug builds.
|
||||
* Triggered from [de.jeanlucmakiola.agendula.MainActivity] only in debug builds.
|
||||
*/
|
||||
@Singleton
|
||||
class DemoSeeder @Inject constructor(
|
||||
@@ -47,14 +47,14 @@ class DemoSeeder @Inject constructor(
|
||||
repository.createTask(TaskForm(title = "Gather receipts", listId = listId, parentId = invoiceId))
|
||||
repository.createTask(TaskForm(title = "Book train tickets", listId = listId, due = at(te + 6 * day), priority = Priority.LOW))
|
||||
repository.createTask(TaskForm(title = "Read Compose 1.5 release notes", listId = listId))
|
||||
repository.createTask(TaskForm(title = "Sketch the Floret app icon", listId = listId))
|
||||
repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId))
|
||||
|
||||
val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day)))
|
||||
repository.setCompleted(done, completed = true)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEMO_LIST = "Floret Demo"
|
||||
const val DEMO_LIST = "Agendula Demo"
|
||||
const val DEMO_COLOR = 0xFF7A5C6B.toInt()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.data.di
|
||||
package de.jeanlucmakiola.agendula.data.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
@@ -10,16 +10,16 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.floret.data.tasks.AndroidTasksDataSource
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepositoryImpl
|
||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.floretDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "floret_prefs",
|
||||
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "agendula_prefs",
|
||||
)
|
||||
|
||||
@Module
|
||||
@@ -42,7 +42,7 @@ object DataProvideModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.floretDataStore
|
||||
context.agendulaDataStore
|
||||
|
||||
@Provides
|
||||
@IoDispatcher
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.data.di
|
||||
package de.jeanlucmakiola.agendula.data.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package de.jeanlucmakiola.agendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderLeadFor
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||
|
||||
data class Settings(
|
||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
val dynamicColor: Boolean = true,
|
||||
/** The list a new task defaults to; `null` = first available. */
|
||||
val defaultListId: Long? = null,
|
||||
/** Default minutes before due to remind; 0 = at due time. */
|
||||
val reminderLeadMinutes: Int = 0,
|
||||
/** Master switch for due reminders; off clears every scheduled alarm. */
|
||||
val remindersEnabled: Boolean = true,
|
||||
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */
|
||||
val showAddSubtaskRow: Boolean = true,
|
||||
/**
|
||||
* Add affordance for a list: `false` = the floating "New task" button (opens
|
||||
* the editor); `true` = a quick-add bar pinned to the bottom of a real list.
|
||||
* Smart lists always use the button (they have no single list to add into).
|
||||
*/
|
||||
val bottomAddBar: Boolean = false,
|
||||
/**
|
||||
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
||||
* overrides the global default (a null value = no reminder); absent = inherit.
|
||||
*/
|
||||
val perListReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
||||
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
||||
) {
|
||||
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
||||
fun reminderLeadFor(listId: Long): Int? =
|
||||
perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes)
|
||||
}
|
||||
|
||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||
@Singleton
|
||||
class SettingsPrefs @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
val settings: Flow<Settings> = dataStore.data.map { p ->
|
||||
Settings(
|
||||
themeMode = p[THEME_MODE]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
|
||||
?: ThemeMode.SYSTEM,
|
||||
dynamicColor = p[DYNAMIC_COLOR] ?: true,
|
||||
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
|
||||
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
|
||||
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
|
||||
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
|
||||
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
|
||||
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
|
||||
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
|
||||
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
|
||||
.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
|
||||
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
|
||||
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
|
||||
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
|
||||
}
|
||||
|
||||
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
|
||||
|
||||
/** One-time reminder onboarding gate; false until the step has been shown. */
|
||||
val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
|
||||
|
||||
suspend fun setReminderOnboardingDone() = dataStore.edit { it[REMINDER_ONBOARDING_DONE] = true }
|
||||
|
||||
suspend fun setRemindersEnabled(enabled: Boolean) = dataStore.edit { it[REMINDERS_ENABLED] = enabled }
|
||||
|
||||
suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show }
|
||||
|
||||
suspend fun setBottomAddBar(enabled: Boolean) = dataStore.edit { it[BOTTOM_ADD_BAR] = enabled }
|
||||
|
||||
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
||||
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
||||
current.applyReminderOverride(listId, override)
|
||||
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
|
||||
}
|
||||
|
||||
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
|
||||
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
|
||||
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
|
||||
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
|
||||
val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
|
||||
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
|
||||
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
|
||||
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
|
||||
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
|
||||
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agendula's stored dialect for the per-list override map: `id:minutes` entries
|
||||
* joined by `;` (the family default). Fixed at release — don't change without a
|
||||
* data migration.
|
||||
*/
|
||||
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.data.reminders
|
||||
package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -1,13 +1,15 @@
|
||||
package de.jeanlucmakiola.floret.data.reminders
|
||||
package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -21,6 +23,7 @@ class DueReminderReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var dataSource: TasksDataSource
|
||||
@Inject lateinit var notifier: TaskNotifier
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@@ -30,6 +33,7 @@ class DueReminderReceiver : BroadcastReceiver() {
|
||||
val pending = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
if (!settingsPrefs.settings.first().remindersEnabled) return@launch
|
||||
val task = runCatching { dataSource.task(taskId) }.getOrNull()
|
||||
if (task != null && !task.isClosed) notifier.postDue(task)
|
||||
} finally {
|
||||
@@ -39,7 +43,7 @@ class DueReminderReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.floret.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)
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.data.reminders
|
||||
package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -12,7 +12,7 @@ import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Re-syncs reminders whenever the tasks provider changes — covers external sync
|
||||
* (DAVx5 pulling new/edited tasks) while Floret isn't in the foreground. The
|
||||
* (DAVx5 pulling new/edited tasks) while Agendula isn't in the foreground. The
|
||||
* manifest filter targets both known authorities; best-effort (the in-app
|
||||
* ContentObserver covers the foreground case regardless).
|
||||
*/
|
||||
@@ -1,15 +1,15 @@
|
||||
package de.jeanlucmakiola.floret.data.reminders
|
||||
package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.floret.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.floret.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -18,7 +18,7 @@ import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The self-scheduled due-reminder engine. Tasks providers don't deliver
|
||||
* reminders, so Floret reads upcoming due tasks and arms one exact [AlarmManager]
|
||||
* reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager]
|
||||
* alarm each, within a rolling window. Re-run on app start, boot and provider
|
||||
* change; it diffs against [ScheduledReminderStore] so only changed alarms move.
|
||||
*/
|
||||
@@ -33,11 +33,11 @@ class ReminderScheduler @Inject constructor(
|
||||
) {
|
||||
suspend fun sync() = withContext(io) {
|
||||
val provider = providerResolver.resolve()
|
||||
if (provider == null || !providerResolver.hasPermission(provider)) {
|
||||
val settings = settingsPrefs.settings.first()
|
||||
if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) {
|
||||
clearAll()
|
||||
return@withContext
|
||||
}
|
||||
val lead = settingsPrefs.settings.first().reminderLeadMinutes.coerceAtLeast(0)
|
||||
val now = System.currentTimeMillis()
|
||||
val horizon = now + WINDOW_MS
|
||||
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
||||
@@ -45,7 +45,13 @@ class ReminderScheduler @Inject constructor(
|
||||
|
||||
val desired = tasks
|
||||
.filter { !it.isClosed && it.due != null }
|
||||
.associate { it.taskId to (it.due!!.toEpochMilliseconds() - lead * 60_000L) }
|
||||
.mapNotNull { task ->
|
||||
// The task's list may override the global lead, or opt out entirely
|
||||
// (override = null), in which case it gets no reminder at all.
|
||||
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
|
||||
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
|
||||
}
|
||||
.toMap()
|
||||
.filterValues { it in now..horizon }
|
||||
|
||||
val previous = store.all()
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.data.reminders
|
||||
package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.data.reminders
|
||||
package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
@@ -12,9 +12,9 @@ import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.jeanlucmakiola.floret.MainActivity
|
||||
import de.jeanlucmakiola.floret.R
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.time.Instant as JInstant
|
||||
import java.time.ZoneId
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
@@ -9,12 +9,12 @@ import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.database.Cursor
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
/** No tasks provider (OpenTasks / tasks.org) is installed on the device. */
|
||||
class ProviderUnavailableException :
|
||||
@@ -7,3 +7,10 @@ class ProviderUnavailableException :
|
||||
/** A ContentResolver write returned no URI or affected no rows. */
|
||||
class TaskWriteFailedException(operation: String) :
|
||||
RuntimeException("Task write failed: $operation")
|
||||
|
||||
/**
|
||||
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
|
||||
* it, so saving would clobber that change. The UI offers to overwrite or reload.
|
||||
*/
|
||||
class TaskConflictException(taskId: Long) :
|
||||
RuntimeException("Task $taskId changed since it was loaded")
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
@@ -8,7 +8,7 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* A tasks provider Floret can talk to. The same dmfs `TaskProvider` backs every
|
||||
* A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every
|
||||
* candidate, so the [TasksContract] columns apply regardless of which is present.
|
||||
*/
|
||||
data class TaskProvider(
|
||||
@@ -1,12 +1,12 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import de.jeanlucmakiola.floret.domain.priorityFromICal
|
||||
import de.jeanlucmakiola.floret.domain.statusFromInt
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */
|
||||
@@ -1,8 +1,8 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||
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. */
|
||||
object TaskProjections {
|
||||
@@ -1,9 +1,9 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.floret.domain.toICal
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
|
||||
/**
|
||||
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
||||
@@ -17,6 +17,27 @@ object TaskWriteMapper {
|
||||
put(Tasks.LIST_ID, form.listId)
|
||||
put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null })
|
||||
put(Tasks.PRIORITY, form.priority.toICal())
|
||||
// Keep completion in sync with progress whenever the form carries a
|
||||
// percent (the Progress field was used). The provider already auto-
|
||||
// completes at 100% but won't reopen below it — left to itself it strands
|
||||
// a task "done at 75%" — so we set status both ways. COMPLETED is only
|
||||
// cleared on reopen; at 100% we leave it for the provider to fill/keep so
|
||||
// re-saving a finished task doesn't churn its completion timestamp. When
|
||||
// no percent is present the standalone complete toggle stays authoritative.
|
||||
val percent = form.percentComplete?.coerceIn(0, 100)
|
||||
put(Tasks.PERCENT_COMPLETE, percent)
|
||||
when {
|
||||
percent == null -> Unit
|
||||
percent >= 100 -> put(Tasks.STATUS, TasksContract.STATUS_COMPLETED)
|
||||
percent > 0 -> {
|
||||
put(Tasks.STATUS, TasksContract.STATUS_IN_PROCESS)
|
||||
put(Tasks.COMPLETED, null)
|
||||
}
|
||||
else -> {
|
||||
put(Tasks.STATUS, TasksContract.STATUS_NEEDS_ACTION)
|
||||
put(Tasks.COMPLETED, null)
|
||||
}
|
||||
}
|
||||
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
|
||||
put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
|
||||
put(Tasks.DUE, form.due?.toEpochMilliseconds())
|
||||
@@ -1,9 +1,9 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* The subset of the OpenTasks `TaskContract` that Floret uses, vendored as
|
||||
* The subset of the OpenTasks `TaskContract` that Agendula uses, vendored as
|
||||
* Kotlin constants. Derived from `org.dmfs.tasks.contract.TaskContract`
|
||||
* (Apache-2.0, dmfs GmbH):
|
||||
* https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java
|
||||
@@ -25,7 +25,7 @@ object TasksContract {
|
||||
const val PARAM_ACCOUNT_TYPE = "account_type"
|
||||
const val LOAD_PROPERTIES = "load_properties"
|
||||
|
||||
/** Account used for local, device-only task lists Floret owns. */
|
||||
/** Account used for local, device-only task lists Agendula owns. */
|
||||
const val LOCAL_ACCOUNT_NAME = "Local"
|
||||
const val LOCAL_ACCOUNT_TYPE = "org.dmfs.account.LOCAL"
|
||||
|
||||
@@ -85,7 +85,7 @@ object TasksContract {
|
||||
* The `instances` view — one row per task occurrence, with recurrence
|
||||
* resolved and absolute [INSTANCE_START]/[INSTANCE_DUE] timestamps. Inherits
|
||||
* every [Tasks] column (title, status, …) plus the instance-only columns
|
||||
* below. This is Floret's read source.
|
||||
* below. This is Agendula's read source.
|
||||
*/
|
||||
object Instances {
|
||||
const val PATH = "instances"
|
||||
@@ -1,8 +1,8 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
|
||||
/** What to fetch from the provider. Smart-list date logic is applied above this. */
|
||||
data class TaskQuery(
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
|
||||
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
||||
|
||||
/**
|
||||
* The single entry point the UI layer uses. Flows re-emit automatically when the
|
||||
* provider changes (our writes *and* external sync like DAVx5), via the
|
||||
* data source's ContentObserver.
|
||||
*/
|
||||
interface TasksRepository {
|
||||
fun taskLists(): Flow<List<TaskList>>
|
||||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||||
|
||||
/**
|
||||
* The direct children of [parentId], re-emitting live like the other flows.
|
||||
* Lets a smart-list view pull a parent's full subtask set on demand — those
|
||||
* children are usually filtered out of the list's own (date-based) query.
|
||||
*/
|
||||
fun subtasks(parentId: Long): Flow<List<Task>>
|
||||
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
||||
|
||||
suspend fun createTask(form: TaskForm): Long
|
||||
|
||||
/**
|
||||
* Overwrite [taskId] with [form]. When [expectedLastModified] is non-null, the
|
||||
* task's current `last_modified` is re-checked first and a
|
||||
* [TaskConflictException] is thrown if it differs — i.e. something changed it
|
||||
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
|
||||
*/
|
||||
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
||||
suspend fun setCompleted(taskId: Long, completed: Boolean)
|
||||
suspend fun deleteTask(taskId: Long)
|
||||
suspend fun createLocalList(name: String, color: Int): Long
|
||||
|
||||
/** Synchronous snapshot for the permission/onboarding gate. */
|
||||
fun providerStatus(): ProviderStatus
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.floret.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.domain.DayWindow
|
||||
import de.jeanlucmakiola.floret.domain.SmartList
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskDetail
|
||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
||||
import de.jeanlucmakiola.floret.domain.TaskFiltering
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import de.jeanlucmakiola.floret.domain.TaskSorting
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSorting
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
@@ -23,6 +22,7 @@ import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Singleton
|
||||
class TasksRepositoryImpl @Inject constructor(
|
||||
@@ -35,28 +35,64 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
|
||||
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
||||
|
||||
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
|
||||
dataSource.subtasks(parentId)
|
||||
.filter { it.taskId != parentId }
|
||||
.sortedWith(TaskSorting.DEFAULT)
|
||||
}
|
||||
|
||||
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
|
||||
dataSource.task(taskId)?.let { task ->
|
||||
TaskDetail(task, dataSource.subtasks(taskId).filter { it.taskId != taskId })
|
||||
TaskDetail(
|
||||
task = task,
|
||||
subtasks = dataSource.subtasks(taskId).filter { it.taskId != taskId },
|
||||
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadTasks(filter: TaskFilter): List<Task> {
|
||||
val query = when (filter) {
|
||||
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
|
||||
is TaskFilter.Smart -> TaskQuery(includeCompleted = filter.list == SmartList.COMPLETED)
|
||||
// Read completed tasks too, then let [TaskFiltering.matches] enforce each
|
||||
// smart list's own open/closed semantics. We over-read on purpose so a
|
||||
// parent's subtask-progress reflects ALL its children — completed ones
|
||||
// included — even on smart lists that hide completed tasks themselves.
|
||||
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
|
||||
}
|
||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||
return dataSource.tasks(query)
|
||||
val all = dataSource.tasks(query)
|
||||
// (done, total) of direct children per parent, over the unfiltered set.
|
||||
val progress: Map<Long, Pair<Int, Int>> = all
|
||||
.filter { (it.parentId ?: 0L) > 0L }
|
||||
.groupingBy { it.parentId!! }
|
||||
.fold(0 to 0) { (done, total), t ->
|
||||
(if (t.isCompleted) done + 1 else done) to (total + 1)
|
||||
}
|
||||
return all
|
||||
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
|
||||
.map { task ->
|
||||
progress[task.taskId]?.let { (done, total) ->
|
||||
task.copy(subtaskDone = done, subtaskTotal = total)
|
||||
} ?: task
|
||||
}
|
||||
.sortedWith(TaskSorting.DEFAULT)
|
||||
}
|
||||
|
||||
override suspend fun createTask(form: TaskForm): Long =
|
||||
withContext(io) { dataSource.insertTask(form) }
|
||||
|
||||
override suspend fun updateTask(taskId: Long, form: TaskForm) =
|
||||
withContext(io) { dataSource.updateTask(taskId, form) }
|
||||
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
|
||||
withContext(io) {
|
||||
// Conflict-safe overwrite: re-read just before writing and 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) {
|
||||
val current = dataSource.task(taskId)?.lastModified
|
||||
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
|
||||
}
|
||||
dataSource.updateTask(taskId, form)
|
||||
}
|
||||
|
||||
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
|
||||
withContext(io) { dataSource.setCompleted(taskId, completed) }
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** A task list (the `tasklists` table). Lists group under their account. */
|
||||
@@ -14,7 +14,7 @@ data class TaskList(
|
||||
val isVisible: Boolean,
|
||||
val owner: String?,
|
||||
) {
|
||||
/** A device-only list Floret (or another app) created locally, not synced. */
|
||||
/** A device-only list Agendula (or another app) created locally, not synced. */
|
||||
val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE
|
||||
}
|
||||
|
||||
@@ -52,6 +52,13 @@ data class Task(
|
||||
val distanceFromCurrent: Int?,
|
||||
val created: Instant?,
|
||||
val lastModified: Instant?,
|
||||
/**
|
||||
* Direct-subtask progress, filled in by the repository over the *full* task
|
||||
* set so a parent's progress chip counts completed children even on smart
|
||||
* lists that hide them. Both 0 when the task has no subtasks.
|
||||
*/
|
||||
val subtaskTotal: Int = 0,
|
||||
val subtaskDone: Int = 0,
|
||||
) {
|
||||
val isCompleted: Boolean get() = status == TaskStatus.COMPLETED
|
||||
val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED
|
||||
@@ -60,10 +67,11 @@ data class Task(
|
||||
val effectiveColor: Int get() = taskColor ?: listColor
|
||||
}
|
||||
|
||||
/** Detail bundle: a task plus its direct children. */
|
||||
/** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */
|
||||
data class TaskDetail(
|
||||
val task: Task,
|
||||
val subtasks: List<Task>,
|
||||
val parent: Task? = null,
|
||||
)
|
||||
|
||||
// --- pure provider <-> domain value mapping (unit-testable) ------------------
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A validated create/edit form. Kept free of Android types so ViewModels and
|
||||
* tests can build and validate it on the JVM. The data layer turns it into
|
||||
* provider ContentValues ([de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper]).
|
||||
*/
|
||||
data class TaskForm(
|
||||
val title: String,
|
||||
val listId: Long,
|
||||
val description: String? = null,
|
||||
val start: Instant? = null,
|
||||
val due: Instant? = null,
|
||||
val isAllDay: Boolean = false,
|
||||
val priority: Priority = Priority.NONE,
|
||||
val parentId: Long? = null,
|
||||
/** 0..100 progress; `null` leaves it unset (cleared). */
|
||||
val percentComplete: Int? = null,
|
||||
/** Minutes before [due] to fire a reminder; `null` = no reminder. */
|
||||
val reminderMinutesBeforeDue: Int? = null,
|
||||
) {
|
||||
fun validate(): Set<TaskFormError> = buildSet {
|
||||
if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
|
||||
if (listId <= 0L) add(TaskFormError.NO_LIST)
|
||||
if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
|
||||
if (reminderMinutesBeforeDue != null && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE)
|
||||
}
|
||||
|
||||
val isValid: Boolean get() = validate().isEmpty()
|
||||
}
|
||||
|
||||
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE }
|
||||
|
||||
/**
|
||||
* Optional edit-form sections that can be shown or tucked behind "More fields"
|
||||
* (the core Title / List / When are always visible). Which render by default is
|
||||
* a setting; the rest unfold on demand. Declaring these as an enum keeps the
|
||||
* disclosure generic, so adding a field later is one entry plus its card.
|
||||
*/
|
||||
enum class TaskFormField { Description, Priority, Progress, Parent, Reminder }
|
||||
|
||||
/** The optional fields that already carry a value — auto-revealed when editing. */
|
||||
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
|
||||
if (!description.isNullOrBlank()) add(TaskFormField.Description)
|
||||
if (priority != Priority.NONE) add(TaskFormField.Priority)
|
||||
if ((percentComplete ?: 0) > 0) add(TaskFormField.Progress)
|
||||
if (parentId != null && parentId > 0) add(TaskFormField.Parent)
|
||||
if (reminderMinutesBeforeDue != null) add(TaskFormField.Reminder)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** A due-date bucket a task list groups its rows under. */
|
||||
enum class TaskSection { OVERDUE, TODAY, UPCOMING, NO_DATE, COMPLETED }
|
||||
|
||||
/** A non-empty run of tasks under one [TaskSection], already in display order. */
|
||||
data class SectionedTasks(val section: TaskSection, val tasks: List<Task>)
|
||||
|
||||
/**
|
||||
* Group already-sorted tasks into due-date sections (the standard task-app
|
||||
* presentation). Pure + side-effect-free so it unit-tests with a fixed clock,
|
||||
* mirroring [TaskFiltering] / [DayWindow]. Completed tasks always fall to the
|
||||
* [TaskSection.COMPLETED] bucket regardless of their due date.
|
||||
*
|
||||
* [todayStart] is local midnight today and [todayEnd] local midnight tomorrow
|
||||
* (compute with [DayWindow]). Only non-empty sections are returned, in the
|
||||
* fixed order Overdue → Today → Upcoming → No date → Completed.
|
||||
*/
|
||||
object TaskSections {
|
||||
|
||||
fun of(tasks: List<Task>, todayStart: Instant, todayEnd: Instant): List<SectionedTasks> {
|
||||
val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd) }
|
||||
return TaskSection.entries.mapNotNull { section ->
|
||||
bySection[section]?.takeIf { it.isNotEmpty() }?.let { SectionedTasks(section, it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
|
||||
if (task.isCompleted) return TaskSection.COMPLETED
|
||||
val due = task.due ?: return TaskSection.NO_DATE
|
||||
return when {
|
||||
due < todayStart -> TaskSection.OVERDUE
|
||||
due < todayEnd -> TaskSection.TODAY
|
||||
else -> TaskSection.UPCOMING
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
/** Default task ordering, extracted so it's unit-testable without the repository. */
|
||||
object TaskSorting {
|
||||
@@ -1,7 +1,8 @@
|
||||
package de.jeanlucmakiola.floret.ui
|
||||
package de.jeanlucmakiola.agendula.ui
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -17,15 +18,16 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.floret.R
|
||||
import de.jeanlucmakiola.floret.data.tasks.ProviderStatus
|
||||
import de.jeanlucmakiola.floret.ui.lists.ListsScreen
|
||||
import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||
import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
|
||||
import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingViewModel
|
||||
|
||||
/**
|
||||
* App root: gates on the tasks-provider permission, then shows the home
|
||||
* ([ListsScreen]). Navigation to the task-list / edit screens is wired as those
|
||||
* screens land (next milestones); the callbacks are stubs for now.
|
||||
* App root: gates on the tasks-provider permission, then hands off to
|
||||
* [AgendulaNavHost] (lists → task list → detail / edit).
|
||||
*/
|
||||
@Composable
|
||||
fun RootScreen(
|
||||
@@ -50,11 +52,31 @@ fun RootScreen(
|
||||
action = stringResource(R.string.onboarding_permission_button),
|
||||
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
|
||||
)
|
||||
ProviderStatus.READY -> ListsScreen(
|
||||
ProviderStatus.READY -> ReadyGate(modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Second one-time gate after the provider grant: the reminder onboarding step.
|
||||
* [ReminderOnboardingViewModel.onboardingDone] is null until DataStore's first
|
||||
* emission — render nothing for that frame rather than flash the wrong screen.
|
||||
* A cross-fade eases the hand-off to the app instead of snapping.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReadyGate(
|
||||
modifier: Modifier = Modifier,
|
||||
onboardingViewModel: ReminderOnboardingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val done by onboardingViewModel.onboardingDone.collectAsStateWithLifecycle()
|
||||
Crossfade(targetState = done, label = "reminderOnboardingGate") { state ->
|
||||
when (state) {
|
||||
true -> AgendulaNavHost(modifier = modifier)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = onboardingViewModel::finish,
|
||||
modifier = modifier,
|
||||
onOpenFilter = { /* TODO: navigate to TaskListScreen */ },
|
||||
onNewTask = { /* TODO: navigate to TaskEditScreen */ },
|
||||
)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,32 @@
|
||||
package de.jeanlucmakiola.floret.ui.common
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Soften a raw provider colour toward a pastel that fits the active theme —
|
||||
* keeps the hue (lists stay recognisable), caps saturation so harsh sync
|
||||
* colours stop screaming, pins brightness to read on light and dark. Copied
|
||||
* from Calendula so the families' colours behave identically.
|
||||
*/
|
||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
||||
hsv[2] = if (dark) 0.82f else 0.72f
|
||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading avatar for a task list: a neutral round chip holding a checklist glyph
|
||||
* tinted in the list's (pastelised) colour — the Calendula calendar-chip pattern,
|
||||
* with a task icon for Floret.
|
||||
* with a task icon for Agendula.
|
||||
*/
|
||||
@Composable
|
||||
fun ListColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||
@@ -53,3 +46,25 @@ fun ListColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small pill naming the task's list, filled in the list's (pastelised) colour.
|
||||
* The label flips black/white by the fill's luminance so it stays legible on any
|
||||
* hue in light and dark. Used in mixed views (smart lists) where rows span lists.
|
||||
*/
|
||||
@Composable
|
||||
fun ListNameChip(name: String, color: Int, modifier: Modifier = Modifier) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val fill = pastelize(color, dark)
|
||||
val onFill = if (fill.luminance() > 0.5f) Color.Black else Color.White
|
||||
Surface(color = fill, shape = RoundedCornerShape(50), modifier = modifier) {
|
||||
Text(
|
||||
text = name,
|
||||
color = onFill,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Flag
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
|
||||
/**
|
||||
* Priority has no semantic Material role (M3 only ships `error` for danger), so
|
||||
* we give the ranked levels their own hues — green / amber / red. They're softened
|
||||
* harder than the list-colour [pastelize] (lower saturation, higher value) so the
|
||||
* chips stay gentle and low-key rather than shouting. [Priority.NONE] has no
|
||||
* colour (callers render it neutral).
|
||||
*/
|
||||
fun priorityFill(priority: Priority, dark: Boolean): Color {
|
||||
val base = when (priority) {
|
||||
Priority.HIGH -> 0xFFE53935.toInt() // red
|
||||
Priority.MEDIUM -> 0xFFFFB300.toInt() // amber
|
||||
Priority.LOW -> 0xFF43A047.toInt() // green
|
||||
Priority.NONE -> return Color.Unspecified
|
||||
}
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(base, hsv)
|
||||
hsv[1] = if (dark) 0.36f else 0.22f // desaturate toward pastel
|
||||
hsv[2] = if (dark) 0.58f else 0.92f // light, soft fill in light; muted in dark
|
||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground tint (icon/text) for a priority level — the same red/amber/green as
|
||||
* [priorityFill], but kept saturated enough to read as a small glyph directly on
|
||||
* the surface rather than as a soft fill. Used by the task list's quiet meta line,
|
||||
* where priority is a tinted flag instead of a filled pill. [Priority.NONE] is
|
||||
* unspecified (callers render it neutral / omit it).
|
||||
*/
|
||||
fun priorityAccent(priority: Priority, dark: Boolean): Color {
|
||||
val base = when (priority) {
|
||||
Priority.HIGH -> 0xFFE53935.toInt() // red
|
||||
Priority.MEDIUM -> 0xFFFB8C00.toInt() // orange-amber (darker than the fill for contrast)
|
||||
Priority.LOW -> 0xFF43A047.toInt() // green
|
||||
Priority.NONE -> return Color.Unspecified
|
||||
}
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(base, hsv)
|
||||
hsv[1] = if (dark) 0.55f else 0.85f
|
||||
hsv[2] = if (dark) 0.85f else 0.62f // light on dark surfaces; deep on light ones
|
||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||
}
|
||||
|
||||
/**
|
||||
* A coloured status pill for a task's priority: a flag plus the level's [label],
|
||||
* filled in that level's pastel hue. Used read-only in the list and detail
|
||||
* screens (default [selected] = true), and as a selectable option in the edit
|
||||
* form — there [selected] = false shows the hue on the flag with an outlined,
|
||||
* unfilled container, and tapping commits the choice via [onClick].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PriorityChip(
|
||||
priority: Priority,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean = true,
|
||||
showIcon: Boolean = true,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val fill = priorityFill(priority, dark)
|
||||
val hasColor = fill.isSpecified
|
||||
|
||||
val container: Color
|
||||
val content: Color
|
||||
val iconTint: Color
|
||||
var border: BorderStroke? = null
|
||||
when {
|
||||
selected && hasColor -> {
|
||||
container = fill
|
||||
content = if (fill.luminance() > 0.5f) Color.Black else Color.White
|
||||
iconTint = content
|
||||
}
|
||||
selected -> { // NONE, selected — neutral fill, no hue to show
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
content = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
iconTint = content
|
||||
}
|
||||
else -> { // an unselected option in a picker — outline, hue kept on the flag
|
||||
container = Color.Transparent
|
||||
content = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
iconTint = if (hasColor) fill else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
|
||||
val body: @Composable () -> Unit = {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showIcon) {
|
||||
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = content)
|
||||
}
|
||||
}
|
||||
val shape = RoundedCornerShape(50)
|
||||
if (onClick != null) {
|
||||
Surface(onClick = onClick, color = container, shape = shape, border = border, modifier = modifier) { body() }
|
||||
} else {
|
||||
Surface(color = container, shape = shape, border = border, modifier = modifier) { body() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
|
||||
/** Common reminder lead times offered as quick picks in the reminder pickers. */
|
||||
val REMINDER_PRESETS = listOf(0, 5, 10, 30, 60, 1_440)
|
||||
|
||||
@StringRes
|
||||
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||
ReminderUnit.Hours -> R.string.reminder_unit_hours
|
||||
ReminderUnit.Days -> R.string.reminder_unit_days
|
||||
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
|
||||
}
|
||||
|
||||
/**
|
||||
* Humanise a reminder lead time (minutes before due) into one line: "At time of
|
||||
* task" (0), "10 minutes before", "1 hour before", … Shared by the reminder
|
||||
* pickers and the settings summaries so the wording never drifts. Ported from
|
||||
* Calendula.
|
||||
*/
|
||||
@Composable
|
||||
fun reminderLeadTimeLabel(minutes: Int): String = when {
|
||||
minutes <= 0 -> stringResource(R.string.reminder_at_due)
|
||||
minutes % 10_080 == 0 ->
|
||||
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
||||
minutes % 1_440 == 0 ->
|
||||
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
||||
minutes % 60 == 0 ->
|
||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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 de.jeanlucmakiola.agendula.R
|
||||
|
||||
/**
|
||||
* Reminder lead-time picker, full-screen: the grouped list of preset lead times
|
||||
* plus a "Custom" row that expands an inline amount field + unit selector.
|
||||
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
|
||||
* a "None" row. Returns the choice as a [ReminderOverride]. Ported from
|
||||
* Calendula's ReminderDefaultPicker.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderLeadPicker(
|
||||
title: String,
|
||||
selected: ReminderOverride,
|
||||
allowInherit: Boolean,
|
||||
allowNone: Boolean,
|
||||
onSelect: (ReminderOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
presets: List<Int> = REMINDER_PRESETS,
|
||||
) {
|
||||
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
|
||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
|
||||
var unit by rememberSaveable { mutableStateOf(seed.unit) }
|
||||
|
||||
val options = buildList {
|
||||
if (allowInherit) add(ReminderOverride.Inherit)
|
||||
if (allowNone) add(ReminderOverride.None)
|
||||
presets.forEach { add(ReminderOverride.Minutes(it)) }
|
||||
}
|
||||
val rowCount = options.size + 1 // + the custom row
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = reminderOverrideLabel(option),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
// Expanded, the Custom row connects downward into the editor card so the
|
||||
// two read as one grouped container.
|
||||
GroupedRow(
|
||||
title = if (customSelected) {
|
||||
stringResource(R.string.reminder_custom_with_value, reminderLeadTimeLabel(selectedMinutes!!))
|
||||
} else {
|
||||
stringResource(R.string.reminder_custom)
|
||||
},
|
||||
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
||||
selected = customSelected,
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
CustomReminderEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = { amountText = it },
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
onConfirm = { minutes ->
|
||||
onSelect(ReminderOverride.Minutes(minutes))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomReminderEditor(
|
||||
amountText: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
unit: ReminderUnit,
|
||||
onUnitChange: (ReminderUnit) -> Unit,
|
||||
onConfirm: (Int) -> Unit,
|
||||
) {
|
||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
ReminderUnit.entries.forEachIndexed { index, entry ->
|
||||
SegmentedButton(
|
||||
selected = unit == entry,
|
||||
onClick = { onUnitChange(entry) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
||||
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10")
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
enabled = amount != null,
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_custom_set))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
||||
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.graphics.shapes.RoundedPolygon
|
||||
|
||||
/**
|
||||
* A top-bar action whose icon sits in a tonal container clipped to one of the
|
||||
* M3 Expressive [MaterialShapes] (cookie, clover, sunny, …) — Agendula's playful
|
||||
* take on a plain [androidx.compose.material3.IconButton]. Each action passes
|
||||
* its own [shape] and [containerColor], so a row of them reads as a set of
|
||||
* distinct little tokens rather than identical grey glyphs.
|
||||
*
|
||||
* Pressing springs the whole shape down and gives it a turn — the scalloped
|
||||
* 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)
|
||||
@Composable
|
||||
fun ShapedActionButton(
|
||||
shape: RoundedPolygon,
|
||||
icon: ImageVector,
|
||||
contentDescription: String?,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
containerColor: Color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
size: Dp = 40.dp,
|
||||
iconSize: Dp = 22.dp,
|
||||
spinIcon: Boolean = false,
|
||||
) {
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val pressed by interaction.collectIsPressedAsState()
|
||||
// The shape (the scalloped cookie) turns and dips on press.
|
||||
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(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.scale(scale)
|
||||
.rotate(shapeRotation),
|
||||
shape = shape.toShape(),
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
interactionSource = interaction,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
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
|
||||
.size(iconSize)
|
||||
.rotate(iconRotation - shapeRotation),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Named shapes for the app's top-bar actions, so each action is recognisably
|
||||
* "its own" and new actions just pick the next unused shape. Expose the
|
||||
* expressive [MaterialShapes] through a single opt-in site.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
object ActionShapes {
|
||||
/** Settings — a 4-sided cookie (rounded, scalloped square). */
|
||||
val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided
|
||||
|
||||
/** Search — a 6-sided cookie, the same family as [Settings] but distinct. */
|
||||
val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.agendula.ui.crash
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
|
||||
/**
|
||||
* Standalone surface for a captured crash report. [MainActivity] routes here
|
||||
* when it detects a startup crash-loop (see [CrashReporter.isCrashLoop]): the
|
||||
* main UI can't be trusted to start, so this screen stays clear of the app's
|
||||
* Hilt graph, DataStore-backed theme and Compose content — it only reads the
|
||||
* report file and shows the kit's report dialog. Plain [AgendulaTheme] defaults
|
||||
* (follow-system, dynamic colour) avoid touching anything that might be the
|
||||
* cause of the crash. The reusable machinery lives in floret-kit's core-crash.
|
||||
*/
|
||||
class CrashReportActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val report = CrashReporter.pendingReport(this)
|
||||
if (report == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AgendulaTheme {
|
||||
// Opaque backdrop so the dialog doesn't float over a bare task.
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||
CrashReportDialog(
|
||||
report = report,
|
||||
onSend = {
|
||||
submitCrashReport(this, report)
|
||||
CrashReporter.clearReport(this)
|
||||
finish()
|
||||
},
|
||||
onDismiss = {
|
||||
CrashReporter.clearReport(this)
|
||||
finish()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Reaching this screen breaks the loop; reset the timing trail so a
|
||||
// later ordinary crash isn't mistaken for a loop.
|
||||
CrashReporter.markHealthy(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
package de.jeanlucmakiola.agendula.ui.detail
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.rounded.ListAlt
|
||||
import androidx.compose.material.icons.automirrored.rounded.Notes
|
||||
import androidx.compose.material.icons.rounded.AccountTree
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.Flag
|
||||
import androidx.compose.material.icons.rounded.Percent
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
|
||||
import de.jeanlucmakiola.floret.time.formatDate
|
||||
import de.jeanlucmakiola.floret.time.formatTime
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||
|
||||
/**
|
||||
* One task's detail. Each fact is its own tonal card with a leading icon (the
|
||||
* Calendula detail pattern); only genuinely-grouped things — the subtasks — use
|
||||
* the grouped-row run. Edit / delete live in the top bar.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TaskDetailScreen(
|
||||
onEdit: () -> Unit,
|
||||
onDeleted: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onOpenTask: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TaskDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.task_detail_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
val content = state as? TaskDetailUiState.Content
|
||||
IconButton(onClick = onEdit, enabled = content != null) {
|
||||
Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.edit))
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
content?.let {
|
||||
viewModel.delete(it.detail.task)
|
||||
onDeleted()
|
||||
}
|
||||
},
|
||||
enabled = content != null,
|
||||
) {
|
||||
Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.delete))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
when (val s = state) {
|
||||
TaskDetailUiState.Loading -> Unit
|
||||
TaskDetailUiState.NotFound ->
|
||||
CenteredMessage(stringResource(R.string.task_detail_not_found), inner)
|
||||
is TaskDetailUiState.Content -> DetailBody(
|
||||
detail = s.detail,
|
||||
inner = inner,
|
||||
onToggle = { viewModel.toggleComplete(s.detail.task) },
|
||||
onToggleSubtask = { viewModel.toggleComplete(it) },
|
||||
onAddSubtask = { viewModel.addSubtask(it) },
|
||||
onOpenTask = onOpenTask,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailBody(
|
||||
detail: TaskDetail,
|
||||
inner: androidx.compose.foundation.layout.PaddingValues,
|
||||
onToggle: () -> Unit,
|
||||
onToggleSubtask: (Task) -> Unit,
|
||||
onAddSubtask: (String) -> Unit,
|
||||
onOpenTask: (Long) -> Unit,
|
||||
) {
|
||||
val task = detail.task
|
||||
val dark = isSystemInDarkTheme()
|
||||
val accent = pastelize(task.effectiveColor, dark)
|
||||
val gap = 12.dp
|
||||
var subtasksExpanded by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(inner)
|
||||
// Shrink the scroll viewport by the keyboard so the focused add-subtask
|
||||
// field scrolls into view above the IME instead of being hidden.
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||
) {
|
||||
// Title + complete toggle, with a short list-coloured accent beneath.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() })
|
||||
Text(
|
||||
text = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(48.dp)
|
||||
.height(3.dp)
|
||||
.background(accent, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// One card per fact; gaps between, no shared container (they aren't a group).
|
||||
val cards = buildList<@Composable () -> Unit> {
|
||||
// Parent — only when this is a subtask. Conveys that it's filed under
|
||||
// another task (and so doesn't stand alone in the list); taps open it.
|
||||
detail.parent?.let { parent ->
|
||||
add {
|
||||
DetailCard(
|
||||
icon = Icons.Rounded.AccountTree,
|
||||
iconContentDescription = stringResource(R.string.detail_parent),
|
||||
onClick = { onOpenTask(parent.taskId) },
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.detail_part_of),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = parent.title.ifBlank { stringResource(R.string.task_untitled) },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When — the Calendula time card: date primary, time(s) secondary.
|
||||
taskWhenLines(task)?.let { (primary, secondary) ->
|
||||
add {
|
||||
DetailCard(icon = Icons.Rounded.Schedule, iconContentDescription = stringResource(R.string.detail_due)) {
|
||||
Text(primary, style = MaterialTheme.typography.titleMedium)
|
||||
if (secondary != null) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
secondary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List — icon tinted in the list colour conveys identity (no extra dot).
|
||||
add {
|
||||
DetailCard(
|
||||
icon = Icons.Rounded.Checklist,
|
||||
iconTint = accent,
|
||||
iconContentDescription = stringResource(R.string.detail_list),
|
||||
) {
|
||||
Text(
|
||||
text = task.listName ?: stringResource(R.string.detail_list),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority — a coloured status chip in the level's hue.
|
||||
if (task.priority != de.jeanlucmakiola.agendula.domain.Priority.NONE) {
|
||||
add {
|
||||
DetailCard(
|
||||
icon = Icons.Rounded.Flag,
|
||||
iconContentDescription = stringResource(R.string.detail_priority),
|
||||
) {
|
||||
PriorityChip(
|
||||
priority = task.priority,
|
||||
label = priorityLabel(task.priority),
|
||||
showIcon = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progress — shown when the task carries its own percent or has
|
||||
// subtasks (then the completion ratio fills in if no percent is set).
|
||||
val hasSubtasks = detail.subtasks.isNotEmpty()
|
||||
if ((task.percentComplete ?: 0) > 0 || hasSubtasks) {
|
||||
val pct = task.percentComplete
|
||||
?: (detail.subtasks.count { it.isCompleted } * 100 / detail.subtasks.size)
|
||||
add {
|
||||
DetailCard(icon = Icons.Rounded.Percent, iconContentDescription = stringResource(R.string.detail_progress)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(stringResource(R.string.detail_progress), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.percent_complete, pct),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(progress = { pct / 100f }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Description.
|
||||
task.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
add {
|
||||
DetailCard(icon = Icons.AutoMirrored.Rounded.Notes, iconContentDescription = null) {
|
||||
Text(description, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subtasks — a genuine group: a connected grouped run whose first
|
||||
// segment is the expand/collapse toggle. Always shown (even with no
|
||||
// subtasks yet) so the add field is reachable on any task.
|
||||
add {
|
||||
SubtasksGroup(
|
||||
subtasks = detail.subtasks,
|
||||
expanded = subtasksExpanded,
|
||||
onToggle = { subtasksExpanded = !subtasksExpanded },
|
||||
onToggleSubtask = onToggleSubtask,
|
||||
onAddSubtask = onAddSubtask,
|
||||
onOpenSubtask = onOpenTask,
|
||||
)
|
||||
}
|
||||
}
|
||||
cards.forEachIndexed { index, card ->
|
||||
if (index > 0) Spacer(Modifier.height(gap))
|
||||
card()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtasks as a connected grouped run (the GroupedRow family look) at the
|
||||
* cards' width: the first segment is the expand/collapse toggle (icon,
|
||||
* "Subtasks", a done/total count, chevron); the rest are the subtask rows.
|
||||
*/
|
||||
@Composable
|
||||
private fun SubtasksGroup(
|
||||
subtasks: List<Task>,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
onToggleSubtask: (Task) -> Unit,
|
||||
onAddSubtask: (String) -> Unit,
|
||||
onOpenSubtask: (Long) -> Unit,
|
||||
) {
|
||||
val done = subtasks.count { it.isCompleted }
|
||||
// Completed subtasks sink to the bottom; stable, so order is otherwise kept.
|
||||
val ordered = remember(subtasks) { subtasks.sortedBy { it.isCompleted } }
|
||||
val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "subtasksChevron")
|
||||
// Segments when expanded: header + the always-present add row + each subtask.
|
||||
val count = if (expanded) subtasks.size + 2 else 1
|
||||
|
||||
// First segment: the toggle header.
|
||||
GroupedSurface(position = positionOf(0, count), onClick = onToggle) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ListAlt,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.detail_subtasks),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// No count until there's something to count.
|
||||
if (subtasks.isNotEmpty()) {
|
||||
Text(
|
||||
text = "$done / ${subtasks.size}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.rotate(rotation),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
// Always-present add row at the top of the list, in an accent tone.
|
||||
GroupedSurface(
|
||||
position = positionOf(1, count),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
AddSubtaskField(onAdd = onAddSubtask)
|
||||
}
|
||||
ordered.forEachIndexed { index, sub ->
|
||||
GroupedSurface(
|
||||
position = positionOf(index + 2, count),
|
||||
onClick = { onOpenSubtask(sub.taskId) },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = sub.isCompleted, onCheckedChange = { onToggleSubtask(sub) })
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = sub.title.ifBlank { stringResource(R.string.task_untitled) },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textDecoration = if (sub.isCompleted) TextDecoration.LineThrough else null,
|
||||
color = if (sub.isCompleted) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline "add a subtask" — title only; submits on the tick or the IME action.
|
||||
* A [BasicTextField] (not the boxed Material field) so its text lines up exactly
|
||||
* with the subtask titles, the checkbox column matched by the leading + icon.
|
||||
*/
|
||||
@Composable
|
||||
private fun AddSubtaskField(onAdd: (String) -> Unit) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
fun submit() {
|
||||
if (text.isNotBlank()) {
|
||||
onAdd(text.trim())
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
val accentOn = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, tint = accentOn)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = accentOn),
|
||||
cursorBrush = SolidColor(accentOn),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||
modifier = Modifier.weight(1f),
|
||||
decorationBox = { inner ->
|
||||
if (text.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.add_task_hint),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = accentOn.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
inner()
|
||||
},
|
||||
)
|
||||
if (text.isNotBlank()) {
|
||||
IconButton(onClick = ::submit) {
|
||||
Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.cd_add_task), tint = accentOn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One info card: tonal container, leading icon in the gutter, value to the right. */
|
||||
@Composable
|
||||
private fun DetailCard(
|
||||
icon: ImageVector,
|
||||
iconContentDescription: String?,
|
||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
onClick: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
val color = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val body: @Composable () -> Unit = {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = iconContentDescription,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f), content = content)
|
||||
}
|
||||
}
|
||||
if (onClick != null) {
|
||||
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { body() }
|
||||
} else {
|
||||
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { body() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The task's "when", formatted like Calendula's event time card: a primary date
|
||||
* line and an optional secondary time line. A start+due pair reads as a range;
|
||||
* `null` when the task has no dates at all.
|
||||
*/
|
||||
@Composable
|
||||
private fun taskWhenLines(task: Task): Pair<String, String?>? {
|
||||
val start = task.start
|
||||
val due = task.due
|
||||
return when {
|
||||
start != null && due != null -> {
|
||||
val sameDay = start.formatDate() == due.formatDate()
|
||||
val primary = if (sameDay) due.formatDate() else "${start.formatDate()} – ${due.formatDate()}"
|
||||
val secondary = if (task.isAllDay) null else "${start.formatTime()} – ${due.formatTime()}"
|
||||
primary to secondary
|
||||
}
|
||||
due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime()
|
||||
start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredMessage(text: String, inner: androidx.compose.foundation.layout.PaddingValues) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package de.jeanlucmakiola.floret.ui.detail
|
||||
package de.jeanlucmakiola.agendula.ui.detail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskDetail
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -54,4 +55,15 @@ class TaskDetailViewModel @Inject constructor(
|
||||
fun delete(task: Task) = viewModelScope.launch {
|
||||
runCatching { repository.deleteTask(task.taskId) }
|
||||
}
|
||||
|
||||
/** Add a subtask (title only) under the currently-shown task. */
|
||||
fun addSubtask(title: String) = viewModelScope.launch {
|
||||
val parent = (state.value as? TaskDetailUiState.Content)?.detail?.task ?: return@launch
|
||||
if (title.isBlank()) return@launch
|
||||
runCatching {
|
||||
repository.createTask(
|
||||
TaskForm(title = title.trim(), listId = parent.listId, parentId = parent.taskId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
package de.jeanlucmakiola.agendula.ui.edit
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Notes
|
||||
import androidx.compose.material.icons.rounded.AccountTree
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.ArrowDropDown
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import androidx.compose.material.icons.rounded.Circle
|
||||
import androidx.compose.material.icons.rounded.Clear
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Flag
|
||||
import androidx.compose.material.icons.rounded.Notifications
|
||||
import androidx.compose.material.icons.rounded.Percent
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
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.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSections
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionalFormSection
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import de.jeanlucmakiola.agendula.ui.common.priorityFill
|
||||
import de.jeanlucmakiola.floret.time.formatDate
|
||||
import de.jeanlucmakiola.floret.time.formatTime
|
||||
import de.jeanlucmakiola.agendula.ui.common.localToInstant
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.agendula.ui.common.toLocalDate
|
||||
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
|
||||
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Create / edit form, in the family's tonal-card language (mirrors Calendula's
|
||||
* event editor): a borderless headline title with a list-colour accent bar, a
|
||||
* "When" card with the all-day toggle and tappable schedule rows, a tappable
|
||||
* list card, and the optional Description / Priority / Reminder sections that
|
||||
* unfold from "More fields" (which ones start open is a setting). Validated
|
||||
* through the M1 [TaskEditViewModel] / [de.jeanlucmakiola.agendula.domain.TaskForm];
|
||||
* the VM flips `saved` once the write lands, which pops us back.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TaskEditScreen(
|
||||
onSaved: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TaskEditViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(state.saved) { if (state.saved) onSaved() }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Button(
|
||||
onClick = viewModel::save,
|
||||
enabled = !state.loading,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
) { Text(stringResource(R.string.save)) }
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
if (state.loading) return@Scaffold
|
||||
EditContent(
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.padding(inner),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class PickerTarget { Start, Due }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EditContent(
|
||||
state: TaskEditUiState,
|
||||
viewModel: TaskEditViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val selectedList = state.lists.firstOrNull { it.id == state.listId }
|
||||
// The accent ties the form to the detail screen's language: the bar under
|
||||
// the title takes the target list's colour.
|
||||
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
||||
val gap = 12.dp
|
||||
|
||||
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
|
||||
var showListPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showParentPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
// Shrink the scroll viewport by the keyboard so the focused field
|
||||
// scrolls into view above the IME instead of being hidden.
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||
) {
|
||||
// Title: borderless headline + accent bar, mirroring the detail screen.
|
||||
InlineField(
|
||||
value = state.title,
|
||||
onValueChange = viewModel::onTitleChange,
|
||||
placeholder = stringResource(R.string.edit_title_hint),
|
||||
textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(48.dp)
|
||||
.height(3.dp)
|
||||
.background(accent, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
if (TaskFormError.BLANK_TITLE in state.errors) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
FieldError(R.string.error_blank_title)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// "When" card: the icon centres on the all-day row (header); the start /
|
||||
// due rows continue below in the same text column.
|
||||
EditCard(
|
||||
icon = Icons.Rounded.Schedule,
|
||||
iconContentDescription = stringResource(R.string.edit_when),
|
||||
header = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.edit_all_day),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(checked = state.isAllDay, onCheckedChange = viewModel::onAllDayChange)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
ScheduleRow(
|
||||
label = stringResource(R.string.edit_start_label),
|
||||
value = state.start,
|
||||
allDay = state.isAllDay,
|
||||
onPick = { pickerTarget = PickerTarget.Start },
|
||||
onClear = { viewModel.onStartChange(null) },
|
||||
)
|
||||
ScheduleRow(
|
||||
label = stringResource(R.string.edit_due_label),
|
||||
value = state.due,
|
||||
allDay = state.isAllDay,
|
||||
onPick = { pickerTarget = PickerTarget.Due },
|
||||
onClear = { viewModel.onDueChange(null) },
|
||||
isError = TaskFormError.DUE_BEFORE_START in state.errors,
|
||||
)
|
||||
if (TaskFormError.DUE_BEFORE_START in state.errors) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
FieldError(R.string.error_due_before_start)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(gap))
|
||||
|
||||
// List card — tap anywhere to pick the target list.
|
||||
EditCard(
|
||||
icon = Icons.Rounded.Checklist,
|
||||
iconContentDescription = stringResource(R.string.edit_list_label),
|
||||
iconTint = accent,
|
||||
onClick = { showListPicker = true }.takeIf { state.lists.isNotEmpty() },
|
||||
) {
|
||||
Text(
|
||||
text = selectedList?.name ?: stringResource(R.string.error_no_list),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (selectedList == null) MaterialTheme.colorScheme.error else Color.Unspecified,
|
||||
)
|
||||
selectedList?.accountName?.takeIf { it.isNotBlank() }?.let { account ->
|
||||
Text(
|
||||
text = account,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional sections: which start open is a setting; the rest unfold
|
||||
// behind the "More fields" button below.
|
||||
OptionalFormSection(visible = TaskFormField.Description in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
icon = Icons.AutoMirrored.Rounded.Notes,
|
||||
iconContentDescription = stringResource(R.string.field_description),
|
||||
iconAtTop = true,
|
||||
) {
|
||||
InlineField(
|
||||
value = state.description,
|
||||
onValueChange = viewModel::onDescriptionChange,
|
||||
placeholder = stringResource(R.string.field_description),
|
||||
singleLine = false,
|
||||
minLines = 3,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = TaskFormField.Priority in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
// Not an EditCard: the four buttons need the card's full width, so
|
||||
// the label sits on its own row above rather than in the indented
|
||||
// icon-gutter column (where "Medium" would wrap to a second line).
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Rounded.Flag,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.edit_priority_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
// The M3 single-select segmented row, but the active segment
|
||||
// takes its priority hue so the choice reads in colour.
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
Priority.entries.forEachIndexed { index, priority ->
|
||||
val fill = priorityFill(priority, dark)
|
||||
val active = fill.isSpecified
|
||||
SegmentedButton(
|
||||
selected = state.priority == priority,
|
||||
onClick = { viewModel.onPriorityChange(priority) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, Priority.entries.size),
|
||||
colors = SegmentedButtonDefaults.colors(
|
||||
activeContainerColor = if (active) fill else MaterialTheme.colorScheme.secondaryContainer,
|
||||
activeContentColor = when {
|
||||
!active -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
fill.luminance() > 0.5f -> Color.Black
|
||||
else -> Color.White
|
||||
},
|
||||
),
|
||||
// Drop the selected-check icon; with four buttons its
|
||||
// reserved width is what crowds the labels.
|
||||
icon = {},
|
||||
) {
|
||||
Text(
|
||||
text = priorityLabel(priority),
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = TaskFormField.Progress in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
val pct = state.percentComplete ?: 0
|
||||
// Full-width like Priority: the slider needs the card's whole width,
|
||||
// so the label/readout sit on a header row above it.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Rounded.Percent,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.edit_progress_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.percent_complete, pct),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Slider(
|
||||
value = pct.toFloat(),
|
||||
onValueChange = { viewModel.onPercentChange(it.toInt()) },
|
||||
valueRange = 0f..100f,
|
||||
steps = 19, // 5% detents
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = TaskFormField.Parent in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
icon = Icons.Rounded.AccountTree,
|
||||
iconContentDescription = stringResource(R.string.edit_parent_label),
|
||||
onClick = { showParentPicker = true },
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = state.parent?.title?.ifBlank { stringResource(R.string.task_untitled) }
|
||||
?: stringResource(R.string.edit_parent_none),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.edit_parent_label),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = TaskFormField.Reminder in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
icon = Icons.Rounded.Notifications,
|
||||
iconContentDescription = null,
|
||||
onClick = { showReminderPicker = true },
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(reminderOptionFor(state.reminderMinutesBeforeDue).labelRes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.edit_reminder_label),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
FieldError(R.string.error_reminder_without_due)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
TextButton(
|
||||
onClick = { showFieldPicker = true },
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.edit_more_fields))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.saveFailed) {
|
||||
Spacer(Modifier.height(gap))
|
||||
FieldError(R.string.edit_save_failed)
|
||||
}
|
||||
}
|
||||
|
||||
pickerTarget?.let { target ->
|
||||
val current = if (target == PickerTarget.Start) state.start else state.due
|
||||
DateTimePickerFlow(
|
||||
initial = current,
|
||||
allDay = state.isAllDay,
|
||||
onResult = {
|
||||
if (target == PickerTarget.Start) viewModel.onStartChange(it) else viewModel.onDueChange(it)
|
||||
pickerTarget = null
|
||||
},
|
||||
onDismiss = { pickerTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
if (showListPicker) {
|
||||
ListPickerDialog(
|
||||
lists = state.lists,
|
||||
selectedId = state.listId,
|
||||
onSelect = { viewModel.onListChange(it); showListPicker = false },
|
||||
onDismiss = { showListPicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showParentPicker) {
|
||||
ParentPickerSheet(
|
||||
candidates = state.parentCandidates,
|
||||
selectedId = state.parentId,
|
||||
onSelect = { viewModel.onParentChange(it); showParentPicker = false },
|
||||
onDismiss = { showParentPicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (state.saveConflict) {
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissConflict,
|
||||
title = { Text(stringResource(R.string.edit_conflict_title)) },
|
||||
text = { Text(stringResource(R.string.edit_conflict_body)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.save(force = true) }) {
|
||||
Text(stringResource(R.string.edit_conflict_overwrite))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::dismissConflict) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showReminderPicker) {
|
||||
ReminderPickerDialog(
|
||||
selected = state.reminderMinutesBeforeDue,
|
||||
onSelect = { viewModel.onReminderChange(it); showReminderPicker = false },
|
||||
onDismiss = { showReminderPicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showFieldPicker) {
|
||||
FieldPickerDialog(
|
||||
hiddenFields = state.hiddenFields,
|
||||
onSelect = { viewModel.revealField(it); showFieldPicker = false },
|
||||
onDismiss = { showFieldPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for an optional form section: fields added via "More fields" spring
|
||||
* open instead of popping in. (Initially-visible sections render without
|
||||
* animating on the first frame they're added.)
|
||||
*/
|
||||
/**
|
||||
* One info card mirroring the detail screen's card: tonal container, leading
|
||||
* icon in the gutter, value to the right. Optionally clickable as a whole.
|
||||
*/
|
||||
@Composable
|
||||
private fun EditCard(
|
||||
icon: ImageVector,
|
||||
iconContentDescription: String?,
|
||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
onClick: (() -> Unit)? = null,
|
||||
// Multiline-input cards align the icon with the field's first text line.
|
||||
iconAtTop: Boolean = false,
|
||||
// Tall cards pass their first row here: the icon centres on it, and
|
||||
// [content] continues below, indented to the same text column.
|
||||
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
val color = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val inner: @Composable () -> Unit = {
|
||||
if (header != null) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, iconContentDescription, tint = iconTint, modifier = Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f), content = header)
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = 40.dp), content = content)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = iconContentDescription,
|
||||
tint = iconTint,
|
||||
// 4dp mirrors InlineField's vertical padding, so a
|
||||
// top-aligned icon centres on the first text line.
|
||||
modifier = Modifier
|
||||
.padding(top = if (iconAtTop) 4.dp else 0.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f), content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onClick != null) {
|
||||
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() }
|
||||
} else {
|
||||
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Borderless input used inside the cards (and as the headline title) — a thin
|
||||
* wrapper over the shared [InlineTextField] so the form and the rest of the app
|
||||
* share one input style.
|
||||
*/
|
||||
@Composable
|
||||
private fun InlineField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
textStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.titleMedium,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
) {
|
||||
InlineTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = placeholder,
|
||||
textStyle = textStyle,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One schedule row: label, then the tappable date and (unless all-day) time —
|
||||
* or "Set" when empty. Tapping runs the date → time picker flow; a clear
|
||||
* affordance appears once a value is set. Tappable values read as links
|
||||
* (primary); an error flips them to the error colour.
|
||||
*/
|
||||
@Composable
|
||||
private fun ScheduleRow(
|
||||
label: String,
|
||||
value: Instant?,
|
||||
allDay: Boolean,
|
||||
onPick: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
val valueColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (value == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.edit_set),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = value.formatDate(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
if (!allDay) {
|
||||
Text(
|
||||
text = value.formatTime(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Rounded.Clear,
|
||||
contentDescription = stringResource(R.string.edit_clear),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun nowInstant(): Instant = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||
|
||||
/** Pick a date, then (unless all-day) a time; emits the combined instant once. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DateTimePickerFlow(
|
||||
initial: Instant?,
|
||||
allDay: Boolean,
|
||||
onResult: (Instant) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var pendingDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
|
||||
var showTime by remember { mutableStateOf(false) }
|
||||
|
||||
if (!showTime) {
|
||||
val initialMillis = (initial ?: nowInstant())
|
||||
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
|
||||
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
if (allDay) {
|
||||
onResult(localToInstant(date, LocalTime.MIDNIGHT))
|
||||
} else {
|
||||
pendingDate = date
|
||||
showTime = true
|
||||
}
|
||||
}) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) }
|
||||
},
|
||||
) { DatePicker(state = dateState) }
|
||||
} else {
|
||||
val base = initial ?: nowInstant()
|
||||
val timeState = rememberTimePickerState(
|
||||
initialHour = base.toLocalTime().hour,
|
||||
initialMinute = base.toLocalTime().minute,
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
text = { TimePicker(state = timeState) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val date = pendingDate ?: return@TextButton
|
||||
onResult(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute)))
|
||||
}) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListPickerDialog(
|
||||
lists: List<TaskList>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.edit_list_label)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
lists.forEach { list ->
|
||||
OptionCard(
|
||||
label = list.name,
|
||||
onClick = { onSelect(list.id) },
|
||||
icon = Icons.Rounded.Circle,
|
||||
iconTint = pastelize(list.color, dark),
|
||||
supportingText = list.accountName.takeIf { it.isNotBlank() },
|
||||
selected = list.id == selectedId,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the task to file this one under, or "None" to keep it top-level. A
|
||||
* full-width modal sheet (not a cramped dialog): a search field filters by title,
|
||||
* and candidates group under the same due-date sections as the task list
|
||||
* (Overdue / Today / Upcoming / No date) so the right one is easy to find in a
|
||||
* long list. Only active tasks are offered (the VM filters closed ones out).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ParentPickerSheet(
|
||||
candidates: List<Task>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
// Animate the sheet out before reporting the pick, so it doesn't snap shut.
|
||||
fun choose(id: Long?) {
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||
if (!sheetState.isVisible) onSelect(id)
|
||||
}
|
||||
}
|
||||
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
val filtered = remember(candidates, query) {
|
||||
val q = query.trim()
|
||||
if (q.isEmpty()) candidates else candidates.filter { it.title.contains(q, ignoreCase = true) }
|
||||
}
|
||||
val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
|
||||
val sections = remember(filtered) { TaskSections.of(filtered, todayStart, todayEnd) }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 32.dp),
|
||||
) {
|
||||
item(key = "title") {
|
||||
Text(
|
||||
text = stringResource(R.string.edit_parent_label),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
// The search field pins to the top of the sheet while the list scrolls.
|
||||
stickyHeader(key = "search") {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
singleLine = true,
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { query = "" }) {
|
||||
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder = { Text(stringResource(R.string.edit_parent_search)) },
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// "None" — promote to top-level. Pinned above the grouped candidates.
|
||||
item(key = "none") {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.edit_parent_none),
|
||||
position = de.jeanlucmakiola.floret.components.Position.Alone,
|
||||
selected = selectedId == null,
|
||||
minHeight = 56.dp,
|
||||
onClick = { choose(null) },
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (sections.isEmpty()) {
|
||||
item(key = "empty") {
|
||||
Text(
|
||||
text = stringResource(R.string.edit_parent_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sections.forEach { section ->
|
||||
stickyHeader(key = "hdr-${section.section}") {
|
||||
SheetSectionHeader(parentSectionLabel(section.section))
|
||||
}
|
||||
itemsIndexed(section.tasks, key = { _, t -> t.taskId }) { index, task ->
|
||||
GroupedRow(
|
||||
title = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
||||
position = positionOf(index, section.tasks.size),
|
||||
summary = task.due?.formatDate(),
|
||||
selected = task.taskId == selectedId,
|
||||
minHeight = 56.dp,
|
||||
onClick = { choose(task.taskId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Section header inside the parent sheet — opaque so rows scroll cleanly under it. */
|
||||
@Composable
|
||||
private fun SheetSectionHeader(text: String) {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun parentSectionLabel(section: TaskSection): String = stringResource(
|
||||
when (section) {
|
||||
TaskSection.OVERDUE -> R.string.section_overdue
|
||||
TaskSection.TODAY -> R.string.section_today
|
||||
TaskSection.UPCOMING -> R.string.section_upcoming
|
||||
TaskSection.NO_DATE -> R.string.section_no_date
|
||||
TaskSection.COMPLETED -> R.string.section_completed
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ReminderPickerDialog(
|
||||
selected: Int?,
|
||||
onSelect: (Int?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.edit_reminder_label)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
reminderOptions.forEach { option ->
|
||||
OptionCard(
|
||||
label = stringResource(option.labelRes),
|
||||
onClick = { onSelect(option.minutes) },
|
||||
selected = option.minutes == selected,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Picks one hidden optional section to add to the form. */
|
||||
@Composable
|
||||
private fun FieldPickerDialog(
|
||||
hiddenFields: List<TaskFormField>,
|
||||
onSelect: (TaskFormField) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.edit_more_fields)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
hiddenFields.forEach { field ->
|
||||
OptionCard(
|
||||
label = stringResource(fieldLabel(field)),
|
||||
onClick = { onSelect(field) },
|
||||
icon = fieldIcon(field),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun fieldLabel(field: TaskFormField): Int = when (field) {
|
||||
TaskFormField.Description -> R.string.field_description
|
||||
TaskFormField.Priority -> R.string.edit_priority_label
|
||||
TaskFormField.Progress -> R.string.edit_progress_label
|
||||
TaskFormField.Parent -> R.string.edit_parent_label
|
||||
TaskFormField.Reminder -> R.string.edit_reminder_label
|
||||
}
|
||||
|
||||
private fun fieldIcon(field: TaskFormField): ImageVector = when (field) {
|
||||
TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes
|
||||
TaskFormField.Priority -> Icons.Rounded.Flag
|
||||
TaskFormField.Progress -> Icons.Rounded.Percent
|
||||
TaskFormField.Parent -> Icons.Rounded.AccountTree
|
||||
TaskFormField.Reminder -> Icons.Rounded.Notifications
|
||||
}
|
||||
|
||||
private data class ReminderOption(val labelRes: Int, val minutes: Int?)
|
||||
|
||||
private val reminderOptions = listOf(
|
||||
ReminderOption(R.string.reminder_none, null),
|
||||
ReminderOption(R.string.reminder_at_due, 0),
|
||||
ReminderOption(R.string.reminder_5_min, 5),
|
||||
ReminderOption(R.string.reminder_10_min, 10),
|
||||
ReminderOption(R.string.reminder_30_min, 30),
|
||||
ReminderOption(R.string.reminder_1_hour, 60),
|
||||
ReminderOption(R.string.reminder_1_day, 1_440),
|
||||
)
|
||||
|
||||
private fun reminderOptionFor(minutes: Int?): ReminderOption =
|
||||
reminderOptions.firstOrNull { it.minutes == minutes } ?: reminderOptions.first()
|
||||
|
||||
@Composable
|
||||
private fun FieldError(messageRes: Int) {
|
||||
Text(
|
||||
text = stringResource(messageRes),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package de.jeanlucmakiola.agendula.ui.edit
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.populatedFields
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Editable form state for creating or editing a task. The screen renders these
|
||||
* fields and calls the `on*` setters; [save] validates, writes, re-syncs
|
||||
* reminders, and flips [TaskEditUiState.saved] for the screen to navigate back.
|
||||
*/
|
||||
data class TaskEditUiState(
|
||||
val loading: Boolean = true,
|
||||
val isNew: Boolean = true,
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val listId: Long? = null,
|
||||
val start: Instant? = null,
|
||||
val due: Instant? = null,
|
||||
val isAllDay: Boolean = false,
|
||||
val priority: Priority = Priority.NONE,
|
||||
val parentId: Long? = null,
|
||||
val percentComplete: Int? = null,
|
||||
val reminderMinutesBeforeDue: Int? = null,
|
||||
val lists: List<TaskList> = emptyList(),
|
||||
/** Top-level tasks in the current list this task can be filed under (reparent). */
|
||||
val parentCandidates: List<Task> = emptyList(),
|
||||
/** Optional sections currently shown (core Title / List / When are always on). */
|
||||
val visibleFields: Set<TaskFormField> = emptySet(),
|
||||
/** Optional sections still tucked behind "More fields". */
|
||||
val hiddenFields: List<TaskFormField> = emptyList(),
|
||||
val errors: Set<TaskFormError> = emptySet(),
|
||||
val saveFailed: Boolean = false,
|
||||
/** The task changed underneath us; the screen asks to overwrite or cancel. */
|
||||
val saveConflict: Boolean = false,
|
||||
val saved: Boolean = false,
|
||||
) {
|
||||
/** The currently-selected parent task, if this task is filed under one. */
|
||||
val parent: Task? get() = parentCandidates.firstOrNull { it.taskId == parentId }
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class TaskEditViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val reminderScheduler: ReminderScheduler,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(TaskEditUiState())
|
||||
val state: StateFlow<TaskEditUiState> = _state.asStateFlow()
|
||||
|
||||
private var editingTaskId: Long? = null
|
||||
|
||||
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
|
||||
private var baselineLastModified: Instant? = null
|
||||
|
||||
/** Sections shown by default (a setting); the rest unfold via [revealField]. */
|
||||
private var defaultFields: Set<TaskFormField> = emptySet()
|
||||
|
||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
|
||||
editingTaskId = null
|
||||
baselineLastModified = null
|
||||
viewModelScope.launch {
|
||||
val settings = settingsPrefs.settings.first()
|
||||
defaultFields = settings.defaultEditFields
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
val defaultList = presetListId
|
||||
?: settings.defaultListId
|
||||
?: lists.firstOrNull { !it.isLocal }?.id
|
||||
?: lists.firstOrNull()?.id
|
||||
_state.value = withFields(
|
||||
TaskEditUiState(
|
||||
loading = false,
|
||||
isNew = true,
|
||||
listId = defaultList,
|
||||
parentId = parentId,
|
||||
lists = lists,
|
||||
parentCandidates = loadParents(defaultList, selfId = null),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Load an existing task for editing. */
|
||||
fun bindEdit(taskId: Long) {
|
||||
editingTaskId = taskId
|
||||
viewModelScope.launch {
|
||||
defaultFields = settingsPrefs.settings.first().defaultEditFields
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull()
|
||||
if (task == null) {
|
||||
_state.value = _state.value.copy(loading = false, lists = lists)
|
||||
return@launch
|
||||
}
|
||||
baselineLastModified = task.lastModified
|
||||
_state.value = withFields(
|
||||
TaskEditUiState(
|
||||
loading = false,
|
||||
isNew = false,
|
||||
title = task.title,
|
||||
description = task.description.orEmpty(),
|
||||
listId = task.listId,
|
||||
start = task.start,
|
||||
due = task.due,
|
||||
isAllDay = task.isAllDay,
|
||||
priority = task.priority,
|
||||
parentId = task.parentId,
|
||||
percentComplete = task.percentComplete,
|
||||
lists = lists,
|
||||
parentCandidates = loadParents(task.listId, selfId = taskId),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks the edited task can be filed under: active, top-level tasks in
|
||||
* [listId], minus the task itself (a task can't parent itself). Restricting to
|
||||
* top-level keeps the hierarchy one level deep (matching the detail screen),
|
||||
* and to active so you don't file work under something already done/cancelled.
|
||||
*/
|
||||
private suspend fun loadParents(listId: Long?, selfId: Long?): List<Task> {
|
||||
if (listId == null) return emptyList()
|
||||
val tasks = runCatching { repository.tasks(TaskFilter.OfList(listId)).first() }.getOrElse { emptyList() }
|
||||
return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId }
|
||||
}
|
||||
|
||||
/** Reveal an optional section (one tap from the "More fields" picker). */
|
||||
fun revealField(field: TaskFormField) = update {
|
||||
val visible = it.visibleFields + field
|
||||
it.copy(visibleFields = visible, hiddenFields = hiddenOf(visible))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which optional sections are visible: the default set, plus any
|
||||
* field that already carries a value (so editing never hides real data).
|
||||
*/
|
||||
private fun withFields(state: TaskEditUiState): TaskEditUiState {
|
||||
val visible = defaultFields + state.toForm().populatedFields()
|
||||
return state.copy(visibleFields = visible, hiddenFields = hiddenOf(visible))
|
||||
}
|
||||
|
||||
private fun hiddenOf(visible: Set<TaskFormField>): List<TaskFormField> =
|
||||
TaskFormField.entries.filterNot { it in visible }
|
||||
|
||||
fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) }
|
||||
fun onDescriptionChange(value: String) = update { it.copy(description = value) }
|
||||
|
||||
/** Switching list invalidates the parent (it lived in the old list); reload candidates. */
|
||||
fun onListChange(listId: Long) {
|
||||
if (_state.value.listId == listId) return // re-picking the same list keeps the parent
|
||||
update { it.copy(listId = listId, parentId = null, errors = emptySet()) }
|
||||
viewModelScope.launch {
|
||||
val candidates = loadParents(listId, selfId = editingTaskId)
|
||||
update { it.copy(parentCandidates = candidates) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
||||
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
|
||||
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
|
||||
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
||||
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
|
||||
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
|
||||
fun onReminderChange(minutesBeforeDue: Int?) = update { it.copy(reminderMinutesBeforeDue = minutesBeforeDue) }
|
||||
|
||||
/** Dismiss the conflict prompt without saving (the user keeps editing). */
|
||||
fun dismissConflict() = update { it.copy(saveConflict = false) }
|
||||
|
||||
/** [force] = overwrite even if the task changed since it loaded (conflict prompt). */
|
||||
fun save(force: Boolean = false) {
|
||||
val current = _state.value
|
||||
val form = current.toForm()
|
||||
val errors = form.validate()
|
||||
if (errors.isNotEmpty()) {
|
||||
_state.value = current.copy(errors = errors)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val id = editingTaskId
|
||||
if (id == null) {
|
||||
repository.createTask(form)
|
||||
} else {
|
||||
repository.updateTask(id, form, expectedLastModified = if (force) null else baselineLastModified)
|
||||
}
|
||||
reminderScheduler.sync()
|
||||
}.onSuccess {
|
||||
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
|
||||
}.onFailure { error ->
|
||||
_state.value = if (error is TaskConflictException) {
|
||||
_state.value.copy(saveConflict = true, saveFailed = false)
|
||||
} else {
|
||||
_state.value.copy(saveFailed = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun update(block: (TaskEditUiState) -> TaskEditUiState) {
|
||||
_state.value = block(_state.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot the editable state as a validated [TaskForm]. */
|
||||
private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
|
||||
title = title,
|
||||
listId = listId ?: 0L,
|
||||
description = description.ifBlank { null },
|
||||
start = start,
|
||||
due = due,
|
||||
isAllDay = isAllDay,
|
||||
priority = priority,
|
||||
parentId = parentId,
|
||||
percentComplete = percentComplete,
|
||||
reminderMinutesBeforeDue = reminderMinutesBeforeDue,
|
||||
)
|
||||
@@ -0,0 +1,689 @@
|
||||
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.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.collectIsPressedAsState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.automirrored.rounded.ListAlt
|
||||
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.Flag
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material.icons.rounded.Today
|
||||
import androidx.compose.material.icons.rounded.Upcoming
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
||||
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.time.formatDateTimeCompact
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* Home: a Today progress hero (wavy ring over tasks due today), an Overdue + All
|
||||
* 2-up of tonal tiles, a live preview of the next upcoming tasks, then the user's
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun ListsScreen(
|
||||
onOpenFilter: (TaskFilter) -> Unit,
|
||||
onOpenTask: (Long) -> Unit,
|
||||
onNewTask: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ListsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
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(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
HomeTopBar(
|
||||
searchActive = searchActive,
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
onToggleSearch = { if (searchActive) closeSearch() else searchActive = true },
|
||||
onOpenSettings = onOpenSettings,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
// The FAB would otherwise float over the search results.
|
||||
if (!searchActive) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onNewTask,
|
||||
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.new_task)) },
|
||||
)
|
||||
}
|
||||
},
|
||||
) { inner ->
|
||||
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
|
||||
when (val s = state) {
|
||||
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListsContent(
|
||||
state: ListsUiState.Content,
|
||||
onOpenFilter: (TaskFilter) -> Unit,
|
||||
onOpenTask: (Long) -> Unit,
|
||||
topPadding: androidx.compose.ui.unit.Dp,
|
||||
bottomPadding: androidx.compose.ui.unit.Dp,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
top = topPadding,
|
||||
bottom = bottomPadding,
|
||||
),
|
||||
) {
|
||||
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()) {
|
||||
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
|
||||
} else {
|
||||
item { SectionHeader(stringResource(R.string.lists_header)) }
|
||||
state.groups.forEach { group ->
|
||||
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
|
||||
itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview ->
|
||||
GroupedRow(
|
||||
title = overview.list.name,
|
||||
position = positionOf(index, group.lists.size),
|
||||
minHeight = 72.dp,
|
||||
leading = { ListColorChip(overview.list.color) },
|
||||
trailing = {
|
||||
if (overview.openCount > 0) {
|
||||
Text(
|
||||
overview.openCount.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { onOpenFilter(TaskFilter.OfList(overview.list.id)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
private fun HomeTopBar(
|
||||
searchActive: Boolean,
|
||||
query: String,
|
||||
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),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(),
|
||||
modifier = Modifier.weight(1f).focusRequester(focusRequester),
|
||||
decorationBox = { innerField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.home_search_hint),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
innerField()
|
||||
}
|
||||
},
|
||||
)
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(
|
||||
Icons.Rounded.Close,
|
||||
contentDescription = stringResource(R.string.home_search_clear),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Space reserved for the search icon SearchSlot overlays at the end.
|
||||
Spacer(Modifier.width(48.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search results overlaying the home content: tasks whose title matches the query,
|
||||
* across every list (completed included), open ones first. A blank query renders
|
||||
* nothing so the home content shows through behind the open field.
|
||||
*/
|
||||
@Composable
|
||||
private fun SearchResults(
|
||||
query: String,
|
||||
allTasks: List<Task>,
|
||||
onOpenTask: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (query.isBlank()) return
|
||||
val results = remember(query, allTasks) {
|
||||
val q = query.trim()
|
||||
allTasks
|
||||
.filter { it.title.contains(q, ignoreCase = true) }
|
||||
.sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() }))
|
||||
}
|
||||
Surface(modifier = modifier, color = MaterialTheme.colorScheme.surface) {
|
||||
if (results.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.TopCenter) {
|
||||
Text(
|
||||
text = stringResource(R.string.home_search_empty, query.trim()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(results, key = { it.id }) { task ->
|
||||
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The day's momentum: a wavy progress ring over "x of y done" for tasks due today.
|
||||
* Tapping opens the Today list. When nothing is due today it drops the ring and
|
||||
* reads as a calm, finished state rather than an empty 0.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun TodayHero(done: Int, total: Int, onClick: () -> Unit) {
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val pressed by interaction.collectIsPressedAsState()
|
||||
val corner by animateDpAsState(if (pressed) 34.dp else 22.dp, label = "todayCorner")
|
||||
val left = total - done
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(corner),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
interactionSource = interaction,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(140.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Rounded.Today, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Text(stringResource(R.string.smart_today), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
val headline = when {
|
||||
total == 0 -> stringResource(R.string.home_today_empty)
|
||||
left == 0 -> stringResource(R.string.home_today_all_done)
|
||||
else -> stringResource(R.string.home_today_progress, done, total)
|
||||
}
|
||||
Text(headline, style = MaterialTheme.typography.headlineSmall)
|
||||
if (total > 0 && left > 0) {
|
||||
Text(
|
||||
stringResource(R.string.home_today_remaining, left),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (total > 0) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(84.dp)) {
|
||||
CircularWavyProgressIndicator(
|
||||
progress = { done.toFloat() / total },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.22f),
|
||||
)
|
||||
Text("$done/$total", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Overdue + All as a 2-up row of the existing tonal tiles. */
|
||||
@Composable
|
||||
private fun SmartPairRow(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
counts.forEach { smart ->
|
||||
SmartCard(
|
||||
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)
|
||||
|
||||
@Composable
|
||||
private fun smartStyle(smart: SmartList): SmartStyle {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
return when (smart) {
|
||||
SmartList.TODAY -> SmartStyle(Icons.Rounded.Today, R.string.smart_today, colors.primaryContainer, colors.onPrimaryContainer)
|
||||
SmartList.OVERDUE -> SmartStyle(Icons.Rounded.ErrorOutline, R.string.smart_overdue, colors.errorContainer, colors.onErrorContainer)
|
||||
SmartList.UPCOMING -> SmartStyle(Icons.Rounded.Upcoming, R.string.smart_upcoming, colors.tertiaryContainer, colors.onTertiaryContainer)
|
||||
else -> SmartStyle(Icons.AutoMirrored.Rounded.ListAlt, R.string.smart_all, colors.secondaryContainer, colors.onSecondaryContainer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean tonal tile. The expressive touch is subtle: rounded corners gently morph
|
||||
* rounder on press (the same quiet shape behaviour as the lists), not a loud
|
||||
* silhouette.
|
||||
*/
|
||||
@Composable
|
||||
private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val style = smartStyle(count.smart)
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val pressed by interaction.collectIsPressedAsState()
|
||||
val corner by animateDpAsState(if (pressed) 34.dp else 22.dp, label = "smartCorner")
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(corner),
|
||||
color = style.container,
|
||||
contentColor = style.onContainer,
|
||||
interactionSource = interaction,
|
||||
modifier = modifier.height(116.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Icon(style.icon, contentDescription = null)
|
||||
Column {
|
||||
Text(count.count.toString(), style = MaterialTheme.typography.headlineMedium)
|
||||
Text(stringResource(style.labelRes), style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredMessage(text: String, inner: PaddingValues) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package de.jeanlucmakiola.agendula.ui.lists
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Clock
|
||||
|
||||
data class ListOverview(val list: TaskList, val openCount: Int)
|
||||
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
||||
data class SmartCount(val smart: SmartList, val count: Int)
|
||||
|
||||
sealed interface ListsUiState {
|
||||
data object Loading : ListsUiState
|
||||
data object Failure : ListsUiState
|
||||
data class Content(
|
||||
val smartCounts: List<SmartCount>,
|
||||
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
|
||||
}
|
||||
|
||||
private const val UPCOMING_PREVIEW = 3
|
||||
|
||||
/** The home overview: smart lists with live counts, then user lists by account. */
|
||||
@HiltViewModel
|
||||
class ListsViewModel @Inject constructor(
|
||||
repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<ListsUiState> =
|
||||
combine(
|
||||
repository.taskLists(),
|
||||
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
|
||||
// Open smart lists drop completed tasks, but the Today ring needs the
|
||||
// ones already ticked off to show "x of y done", so read them too.
|
||||
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
||||
) { lists, openTasks, completedTasks ->
|
||||
buildContent(lists, openTasks, completedTasks) as ListsUiState
|
||||
}.catch { emit(ListsUiState.Failure) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
||||
|
||||
private fun buildContent(
|
||||
lists: List<TaskList>,
|
||||
openTasks: List<Task>,
|
||||
completedTasks: List<Task>,
|
||||
): ListsUiState.Content {
|
||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||
// 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
|
||||
// not read as an open item here. Mirrors the task list collapsing subtasks.
|
||||
val topLevel = openTasks.filter { !it.isSubtask }
|
||||
fun count(smart: SmartList) =
|
||||
topLevel.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) }
|
||||
|
||||
val smartCounts = listOf(
|
||||
SmartCount(SmartList.TODAY, count(SmartList.TODAY)),
|
||||
SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)),
|
||||
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
|
||||
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 groups = lists
|
||||
.groupBy { it.accountName }
|
||||
.map { (account, accountLists) ->
|
||||
AccountGroup(
|
||||
accountName = account,
|
||||
lists = accountLists.map { ListOverview(it, openByList[it.id] ?: 0) },
|
||||
)
|
||||
}
|
||||
.sortedBy { it.accountName.lowercase() }
|
||||
return ListsUiState.Content(
|
||||
smartCounts = smartCounts,
|
||||
groups = groups,
|
||||
todayDone = completedDueToday,
|
||||
todayTotal = todayTotal,
|
||||
upcoming = upcoming,
|
||||
allTasks = openTasks + completedTasks,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package de.jeanlucmakiola.agendula.ui.navigation
|
||||
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen
|
||||
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen
|
||||
import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
|
||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
|
||||
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen
|
||||
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
|
||||
|
||||
/**
|
||||
* The single in-app back stack: lists → task list → detail / edit.
|
||||
*
|
||||
* Each destination resolves its own Hilt [androidx.lifecycle.ViewModel] and
|
||||
* feeds it the route arguments through that VM's `bind(...)` method (the VMs
|
||||
* were built render-only in M1 and take their input this way rather than from a
|
||||
* `SavedStateHandle`). Screens receive plain navigation lambdas so they stay
|
||||
* unaware of the nav library.
|
||||
*/
|
||||
@Composable
|
||||
fun AgendulaNavHost(modifier: Modifier = Modifier) {
|
||||
val nav = rememberNavController()
|
||||
|
||||
NavHost(
|
||||
navController = nav,
|
||||
startDestination = Dest.LISTS,
|
||||
modifier = modifier,
|
||||
// Only the screen on top moves; the background is always held static.
|
||||
// Forward is a plain fade-in over the held screen — calm, not a slide.
|
||||
// Back is the predictive-back peek: navigation-compose composes the
|
||||
// screen being revealed underneath and seeks popExit with the gesture, so
|
||||
// the leaving screen scales down to reveal the (unmoved) previous screen.
|
||||
enterTransition = { fadeIn() },
|
||||
exitTransition = { ExitTransition.None },
|
||||
popEnterTransition = { EnterTransition.None },
|
||||
popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() },
|
||||
) {
|
||||
composable(Dest.LISTS) {
|
||||
ListsScreen(
|
||||
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
|
||||
onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) },
|
||||
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
|
||||
onOpenSettings = { nav.navigate(Dest.SETTINGS) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Dest.SETTINGS) {
|
||||
SettingsScreen(onBack = { nav.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Dest.TaskList.route, arguments = Dest.TaskList.arguments) { entry ->
|
||||
val args = entry.arguments
|
||||
val filter = Dest.TaskList.filterOf(
|
||||
smart = args?.getString(Dest.TaskList.ARG_SMART),
|
||||
listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID,
|
||||
)
|
||||
val vm: TaskListViewModel = hiltViewModel()
|
||||
LaunchedEffect(filter) { vm.bind(filter) }
|
||||
|
||||
TaskListScreen(
|
||||
filter = filter,
|
||||
viewModel = vm,
|
||||
onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) },
|
||||
onNewTask = {
|
||||
val presetListId = (filter as? TaskFilter.OfList)?.listId
|
||||
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId))
|
||||
},
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry ->
|
||||
val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID
|
||||
val vm: TaskDetailViewModel = hiltViewModel()
|
||||
LaunchedEffect(taskId) { vm.bind(taskId) }
|
||||
|
||||
TaskDetailScreen(
|
||||
viewModel = vm,
|
||||
onEdit = { nav.navigate(Dest.TaskEdit.buildEdit(taskId)) },
|
||||
onDeleted = { nav.popBackStack() },
|
||||
onBack = { nav.popBackStack() },
|
||||
// A subtask opens its own detail — another entry on the stack, with
|
||||
// its own VM bound to that id (it may have children of its own).
|
||||
onOpenTask = { subtaskId -> nav.navigate(Dest.TaskDetail.build(subtaskId)) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Dest.TaskEdit.route, arguments = Dest.TaskEdit.arguments) { entry ->
|
||||
val args = entry.arguments
|
||||
val taskId = args?.getLong(Dest.TaskEdit.ARG_TASK_ID) ?: Dest.NO_ID
|
||||
val presetListId = args?.getLong(Dest.TaskEdit.ARG_PRESET_LIST_ID) ?: Dest.NO_ID
|
||||
val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID
|
||||
val vm: TaskEditViewModel = hiltViewModel()
|
||||
LaunchedEffect(taskId, presetListId, parentId) {
|
||||
if (taskId != Dest.NO_ID) {
|
||||
vm.bindEdit(taskId)
|
||||
} else {
|
||||
vm.bindNew(
|
||||
presetListId = presetListId.takeIf { it != Dest.NO_ID },
|
||||
parentId = parentId.takeIf { it != Dest.NO_ID },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TaskEditScreen(
|
||||
viewModel = vm,
|
||||
onSaved = { nav.popBackStack() },
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.jeanlucmakiola.agendula.ui.navigation
|
||||
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
|
||||
/**
|
||||
* String-based route table for the app's [androidx.navigation.NavHost].
|
||||
*
|
||||
* Routes are kept stringly-typed (rather than the type-safe `@Serializable`
|
||||
* API) so we don't have to pull in the kotlinx-serialization Gradle plugin for
|
||||
* four small destinations. Each destination exposes a `route` template (for
|
||||
* `composable(...)`) and a builder that fills the arguments (for `navigate`).
|
||||
*
|
||||
* Argument convention: optional `Long`s default to [NO_ID] (`-1`) rather than
|
||||
* being nullable, which sidesteps the lack of a nullable-Long [NavType].
|
||||
*/
|
||||
object Dest {
|
||||
|
||||
const val NO_ID = -1L
|
||||
|
||||
/** Home — smart lists + the user's lists. */
|
||||
const val LISTS = "lists"
|
||||
|
||||
/** App preferences. */
|
||||
const val SETTINGS = "settings"
|
||||
|
||||
/**
|
||||
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
|
||||
* `smart` (a [SmartList] name); whichever is set decides the filter.
|
||||
*/
|
||||
object TaskList {
|
||||
const val ARG_LIST_ID = "listId"
|
||||
const val ARG_SMART = "smart"
|
||||
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}"
|
||||
|
||||
val arguments = listOf(
|
||||
navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
)
|
||||
|
||||
fun build(filter: TaskFilter): String = when (filter) {
|
||||
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
|
||||
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
|
||||
}
|
||||
|
||||
/** Reconstruct the filter from a destination's arguments. */
|
||||
fun filterOf(smart: String?, listId: Long): TaskFilter =
|
||||
if (smart != null) TaskFilter.Smart(SmartList.valueOf(smart))
|
||||
else TaskFilter.OfList(listId)
|
||||
}
|
||||
|
||||
/** A single task's detail. */
|
||||
object TaskDetail {
|
||||
const val ARG_TASK_ID = "taskId"
|
||||
const val route = "taskDetail/{$ARG_TASK_ID}"
|
||||
val arguments = listOf(navArgument(ARG_TASK_ID) { type = NavType.LongType })
|
||||
fun build(taskId: Long): String = "taskDetail/$taskId"
|
||||
}
|
||||
|
||||
/**
|
||||
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
|
||||
* task, `presetListId` / `parentId` optionally seed the form.
|
||||
*/
|
||||
object TaskEdit {
|
||||
const val ARG_TASK_ID = "taskId"
|
||||
const val ARG_PRESET_LIST_ID = "presetListId"
|
||||
const val ARG_PARENT_ID = "parentId"
|
||||
const val route =
|
||||
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
|
||||
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
|
||||
"&$ARG_PARENT_ID={$ARG_PARENT_ID}"
|
||||
|
||||
val arguments = listOf(
|
||||
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
navArgument(ARG_PRESET_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
)
|
||||
|
||||
fun buildEdit(taskId: Long): String = "taskEdit?$ARG_TASK_ID=$taskId"
|
||||
|
||||
fun buildNew(presetListId: Long? = null, parentId: Long? = null): String {
|
||||
val params = buildList {
|
||||
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
|
||||
if (parentId != null) add("$ARG_PARENT_ID=$parentId")
|
||||
}
|
||||
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.agendula.ui.permission
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Agendula's onboarding hero: an icon in the brand squircle. Each app keeps its
|
||||
* own hero; the surrounding shell comes from floret-kit's OnboardingScaffold.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SquircleHero(icon: ImageVector) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
.clip(RoundedCornerShape(34.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package de.jeanlucmakiola.floret.ui.permission
|
||||
package de.jeanlucmakiola.agendula.ui.permission
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.floret.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.floret.data.tasks.ProviderStatus
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.jeanlucmakiola.agendula.ui.permission
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.Notifications
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
/**
|
||||
* One-time onboarding step after the tasks-provider grant: explains that Agendula
|
||||
* delivers due reminders itself (tasks providers don't broadcast reminders) and
|
||||
* requests `POST_NOTIFICATIONS` (a system dialog on API 33+ only).
|
||||
*
|
||||
* Reminders default ON: [onFinished] gets true from the primary action even if
|
||||
* the system dialog is declined — the OS permission is the real gate, and the
|
||||
* Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderOnboardingScreen(
|
||||
onFinished: (remindersEnabled: Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { onFinished(true) }
|
||||
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
hero = { SquircleHero(Icons.Rounded.Notifications) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
onFinished(true)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.reminder_onboarding_enable_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = { onFinished(false) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_onboarding_skip_button))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.reminder_onboarding_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.reminder_onboarding_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||
|
||||
BenefitRow(
|
||||
icon = Icons.Rounded.Notifications,
|
||||
title = stringResource(R.string.reminder_benefit_delivery_title),
|
||||
body = stringResource(R.string.reminder_benefit_delivery_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Rounded.Schedule,
|
||||
title = stringResource(R.string.reminder_benefit_timing_title),
|
||||
body = stringResource(R.string.reminder_benefit_timing_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Rounded.Edit,
|
||||
title = stringResource(R.string.reminder_benefit_reversible_title),
|
||||
body = stringResource(R.string.reminder_benefit_reversible_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.jeanlucmakiola.agendula.ui.permission
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Gates the one-time reminder onboarding step shown after the tasks-provider
|
||||
* grant. [onboardingDone] is null until DataStore's first emission so the step
|
||||
* neither flashes for users who completed it nor gets skipped.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ReminderOnboardingViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||
.map { done -> done as Boolean? }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/** Close the step, recording whether due reminders stay on. */
|
||||
fun finish(remindersEnabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(remindersEnabled)
|
||||
prefs.setReminderOnboardingDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
package de.jeanlucmakiola.agendula.ui.settings
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AlarmManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Notes
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Gavel
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.rounded.AccountTree
|
||||
import androidx.compose.material.icons.rounded.Circle
|
||||
import androidx.compose.material.icons.rounded.Flag
|
||||
import androidx.compose.material.icons.rounded.Percent
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
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 de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.floret.components.AboutCard
|
||||
import de.jeanlucmakiola.floret.components.AboutLink
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.LanguagePickerRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
private enum class SettingsSection { Appearance, TaskForm, Reminders }
|
||||
|
||||
/**
|
||||
* Token-based accent for a leading icon chip (container / on-container pair),
|
||||
* so each accent stays correctly paired across theme, dark mode and dynamic
|
||||
* colour.
|
||||
*/
|
||||
private enum class ChipAccent { Neutral, Primary, Tertiary }
|
||||
|
||||
/**
|
||||
* Settings, structured (after Calendula) as a category hub with sliding
|
||||
* sub-screens. The hub and the sub-screens share [CollapsingScaffold] and the
|
||||
* grouped-row card system; option lists use the full-screen [OptionPicker].
|
||||
* A full-screen destination; [onBack] pops it.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
|
||||
|
||||
// Inside a sub-screen, system back (button or gesture) returns to the hub
|
||||
// rather than popping the whole Settings destination to the lists overview.
|
||||
BackHandler(enabled = section != null) { section = null }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
SettingsHub(onBack = onBack, onOpenSection = { section = it })
|
||||
|
||||
SlideInSection(visible = section == SettingsSection.Appearance) {
|
||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.TaskForm) {
|
||||
TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Reminders) {
|
||||
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInHorizontally { it } + fadeIn(),
|
||||
exit = slideOutHorizontally { it } + fadeOut(),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hub
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun SettingsHub(
|
||||
onBack: () -> Unit,
|
||||
onOpenSection: (SettingsSection) -> Unit,
|
||||
) {
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
|
||||
Box(Modifier.padding(horizontal = 16.dp)) {
|
||||
AboutCard(
|
||||
logo = { AppLogo() },
|
||||
appName = stringResource(R.string.app_name),
|
||||
author = stringResource(R.string.settings_about_author),
|
||||
primaryLinks = listOf(
|
||||
AboutLink(
|
||||
icon = Icons.Default.Code,
|
||||
label = stringResource(R.string.settings_about_source),
|
||||
url = stringResource(R.string.about_source_url),
|
||||
),
|
||||
AboutLink(
|
||||
icon = Icons.Default.Gavel,
|
||||
label = stringResource(R.string.settings_license),
|
||||
url = stringResource(R.string.about_license_url),
|
||||
),
|
||||
),
|
||||
highlightLink = AboutLink(
|
||||
icon = Icons.Default.Favorite,
|
||||
label = stringResource(R.string.settings_about_support),
|
||||
url = stringResource(R.string.about_support_url),
|
||||
),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
summary = stringResource(R.string.settings_appearance_subtitle),
|
||||
position = Position.Top,
|
||||
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
|
||||
onClick = { onOpenSection(SettingsSection.Appearance) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_task_form),
|
||||
summary = stringResource(R.string.settings_task_form_subtitle),
|
||||
position = Position.Middle,
|
||||
leading = { CategoryIcon(Icons.Default.Tune, ChipAccent.Tertiary) },
|
||||
onClick = { onOpenSection(SettingsSection.TaskForm) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_reminders),
|
||||
summary = stringResource(R.string.settings_reminders_subtitle),
|
||||
position = Position.Middle,
|
||||
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
|
||||
onClick = { onOpenSection(SettingsSection.Reminders) },
|
||||
)
|
||||
LanguagePickerRow(
|
||||
position = Position.Middle,
|
||||
title = stringResource(R.string.settings_language),
|
||||
autoLabel = stringResource(R.string.settings_language_auto),
|
||||
localesConfig = R.xml.locales_config,
|
||||
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
|
||||
)
|
||||
ReportProblemRow(position = Position.Bottom)
|
||||
|
||||
AppVersionText()
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the project's issue tracker; no data leaves the device until submitted. */
|
||||
@Composable
|
||||
private fun ReportProblemRow(position: Position) {
|
||||
val context = LocalContext.current
|
||||
val issueUrl = stringResource(R.string.report_issue_url)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_report_problem),
|
||||
summary = stringResource(R.string.settings_report_problem_hint),
|
||||
position = position,
|
||||
leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) },
|
||||
onClick = { openUrl(context, issueUrl) },
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-screens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun AppearanceScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
var showTheme by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
summary = stringResource(themeLabel(state.settings.themeMode)),
|
||||
position = Position.Top,
|
||||
onClick = { showTheme = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_dynamic_color),
|
||||
summary = if (dynamicColorAvailable) {
|
||||
stringResource(R.string.settings_dynamic_color_hint)
|
||||
} else {
|
||||
stringResource(R.string.settings_dynamic_color_unavailable)
|
||||
},
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.settings.dynamicColor && dynamicColorAvailable,
|
||||
onCheckedChange = viewModel::setDynamicColor,
|
||||
enabled = dynamicColorAvailable,
|
||||
)
|
||||
},
|
||||
onClick = if (dynamicColorAvailable) {
|
||||
{ viewModel.setDynamicColor(!state.settings.dynamicColor) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showTheme) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
options = ThemeMode.entries,
|
||||
selected = state.settings.themeMode,
|
||||
label = { stringResource(themeLabel(it)) },
|
||||
onSelect = viewModel::setThemeMode,
|
||||
onDismiss = { showTheme = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskFormScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var showDefaultList by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_form_fields_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val fields = TaskFormField.entries
|
||||
fields.forEachIndexed { index, field ->
|
||||
val checked = field in state.settings.defaultEditFields
|
||||
GroupedRow(
|
||||
title = stringResource(formFieldLabel(field)),
|
||||
position = positionOf(index, fields.size),
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = formFieldIcon(field),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Switch(checked = checked, onCheckedChange = { viewModel.setFormFieldDefault(field, it) })
|
||||
},
|
||||
onClick = { viewModel.setFormFieldDefault(field, !checked) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_list),
|
||||
summary = defaultListLabel(state),
|
||||
position = Position.Top,
|
||||
onClick = { showDefaultList = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_add_subtask_row),
|
||||
summary = stringResource(R.string.settings_add_subtask_row_hint),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.settings.showAddSubtaskRow,
|
||||
onCheckedChange = viewModel::setShowAddSubtaskRow,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_bottom_add_bar),
|
||||
summary = stringResource(R.string.settings_bottom_add_bar_hint),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.settings.bottomAddBar,
|
||||
onCheckedChange = viewModel::setBottomAddBar,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setBottomAddBar(!state.settings.bottomAddBar) },
|
||||
)
|
||||
}
|
||||
|
||||
if (showDefaultList) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
// null = first available list; the rest are real list ids.
|
||||
val options = remember(state.lists) { listOf<Long?>(null) + state.lists.map { it.id } }
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_default_list),
|
||||
options = options,
|
||||
selected = state.settings.defaultListId,
|
||||
label = { id -> state.lists.firstOrNull { it.id == id }?.name ?: stringResource(R.string.settings_default_list_first) },
|
||||
leading = { id ->
|
||||
state.lists.firstOrNull { it.id == id }?.let { list ->
|
||||
Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark))
|
||||
}
|
||||
},
|
||||
onSelect = { viewModel.setDefaultList(it) },
|
||||
onDismiss = { showDefaultList = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemindersScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val notifLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { /* pref already set; a denial just leaves the OS gate shut */ }
|
||||
val toggleReminders: (Boolean) -> Unit = { enabled ->
|
||||
viewModel.setRemindersEnabled(enabled)
|
||||
if (enabled &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notifLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
val canExact = rememberExactAlarmAllowed(context)
|
||||
val exactAlarmRow = showExactAlarmRow()
|
||||
val dark = isSystemInDarkTheme()
|
||||
var showOffset by remember { mutableStateOf(false) }
|
||||
var perListExpanded by remember { mutableStateOf(false) }
|
||||
var listPickerId by remember { mutableStateOf<Long?>(null) }
|
||||
val lists = state.lists.filter { it.id > 0L }
|
||||
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_reminders),
|
||||
summary = stringResource(R.string.settings_reminders_hint),
|
||||
position = Position.Top,
|
||||
trailing = { Switch(checked = state.settings.remindersEnabled, onCheckedChange = toggleReminders) },
|
||||
onClick = { toggleReminders(!state.settings.remindersEnabled) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = reminderLeadTimeLabel(state.settings.reminderLeadMinutes),
|
||||
position = if (exactAlarmRow) Position.Middle else Position.Bottom,
|
||||
onClick = { showOffset = true },
|
||||
)
|
||||
if (exactAlarmRow) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_exact_alarms),
|
||||
summary = stringResource(
|
||||
if (canExact) R.string.settings_exact_alarms_allowed
|
||||
else R.string.settings_exact_alarms_blocked,
|
||||
),
|
||||
position = Position.Bottom,
|
||||
onClick = if (canExact) null else ({ context.openExactAlarmSettings() }),
|
||||
)
|
||||
}
|
||||
|
||||
// Per-list overrides: the whole section folds behind one header. Each
|
||||
// list may keep, drop, or replace the global default for its tasks.
|
||||
if (lists.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
// The header and the list rows form one connected run: the header
|
||||
// opens its bottom (Top) when expanded, and the last list rounds it off.
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_list_reminders_title),
|
||||
summary = stringResource(R.string.settings_list_reminders_hint),
|
||||
position = if (perListExpanded) Position.Top else Position.Alone,
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = if (perListExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = { perListExpanded = !perListExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = perListExpanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
lists.forEachIndexed { index, list ->
|
||||
GroupedRow(
|
||||
title = list.name,
|
||||
summary = listOverrideSummary(
|
||||
listOverrideChoice(state, list.id),
|
||||
state.settings.reminderLeadMinutes,
|
||||
),
|
||||
// Every list is mid-run under the header; the last rounds off.
|
||||
position = if (index == lists.lastIndex) Position.Bottom else Position.Middle,
|
||||
leading = {
|
||||
Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark))
|
||||
},
|
||||
onClick = { listPickerId = list.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showOffset) {
|
||||
ReminderLeadPicker(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
selected = ReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
||||
allowInherit = false,
|
||||
allowNone = false,
|
||||
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
|
||||
onDismiss = { showOffset = false },
|
||||
)
|
||||
}
|
||||
listPickerId?.let { id ->
|
||||
ReminderLeadPicker(
|
||||
title = state.lists.firstOrNull { it.id == id }?.name
|
||||
?: stringResource(R.string.settings_default_reminder),
|
||||
selected = listOverrideChoice(state, id),
|
||||
allowInherit = true,
|
||||
allowNone = true,
|
||||
onSelect = { viewModel.setListReminderOverride(id, it) },
|
||||
onDismiss = { listPickerId = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
||||
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride {
|
||||
val map = state.settings.perListReminderOverride
|
||||
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. */
|
||||
@Composable
|
||||
private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): String = when (choice) {
|
||||
ReminderOverride.Inherit ->
|
||||
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// About + shared building blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun AppLogo() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(colorResource(R.color.ic_launcher_background)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = stringResource(R.string.settings_about_logo_desc),
|
||||
modifier = Modifier.requiredSize(108.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppVersionText() {
|
||||
val context = LocalContext.current
|
||||
val versionName = remember {
|
||||
runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
|
||||
.getOrNull() ?: "—"
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.settings_about_version, versionName),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Leading circular icon chip; colours come from the M3 scheme via a token pair. */
|
||||
@Composable
|
||||
private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val (background, iconColor) = when (accent) {
|
||||
ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
|
||||
ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
|
||||
ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(40.dp).clip(CircleShape).background(background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun defaultListLabel(state: SettingsUiState): String =
|
||||
state.lists.firstOrNull { it.id == state.settings.defaultListId }?.name
|
||||
?: stringResource(R.string.settings_default_list_first)
|
||||
|
||||
private fun openUrl(context: Context, url: String) {
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }
|
||||
}
|
||||
|
||||
private fun themeLabel(mode: ThemeMode): Int = when (mode) {
|
||||
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
||||
ThemeMode.LIGHT -> R.string.settings_theme_light
|
||||
ThemeMode.DARK -> R.string.settings_theme_dark
|
||||
}
|
||||
|
||||
private fun formFieldLabel(field: TaskFormField): Int = when (field) {
|
||||
TaskFormField.Description -> R.string.field_description
|
||||
TaskFormField.Priority -> R.string.edit_priority_label
|
||||
TaskFormField.Progress -> R.string.edit_progress_label
|
||||
TaskFormField.Parent -> R.string.edit_parent_label
|
||||
TaskFormField.Reminder -> R.string.edit_reminder_label
|
||||
}
|
||||
|
||||
private fun formFieldIcon(field: TaskFormField): ImageVector = when (field) {
|
||||
TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes
|
||||
TaskFormField.Priority -> Icons.Rounded.Flag
|
||||
TaskFormField.Progress -> Icons.Rounded.Percent
|
||||
TaskFormField.Parent -> Icons.Rounded.AccountTree
|
||||
TaskFormField.Reminder -> Icons.Default.Notifications
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact-alarm control is only meaningful on API 31-32 (SCHEDULE_EXACT_ALARM,
|
||||
* revocable). On 33+ the app holds USE_EXACT_ALARM (always granted), so the row
|
||||
* would be a permanent no-op — hide it there.
|
||||
*/
|
||||
private fun showExactAlarmRow(): Boolean =
|
||||
Build.VERSION.SDK_INT in Build.VERSION_CODES.S..Build.VERSION_CODES.S_V2
|
||||
|
||||
@Composable
|
||||
private fun rememberExactAlarmAllowed(context: Context): Boolean {
|
||||
var allowed by remember {
|
||||
mutableStateOf(
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
|
||||
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
|
||||
)
|
||||
}
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(obs)
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
private fun Context.openExactAlarmSettings() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
runCatching {
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).setData("package:$packageName".toUri()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
package de.jeanlucmakiola.floret.ui.settings
|
||||
package de.jeanlucmakiola.agendula.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.floret.data.prefs.Settings
|
||||
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -40,4 +42,17 @@ class SettingsViewModel @Inject constructor(
|
||||
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
|
||||
fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) }
|
||||
fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
|
||||
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
|
||||
|
||||
/** Toggle whether [field] shows by default on a new task's edit form. */
|
||||
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
|
||||
val current = state.value.settings.defaultEditFields
|
||||
prefs.setDefaultEditFields(if (enabled) current + field else current - field)
|
||||
}
|
||||
|
||||
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||
fun setListReminderOverride(listId: Long, override: ReminderOverride) =
|
||||
viewModelScope.launch { prefs.setListReminderOverride(listId, override) }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
||||
package de.jeanlucmakiola.agendula.ui.tasklist
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface TaskListUiState {
|
||||
data object Loading : TaskListUiState
|
||||
data object Failure : TaskListUiState
|
||||
|
||||
/**
|
||||
* [listName] is the real list's name when the filter is a
|
||||
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
|
||||
* the screen falls back to the smart label in that case.
|
||||
*/
|
||||
data class Content(
|
||||
val tasks: List<Task>,
|
||||
val listName: String? = null,
|
||||
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
|
||||
val showAddSubtaskRow: Boolean = true,
|
||||
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
|
||||
val bottomAddBar: Boolean = false,
|
||||
) : TaskListUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* The tasks for one [TaskFilter]. Call [bind] with the filter the screen shows;
|
||||
* the list re-emits live as the provider changes. Actions are fire-and-forget —
|
||||
* the resulting provider change flows back through the list automatically.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class TaskListViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
private val filter = MutableStateFlow<TaskFilter?>(null)
|
||||
|
||||
/** Tasks swiped to delete but not yet committed — hidden until the undo lapses. */
|
||||
private val pendingDeletes = MutableStateFlow<Set<Long>>(emptySet())
|
||||
|
||||
val state: StateFlow<TaskListUiState> =
|
||||
filter.filterNotNull()
|
||||
.flatMapLatest { f ->
|
||||
val tasks = repository.tasks(f)
|
||||
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
|
||||
is TaskFilter.OfList ->
|
||||
combine(tasks, repository.taskLists()) { list, lists ->
|
||||
TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name)
|
||||
}
|
||||
is TaskFilter.Smart ->
|
||||
tasks.map { TaskListUiState.Content(it) }
|
||||
}
|
||||
// Drop rows pending an undoable delete so the row vanishes on swipe
|
||||
// while the actual provider delete waits for the snackbar to commit,
|
||||
// and fold in the live UI settings (add-subtask row, bottom add bar).
|
||||
val uiPrefs = settingsPrefs.settings.map { it.showAddSubtaskRow to it.bottomAddBar }
|
||||
combine(content, pendingDeletes, uiPrefs) { st, pending, (showRow, bottomBar) ->
|
||||
if (st is TaskListUiState.Content) {
|
||||
st.copy(
|
||||
tasks = st.tasks.filter { it.taskId !in pending },
|
||||
showAddSubtaskRow = showRow,
|
||||
bottomAddBar = bottomBar,
|
||||
)
|
||||
} else {
|
||||
st
|
||||
}
|
||||
}
|
||||
.onStart { emit(TaskListUiState.Loading) }
|
||||
.catch { emit(TaskListUiState.Failure) }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
||||
|
||||
/**
|
||||
* Parents the screen has expanded whose children it can't pull from the list
|
||||
* itself — i.e. smart lists, where the date filter drops a parent's subtasks.
|
||||
* For each we observe the full child set live (below), so the group can expand.
|
||||
*/
|
||||
private val lazyChildParents = MutableStateFlow<Set<Long>>(emptySet())
|
||||
|
||||
/**
|
||||
* The on-demand child sets, keyed by parent id, kept live. Empty for real
|
||||
* lists (the screen reports no parents there, since it already holds every
|
||||
* child) so no redundant queries run.
|
||||
*/
|
||||
val lazyChildren: StateFlow<Map<Long, List<Task>>> =
|
||||
lazyChildParents
|
||||
.flatMapLatest { ids ->
|
||||
if (ids.isEmpty()) {
|
||||
flowOf(emptyMap())
|
||||
} else {
|
||||
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||
|
||||
/** The screen reports which expanded parents need their children fetched. */
|
||||
fun setLazyChildParents(ids: Set<Long>) { lazyChildParents.value = ids }
|
||||
|
||||
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
||||
|
||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
||||
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
|
||||
}
|
||||
|
||||
/** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */
|
||||
fun markPendingDelete(task: Task) {
|
||||
pendingDeletes.value = pendingDeletes.value + task.taskId
|
||||
}
|
||||
|
||||
/** Undo — stop hiding the task; it returns to the list. */
|
||||
fun undoDelete(taskId: Long) {
|
||||
pendingDeletes.value = pendingDeletes.value - taskId
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the delete once the undo window passes. The id stays in the pending
|
||||
* set: the task is gone from the provider so it can't reappear, and removing
|
||||
* it could briefly un-hide the row before the provider change propagates.
|
||||
*/
|
||||
fun commitDelete(taskId: Long) = viewModelScope.launch {
|
||||
if (taskId !in pendingDeletes.value) return@launch
|
||||
runCatching { repository.deleteTask(taskId) }
|
||||
}
|
||||
|
||||
/** Inline "add task" — title only, into [listId]. */
|
||||
fun quickAdd(title: String, listId: Long) = viewModelScope.launch {
|
||||
if (title.isBlank() || listId <= 0L) return@launch
|
||||
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
|
||||
}
|
||||
|
||||
/** Inline "add subtask" from an expanded list group — files it under [parent]. */
|
||||
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
|
||||
if (title.isBlank() || parent.listId <= 0L) return@launch
|
||||
runCatching {
|
||||
repository.createTask(
|
||||
TaskForm(title = title.trim(), listId = parent.listId, parentId = parent.taskId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.ui.theme
|
||||
package de.jeanlucmakiola.agendula.ui.theme
|
||||
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
@@ -12,14 +12,14 @@ import androidx.compose.ui.graphics.Color
|
||||
*
|
||||
* Placeholder until branding lands; refine alongside the launcher icon.
|
||||
*/
|
||||
val FloretSeed: Color = Color(0xFF7A5C6B)
|
||||
val AgendulaSeed: Color = Color(0xFF7A5C6B)
|
||||
|
||||
/**
|
||||
* Fallback light scheme for devices without dynamic color (API < 31) or when
|
||||
* the user disables it. Dynamic color (API 31+) is the real path; these are a
|
||||
* hand-picked approximation of the tonal palette generated from [FloretSeed].
|
||||
* hand-picked approximation of the tonal palette generated from [AgendulaSeed].
|
||||
*/
|
||||
val FloretLightFallback = lightColorScheme(
|
||||
val AgendulaLightFallback = lightColorScheme(
|
||||
primary = Color(0xFF6A4E5B),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFF6D9E5),
|
||||
@@ -32,7 +32,7 @@ val FloretLightFallback = lightColorScheme(
|
||||
onSurface = Color(0xFF1F1A1C),
|
||||
)
|
||||
|
||||
val FloretDarkFallback = darkColorScheme(
|
||||
val AgendulaDarkFallback = darkColorScheme(
|
||||
primary = Color(0xFFE2BAC9),
|
||||
onPrimary = Color(0xFF402533),
|
||||
primaryContainer = Color(0xFF583B49),
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.jeanlucmakiola.agendula.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
|
||||
|
||||
/**
|
||||
* Agendula's theme: the family's [FloretExpressiveTheme] machinery (dynamic
|
||||
* colour on API 31+, system light/dark, the standard motion scheme) fed with
|
||||
* Agendula's own identity — its seed-derived fallback schemes
|
||||
* ([AgendulaLightFallback] / [AgendulaDarkFallback]) and [AgendulaTypography].
|
||||
* The mechanics live in floret-kit; the look stays here.
|
||||
*/
|
||||
@Composable
|
||||
fun AgendulaTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
FloretExpressiveTheme(
|
||||
lightScheme = AgendulaLightFallback,
|
||||
darkScheme = AgendulaDarkFallback,
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = dynamicColor,
|
||||
typography = AgendulaTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.ui.theme
|
||||
package de.jeanlucmakiola.agendula.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
@@ -7,4 +7,4 @@ import androidx.compose.material3.Typography
|
||||
* land in a later UI-design iteration; the defaults keep the M0 foundation lean
|
||||
* (same posture as Calendula's scaffolding).
|
||||
*/
|
||||
val FloretTypography = Typography()
|
||||
val AgendulaTypography = Typography()
|
||||
@@ -1,69 +0,0 @@
|
||||
package de.jeanlucmakiola.floret
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.floret.data.demo.DemoSeeder
|
||||
import de.jeanlucmakiola.floret.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.ui.RootScreen
|
||||
import de.jeanlucmakiola.floret.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.floret.ui.theme.FloretTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
|
||||
* (replaceable) functional scaffold over the real data layer. Task-detail intent
|
||||
* routing for reminder taps lands with the full UI.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var demoSeeder: DemoSeeder
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Debug-only sample data: `am start ... --ez floret_seed true`. Seeds a
|
||||
// local (non-syncing) demo list once; no-op without the extra.
|
||||
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
|
||||
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
|
||||
}
|
||||
setContent {
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||
val darkTheme = when (ui.settings.themeMode) {
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
FloretTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
|
||||
RootScreen(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_TASK_ID = "de.jeanlucmakiola.floret.extra.TASK_ID"
|
||||
private const val EXTRA_SEED = "floret_seed"
|
||||
|
||||
/** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
|
||||
fun taskIntent(context: Context, taskId: Long): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
putExtra(EXTRA_TASK_ID, taskId)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package de.jeanlucmakiola.floret.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||
|
||||
data class Settings(
|
||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
val dynamicColor: Boolean = true,
|
||||
/** The list a new task defaults to; `null` = first available. */
|
||||
val defaultListId: Long? = null,
|
||||
/** Default minutes before due to remind; 0 = at due time. */
|
||||
val reminderLeadMinutes: Int = 0,
|
||||
)
|
||||
|
||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||
@Singleton
|
||||
class SettingsPrefs @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
val settings: Flow<Settings> = dataStore.data.map { p ->
|
||||
Settings(
|
||||
themeMode = p[THEME_MODE]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
|
||||
?: ThemeMode.SYSTEM,
|
||||
dynamicColor = p[DYNAMIC_COLOR] ?: true,
|
||||
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
|
||||
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
|
||||
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
|
||||
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
|
||||
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
|
||||
}
|
||||
|
||||
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
|
||||
|
||||
private companion object {
|
||||
val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
|
||||
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
|
||||
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskDetail
|
||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** Whether Floret can use the tasks provider right now. Drives onboarding. */
|
||||
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
||||
|
||||
/**
|
||||
* The single entry point the UI layer uses. Flows re-emit automatically when the
|
||||
* provider changes (our writes *and* external sync like DAVx5), via the
|
||||
* data source's ContentObserver.
|
||||
*/
|
||||
interface TasksRepository {
|
||||
fun taskLists(): Flow<List<TaskList>>
|
||||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||||
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
||||
|
||||
suspend fun createTask(form: TaskForm): Long
|
||||
suspend fun updateTask(taskId: Long, form: TaskForm)
|
||||
suspend fun setCompleted(taskId: Long, completed: Boolean)
|
||||
suspend fun deleteTask(taskId: Long)
|
||||
suspend fun createLocalList(name: String, color: Int): Long
|
||||
|
||||
/** Synchronous snapshot for the permission/onboarding gate. */
|
||||
fun providerStatus(): ProviderStatus
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Local-day boundaries used by the smart-list predicates. Pure + testable. */
|
||||
object DayWindow {
|
||||
|
||||
/** Returns `[startOfToday, startOfTomorrow)` in [zone] for the instant [now]. */
|
||||
fun today(now: Instant, zone: ZoneId): Pair<Instant, Instant> {
|
||||
val date = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds())
|
||||
.atZone(zone)
|
||||
.toLocalDate()
|
||||
val start = date.atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
val end = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
return Instant.fromEpochMilliseconds(start) to Instant.fromEpochMilliseconds(end)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A validated create/edit form. Kept free of Android types so ViewModels and
|
||||
* tests can build and validate it on the JVM. The data layer turns it into
|
||||
* provider ContentValues ([de.jeanlucmakiola.floret.data.tasks.TaskWriteMapper]).
|
||||
*/
|
||||
data class TaskForm(
|
||||
val title: String,
|
||||
val listId: Long,
|
||||
val description: String? = null,
|
||||
val start: Instant? = null,
|
||||
val due: Instant? = null,
|
||||
val isAllDay: Boolean = false,
|
||||
val priority: Priority = Priority.NONE,
|
||||
val parentId: Long? = null,
|
||||
/** Minutes before [due] to fire a reminder; `null` = no reminder. */
|
||||
val reminderMinutesBeforeDue: Int? = null,
|
||||
) {
|
||||
fun validate(): Set<TaskFormError> = buildSet {
|
||||
if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
|
||||
if (listId <= 0L) add(TaskFormError.NO_LIST)
|
||||
if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
|
||||
if (reminderMinutesBeforeDue != null && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE)
|
||||
}
|
||||
|
||||
val isValid: Boolean get() = validate().isEmpty()
|
||||
}
|
||||
|
||||
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE }
|
||||
@@ -1,116 +0,0 @@
|
||||
package de.jeanlucmakiola.floret.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Position of a row within a grouped list (the Android-15 settings pattern): a
|
||||
* run of rows shares one rounded container, full corners at the group's outer
|
||||
* edges and small corners between, separated by small gaps. Copied from
|
||||
* Calendula so the two apps read as one family.
|
||||
*/
|
||||
enum class Position { Top, Middle, Bottom, Alone }
|
||||
|
||||
fun positionOf(index: Int, count: Int): Position = when {
|
||||
count <= 1 -> Position.Alone
|
||||
index == 0 -> Position.Top
|
||||
index == count - 1 -> Position.Bottom
|
||||
else -> Position.Middle
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
||||
* corner radii come from its [position] (so a run of rows reads as a single
|
||||
* rounded card). Corners morph further on press — the expressive shape play.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupedRow(
|
||||
title: String,
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
summary: String? = null,
|
||||
selected: Boolean = false,
|
||||
minHeight: Dp = 72.dp,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val pressed by interaction.collectIsPressedAsState()
|
||||
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
||||
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
||||
val shape = when (position) {
|
||||
Position.Alone -> RoundedCornerShape(full)
|
||||
Position.Top -> RoundedCornerShape(
|
||||
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
||||
)
|
||||
Position.Middle -> RoundedCornerShape(small)
|
||||
Position.Bottom -> RoundedCornerShape(
|
||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||
)
|
||||
}
|
||||
val gap = when (position) {
|
||||
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||
Position.Bottom, Position.Alone -> Modifier
|
||||
}
|
||||
val itemColors = if (selected) {
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
} else {
|
||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||
}
|
||||
val item: @Composable () -> Unit = {
|
||||
ListItem(
|
||||
headlineContent = { Text(title) },
|
||||
supportingContent = summary?.let { text -> { Text(text) } },
|
||||
leadingContent = leading,
|
||||
trailingContent = trailing,
|
||||
colors = itemColors,
|
||||
modifier = Modifier.heightIn(min = minHeight),
|
||||
)
|
||||
}
|
||||
val base = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.then(gap)
|
||||
val containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
if (onClick != null) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = containerColor,
|
||||
shape = shape,
|
||||
interactionSource = interaction,
|
||||
modifier = base,
|
||||
) { item() }
|
||||
} else {
|
||||
Surface(color = containerColor, shape = shape, modifier = base) { item() }
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package de.jeanlucmakiola.floret.ui.edit
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.domain.Priority
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.floret.domain.TaskFormError
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Editable form state for creating or editing a task. The screen renders these
|
||||
* fields and calls the `on*` setters; [save] validates, writes, re-syncs
|
||||
* reminders, and flips [TaskEditUiState.saved] for the screen to navigate back.
|
||||
*/
|
||||
data class TaskEditUiState(
|
||||
val loading: Boolean = true,
|
||||
val isNew: Boolean = true,
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val listId: Long? = null,
|
||||
val start: Instant? = null,
|
||||
val due: Instant? = null,
|
||||
val isAllDay: Boolean = false,
|
||||
val priority: Priority = Priority.NONE,
|
||||
val parentId: Long? = null,
|
||||
val reminderMinutesBeforeDue: Int? = null,
|
||||
val lists: List<TaskList> = emptyList(),
|
||||
val errors: Set<TaskFormError> = emptySet(),
|
||||
val saveFailed: Boolean = false,
|
||||
val saved: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class TaskEditViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val reminderScheduler: ReminderScheduler,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(TaskEditUiState())
|
||||
val state: StateFlow<TaskEditUiState> = _state.asStateFlow()
|
||||
|
||||
private var editingTaskId: Long? = null
|
||||
|
||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
|
||||
editingTaskId = null
|
||||
viewModelScope.launch {
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
val defaultList = presetListId
|
||||
?: settingsPrefs.settings.first().defaultListId
|
||||
?: lists.firstOrNull { !it.isLocal }?.id
|
||||
?: lists.firstOrNull()?.id
|
||||
_state.value = TaskEditUiState(
|
||||
loading = false,
|
||||
isNew = true,
|
||||
listId = defaultList,
|
||||
parentId = parentId,
|
||||
lists = lists,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Load an existing task for editing. */
|
||||
fun bindEdit(taskId: Long) {
|
||||
editingTaskId = taskId
|
||||
viewModelScope.launch {
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull()
|
||||
if (task == null) {
|
||||
_state.value = _state.value.copy(loading = false, lists = lists)
|
||||
return@launch
|
||||
}
|
||||
_state.value = TaskEditUiState(
|
||||
loading = false,
|
||||
isNew = false,
|
||||
title = task.title,
|
||||
description = task.description.orEmpty(),
|
||||
listId = task.listId,
|
||||
start = task.start,
|
||||
due = task.due,
|
||||
isAllDay = task.isAllDay,
|
||||
priority = task.priority,
|
||||
parentId = task.parentId,
|
||||
lists = lists,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) }
|
||||
fun onDescriptionChange(value: String) = update { it.copy(description = value) }
|
||||
fun onListChange(listId: Long) = update { it.copy(listId = listId, errors = emptySet()) }
|
||||
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
||||
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
|
||||
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
|
||||
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
||||
fun onReminderChange(minutesBeforeDue: Int?) = update { it.copy(reminderMinutesBeforeDue = minutesBeforeDue) }
|
||||
|
||||
fun save() {
|
||||
val current = _state.value
|
||||
val form = TaskForm(
|
||||
title = current.title,
|
||||
listId = current.listId ?: 0L,
|
||||
description = current.description.ifBlank { null },
|
||||
start = current.start,
|
||||
due = current.due,
|
||||
isAllDay = current.isAllDay,
|
||||
priority = current.priority,
|
||||
parentId = current.parentId,
|
||||
reminderMinutesBeforeDue = current.reminderMinutesBeforeDue,
|
||||
)
|
||||
val errors = form.validate()
|
||||
if (errors.isNotEmpty()) {
|
||||
_state.value = current.copy(errors = errors)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val id = editingTaskId
|
||||
if (id == null) repository.createTask(form) else repository.updateTask(id, form)
|
||||
reminderScheduler.sync()
|
||||
}.onSuccess {
|
||||
_state.value = _state.value.copy(saved = true, saveFailed = false)
|
||||
}.onFailure {
|
||||
_state.value = _state.value.copy(saveFailed = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun update(block: (TaskEditUiState) -> TaskEditUiState) {
|
||||
_state.value = block(_state.value)
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
package de.jeanlucmakiola.floret.ui.lists
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ListAlt
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||
import androidx.compose.material.icons.rounded.Today
|
||||
import androidx.compose.material.icons.rounded.Upcoming
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.floret.R
|
||||
import de.jeanlucmakiola.floret.domain.SmartList
|
||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
||||
import de.jeanlucmakiola.floret.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.floret.ui.common.ListColorChip
|
||||
import de.jeanlucmakiola.floret.ui.common.positionOf
|
||||
|
||||
/**
|
||||
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live
|
||||
* counts, then the user's lists grouped by account. Tapping a card or row opens
|
||||
* that task list; the FAB starts a new task.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ListsScreen(
|
||||
onOpenFilter: (TaskFilter) -> Unit,
|
||||
onNewTask: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ListsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MediumTopAppBar(
|
||||
title = { Text(stringResource(R.string.app_name)) },
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onNewTask,
|
||||
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.new_task)) },
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
when (val s = state) {
|
||||
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||
ListsUiState.Failure -> CenteredMessage(stringResource(R.string.lists_failure), inner)
|
||||
is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListsContent(
|
||||
state: ListsUiState.Content,
|
||||
inner: PaddingValues,
|
||||
onOpenFilter: (TaskFilter) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
top = inner.calculateTopPadding(),
|
||||
bottom = inner.calculateBottomPadding() + 96.dp,
|
||||
),
|
||||
) {
|
||||
item { SmartGrid(state.smartCounts, onOpenFilter) }
|
||||
|
||||
if (state.groups.isEmpty()) {
|
||||
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
|
||||
} else {
|
||||
item { SectionHeader(stringResource(R.string.lists_header)) }
|
||||
state.groups.forEach { group ->
|
||||
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
|
||||
itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview ->
|
||||
GroupedRow(
|
||||
title = overview.list.name,
|
||||
position = positionOf(index, group.lists.size),
|
||||
minHeight = 72.dp,
|
||||
leading = { ListColorChip(overview.list.color) },
|
||||
trailing = {
|
||||
if (overview.openCount > 0) {
|
||||
Text(
|
||||
overview.openCount.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { onOpenFilter(TaskFilter.OfList(overview.list.id)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmartGrid(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
counts.chunked(2).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
row.forEach { smart ->
|
||||
SmartCard(
|
||||
count = smart,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) },
|
||||
)
|
||||
}
|
||||
if (row.size == 1) Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color)
|
||||
|
||||
@Composable
|
||||
private fun smartStyle(smart: SmartList): SmartStyle {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
return when (smart) {
|
||||
SmartList.TODAY -> SmartStyle(Icons.Rounded.Today, R.string.smart_today, colors.primaryContainer, colors.onPrimaryContainer)
|
||||
SmartList.OVERDUE -> SmartStyle(Icons.Rounded.ErrorOutline, R.string.smart_overdue, colors.errorContainer, colors.onErrorContainer)
|
||||
SmartList.UPCOMING -> SmartStyle(Icons.Rounded.Upcoming, R.string.smart_upcoming, colors.tertiaryContainer, colors.onTertiaryContainer)
|
||||
else -> SmartStyle(Icons.AutoMirrored.Rounded.ListAlt, R.string.smart_all, colors.secondaryContainer, colors.onSecondaryContainer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean tonal tile. The expressive touch is subtle: rounded corners gently morph
|
||||
* rounder on press (the same quiet shape behaviour as the lists), not a loud
|
||||
* silhouette.
|
||||
*/
|
||||
@Composable
|
||||
private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val style = smartStyle(count.smart)
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val pressed by interaction.collectIsPressedAsState()
|
||||
val corner by animateDpAsState(if (pressed) 34.dp else 22.dp, label = "smartCorner")
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(corner),
|
||||
color = style.container,
|
||||
contentColor = style.onContainer,
|
||||
interactionSource = interaction,
|
||||
modifier = modifier.height(116.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Icon(style.icon, contentDescription = null)
|
||||
Column {
|
||||
Text(count.count.toString(), style = MaterialTheme.typography.headlineMedium)
|
||||
Text(stringResource(style.labelRes), style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredMessage(text: String, inner: PaddingValues) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package de.jeanlucmakiola.floret.ui.lists
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.domain.DayWindow
|
||||
import de.jeanlucmakiola.floret.domain.SmartList
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
||||
import de.jeanlucmakiola.floret.domain.TaskFiltering
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Clock
|
||||
|
||||
data class ListOverview(val list: TaskList, val openCount: Int)
|
||||
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
||||
data class SmartCount(val smart: SmartList, val count: Int)
|
||||
|
||||
sealed interface ListsUiState {
|
||||
data object Loading : ListsUiState
|
||||
data object Failure : ListsUiState
|
||||
data class Content(
|
||||
val smartCounts: List<SmartCount>,
|
||||
val groups: List<AccountGroup>,
|
||||
) : ListsUiState
|
||||
}
|
||||
|
||||
/** The home overview: smart lists with live counts, then user lists by account. */
|
||||
@HiltViewModel
|
||||
class ListsViewModel @Inject constructor(
|
||||
repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<ListsUiState> =
|
||||
combine(
|
||||
repository.taskLists(),
|
||||
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
|
||||
) { lists, openTasks ->
|
||||
buildContent(lists, openTasks) as ListsUiState
|
||||
}.catch { emit(ListsUiState.Failure) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
||||
|
||||
private fun buildContent(lists: List<TaskList>, openTasks: List<Task>): ListsUiState.Content {
|
||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||
fun count(smart: SmartList) =
|
||||
openTasks.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) }
|
||||
|
||||
val smartCounts = listOf(
|
||||
SmartCount(SmartList.TODAY, count(SmartList.TODAY)),
|
||||
SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)),
|
||||
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
|
||||
SmartCount(SmartList.ALL, openTasks.size),
|
||||
)
|
||||
val openByList = openTasks.groupingBy { it.listId }.eachCount()
|
||||
val groups = lists
|
||||
.groupBy { it.accountName }
|
||||
.map { (account, accountLists) ->
|
||||
AccountGroup(
|
||||
accountName = account,
|
||||
lists = accountLists.map { ListOverview(it, openByList[it.id] ?: 0) },
|
||||
)
|
||||
}
|
||||
.sortedBy { it.accountName.lowercase() }
|
||||
return ListsUiState.Content(smartCounts, groups)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package de.jeanlucmakiola.floret.ui.tasklist
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface TaskListUiState {
|
||||
data object Loading : TaskListUiState
|
||||
data object Failure : TaskListUiState
|
||||
data class Content(val tasks: List<Task>) : TaskListUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* The tasks for one [TaskFilter]. Call [bind] with the filter the screen shows;
|
||||
* the list re-emits live as the provider changes. Actions are fire-and-forget —
|
||||
* the resulting provider change flows back through the list automatically.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class TaskListViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val filter = MutableStateFlow<TaskFilter?>(null)
|
||||
|
||||
val state: StateFlow<TaskListUiState> =
|
||||
filter.filterNotNull()
|
||||
.flatMapLatest { f ->
|
||||
repository.tasks(f)
|
||||
.map<List<Task>, TaskListUiState> { TaskListUiState.Content(it) }
|
||||
.onStart { emit(TaskListUiState.Loading) }
|
||||
.catch { emit(TaskListUiState.Failure) }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
||||
|
||||
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
||||
|
||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
||||
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
|
||||
}
|
||||
|
||||
fun delete(task: Task) = viewModelScope.launch {
|
||||
runCatching { repository.deleteTask(task.taskId) }
|
||||
}
|
||||
|
||||
/** Inline "add task" — title only, into [listId]. */
|
||||
fun quickAdd(title: String, listId: Long) = viewModelScope.launch {
|
||||
if (title.isBlank() || listId <= 0L) return@launch
|
||||
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package de.jeanlucmakiola.floret.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MotionScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* App theme. Honors:
|
||||
* - System light/dark.
|
||||
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
|
||||
* derived from [FloretSeed].
|
||||
*
|
||||
* Mirrors Calendula's theme: MaterialExpressiveTheme routes all component +
|
||||
* custom motion through MaterialTheme.motionScheme. STANDARD over expressive()
|
||||
* is the same deliberate choice — spring choreography without the overshoot.
|
||||
*
|
||||
* A Settings screen can later override dynamicColor and theme mode; the M0
|
||||
* foundation just follows the system.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun FloretTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val ctx = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
|
||||
}
|
||||
darkTheme -> FloretDarkFallback
|
||||
else -> FloretLightFallback
|
||||
}
|
||||
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = FloretTypography,
|
||||
motionScheme = MotionScheme.standard(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Floret launcher icon foreground — PLACEHOLDER.
|
||||
Agendula launcher icon foreground.
|
||||
|
||||
A simple rounded check mark inside the 108dp adaptive-icon canvas
|
||||
(72dp safe zone). Deliberately not Calendula's calendar mark, so the two
|
||||
apps never look alike. Replace with real branding (a stylized floret)
|
||||
when the design lands — see docs/PLAN.md §9.
|
||||
Converted from design/icon/agendula_icon.svg (512x512 viewport): a rounded
|
||||
line-art task card with a check mark, plus a small Calendula bloom badge in
|
||||
the bottom-right corner — the sibling of Calendula's calendar mark.
|
||||
|
||||
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"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group
|
||||
android:pivotX="256"
|
||||
android:pivotY="242.76"
|
||||
android:scaleX="0.66"
|
||||
android:scaleY="0.66"
|
||||
android:translateY="13.24">
|
||||
<!-- Task card (rounded square, opening at the bottom-right for the bloom) -->
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="9"
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="20"
|
||||
android:strokeMiterLimit="12"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M36,55 l13,13 l25,-27" />
|
||||
android:pathData="M370.207 254.345V168.276C370.207 144.508 350.939 125.241 327.172 125.241H178.207C154.439 125.241 135.172 144.508 135.172 168.276V317.241C135.172 341.008 154.439 360.276 178.207 360.276H269.241" />
|
||||
<!-- Check mark -->
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="20"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M193 245.569L231.822 287L320 199" />
|
||||
<!-- Calendula bloom: petals around the centre -->
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="16"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M346.672 323.721C356.301 323.721 364.107 312.367 364.107 298.361C364.107 284.354 356.301 273 346.672 273C337.042 273 329.236 284.354 329.236 298.361C329.236 312.367 337.042 323.721 346.672 323.721Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="16"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M355.716 330.293C358.692 339.451 371.903 343.366 385.224 339.038C398.544 334.71 406.931 323.777 403.955 314.619C400.98 305.461 387.769 301.546 374.448 305.874C361.127 310.202 352.741 321.135 355.716 330.293Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="16"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M352.262 340.926C344.471 346.586 344.83 360.36 353.063 371.691C361.295 383.022 374.284 387.62 382.075 381.96C389.865 376.3 389.506 362.526 381.274 351.194C373.041 339.863 360.052 335.266 352.262 340.926Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="16"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M341.082 340.926C333.291 335.266 320.302 339.863 312.069 351.194C303.837 362.526 303.478 376.3 311.268 381.96C319.059 387.62 332.048 383.022 340.28 371.691C348.513 360.36 348.872 346.585 341.082 340.926Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="16"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M337.627 330.293C340.602 321.135 332.216 310.202 318.895 305.874C305.574 301.546 292.363 305.461 289.388 314.619C286.412 323.777 294.799 334.71 308.119 339.038C321.44 343.366 334.651 339.451 337.627 330.293Z" />
|
||||
<!-- Bloom centre -->
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="16"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M346.672 342.742C351.924 342.742 356.182 338.484 356.182 333.232C356.182 327.979 351.924 323.721 346.672 323.721C341.419 323.721 337.161 327.979 337.161 333.232C337.161 338.484 341.419 342.742 346.672 342.742Z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -1,9 +1,122 @@
|
||||
<resources>
|
||||
<string name="app_name">Floret</string>
|
||||
<string name="app_name">Agendula</string>
|
||||
<string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</string>
|
||||
|
||||
<!-- Generic -->
|
||||
<string name="task_untitled">Untitled task</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="delete">Delete</string>
|
||||
|
||||
<!-- Task list -->
|
||||
<string name="tasks_title">Tasks</string>
|
||||
<string name="task_list_empty">No tasks here yet.</string>
|
||||
<string name="task_list_failure">Could not load these tasks.</string>
|
||||
<string name="smart_no_date">No date</string>
|
||||
<string name="smart_completed">Completed</string>
|
||||
|
||||
<!-- Task detail -->
|
||||
<string name="task_detail_title">Task</string>
|
||||
<string name="task_detail_not_found">This task no longer exists.</string>
|
||||
<string name="edit">Edit</string>
|
||||
|
||||
<!-- Task edit -->
|
||||
<string name="edit_task_title">Edit task</string>
|
||||
<string name="new_task_title">New task</string>
|
||||
<string name="field_title">Title</string>
|
||||
<string name="field_description">Description</string>
|
||||
<string name="edit_save_failed">Could not save. Try again.</string>
|
||||
|
||||
<!-- Task list: inline add + swipe -->
|
||||
<string name="add_task_hint">Add a task</string>
|
||||
<string name="add_subtask_hint">Add a subtask</string>
|
||||
<string name="cd_add_task">Add task</string>
|
||||
<string name="cd_complete">Complete</string>
|
||||
<string name="cd_reopen">Reopen</string>
|
||||
<string name="task_deleted">Deleted</string>
|
||||
<string name="undo">Undo</string>
|
||||
<string name="cd_expand_subtasks">Show subtasks</string>
|
||||
<string name="cd_collapse_subtasks">Hide subtasks</string>
|
||||
|
||||
<!-- Sections (due-date buckets) -->
|
||||
<string name="section_overdue">Overdue</string>
|
||||
<string name="section_today">Today</string>
|
||||
<string name="section_upcoming">Upcoming</string>
|
||||
<string name="section_no_date">No date</string>
|
||||
<string name="section_completed">Completed</string>
|
||||
|
||||
<!-- Priority -->
|
||||
<string name="priority_none">None</string>
|
||||
<string name="priority_low">Low</string>
|
||||
<string name="priority_medium">Medium</string>
|
||||
<string name="priority_high">High</string>
|
||||
|
||||
<!-- Task detail -->
|
||||
<string name="detail_mark_complete">Mark complete</string>
|
||||
<string name="detail_mark_incomplete">Mark not complete</string>
|
||||
<string name="detail_list">List</string>
|
||||
<string name="detail_due">Due</string>
|
||||
<string name="detail_start">Starts</string>
|
||||
<string name="detail_priority">Priority</string>
|
||||
<string name="detail_progress">Progress</string>
|
||||
<string name="detail_subtasks">Subtasks</string>
|
||||
<string name="detail_parent">Parent task</string>
|
||||
<string name="detail_part_of">Part of</string>
|
||||
<string name="percent_complete">%1$d%%</string>
|
||||
|
||||
<!-- Task edit -->
|
||||
<string name="edit_list_label">List</string>
|
||||
<string name="edit_start_label">Starts</string>
|
||||
<string name="edit_due_label">Due</string>
|
||||
<string name="edit_all_day">All day</string>
|
||||
<string name="edit_priority_label">Priority</string>
|
||||
<string name="edit_progress_label">Progress</string>
|
||||
<string name="edit_parent_label">Parent task</string>
|
||||
<string name="edit_parent_none">None (top-level)</string>
|
||||
<string name="edit_parent_search">Search tasks</string>
|
||||
<string name="edit_parent_empty">No matching tasks in this list.</string>
|
||||
<string name="edit_reminder_label">Reminder</string>
|
||||
<string name="edit_set">Set</string>
|
||||
<string name="edit_clear">Clear</string>
|
||||
<string name="edit_title_hint">Task title</string>
|
||||
<string name="edit_when">When</string>
|
||||
<string name="edit_more_fields">More fields</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="dialog_cancel">Cancel</string>
|
||||
|
||||
<!-- Save conflict -->
|
||||
<string name="edit_conflict_title">Task changed elsewhere</string>
|
||||
<string name="edit_conflict_body">This task was updated somewhere else (a sync or another app) since you opened it. Overwrite those changes with your edits?</string>
|
||||
<string name="edit_conflict_overwrite">Overwrite</string>
|
||||
|
||||
<!-- Reminder offsets -->
|
||||
<string name="reminder_none">None</string>
|
||||
<string name="reminder_at_due">At time of task</string>
|
||||
<string name="reminder_5_min">5 minutes before</string>
|
||||
<string name="reminder_10_min">10 minutes before</string>
|
||||
<string name="reminder_30_min">30 minutes before</string>
|
||||
<string name="reminder_1_hour">1 hour before</string>
|
||||
<string name="reminder_1_day">1 day before</string>
|
||||
|
||||
<!-- Reminder custom lead time + per-list overrides -->
|
||||
<string name="reminder_custom">Custom</string>
|
||||
<string name="reminder_custom_with_value">Custom (%1$s)</string>
|
||||
<string name="reminder_custom_set">Set</string>
|
||||
<string name="reminder_custom_amount">Enter an amount</string>
|
||||
<string name="reminder_use_default">Use default</string>
|
||||
<string name="reminder_unit_minutes">Minutes</string>
|
||||
<string name="reminder_unit_hours">Hours</string>
|
||||
<string name="reminder_unit_days">Days</string>
|
||||
<string name="reminder_unit_weeks">Weeks</string>
|
||||
<string name="settings_list_reminders_title">Per-list reminders</string>
|
||||
<string name="settings_list_reminders_hint">Override the default reminder for individual lists</string>
|
||||
<string name="settings_list_reminder_inherits">Default (%1$s)</string>
|
||||
|
||||
<!-- Form validation -->
|
||||
<string name="error_blank_title">Enter a title.</string>
|
||||
<string name="error_no_list">Choose a list.</string>
|
||||
<string name="error_due_before_start">Due can\'t be before the start.</string>
|
||||
<string name="error_reminder_without_due">Set a due date to use a reminder.</string>
|
||||
|
||||
<!-- Lists overview -->
|
||||
<string name="lists_header">Lists</string>
|
||||
@@ -16,6 +129,23 @@
|
||||
<string name="smart_all">All</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 -->
|
||||
<string name="reminder_due_at">Due %1$s</string>
|
||||
<string name="reminder_channel_name">Task reminders</string>
|
||||
@@ -23,10 +153,86 @@
|
||||
|
||||
<!-- Provider / permission onboarding -->
|
||||
<string name="onboarding_no_provider_title">A tasks app is needed</string>
|
||||
<string name="onboarding_no_provider_body">Floret shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started.</string>
|
||||
<string name="onboarding_no_provider_body">Agendula shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started.</string>
|
||||
<string name="onboarding_permission_title">Allow access to your tasks</string>
|
||||
<string name="onboarding_permission_body">Floret needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string>
|
||||
<string name="onboarding_permission_body">Agendula needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string>
|
||||
<string name="onboarding_permission_button">Grant task access</string>
|
||||
<string name="onboarding_install_opentasks">Install OpenTasks</string>
|
||||
<string name="onboarding_install_tasksorg">Install tasks.org</string>
|
||||
|
||||
<!-- Reminder onboarding (one-time, after the provider grant) -->
|
||||
<string name="reminder_onboarding_title">Never miss what\'s due</string>
|
||||
<string name="reminder_onboarding_body">Tasks apps don\'t send reminders themselves, so Agendula delivers them for you. Turn them on to get a notification when a task is due.</string>
|
||||
<string name="reminder_onboarding_enable_button">Enable reminders</string>
|
||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||
<string name="reminder_benefit_delivery_title">Agendula reminds you</string>
|
||||
<string name="reminder_benefit_delivery_body">It schedules a notification for each task with a due date.</string>
|
||||
<string name="reminder_benefit_timing_title">On your schedule</string>
|
||||
<string name="reminder_benefit_timing_body">Choose how far ahead to be reminded in Settings.</string>
|
||||
<string name="reminder_benefit_reversible_title">Change it anytime</string>
|
||||
<string name="reminder_benefit_reversible_body">Turn reminders off whenever you like — it\'s just a switch.</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_section_appearance">Appearance</string>
|
||||
<string name="settings_appearance_subtitle">Theme and colour</string>
|
||||
<string name="settings_section_task_form">Task form</string>
|
||||
<string name="settings_task_form_subtitle">Default fields, list and subtasks</string>
|
||||
<string name="settings_reminders_subtitle">Notifications for due tasks</string>
|
||||
<string name="settings_form_fields_hint">Fields shown by default on a new task. The rest sit behind \"More fields\".</string>
|
||||
<string name="settings_dynamic_color_unavailable">Available on Android 12 and later</string>
|
||||
<!-- About -->
|
||||
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Source</string>
|
||||
<string name="settings_license">License</string>
|
||||
<string name="settings_about_support">Support development</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_about_logo_desc">Agendula app icon</string>
|
||||
<string name="settings_language">App language</string>
|
||||
<string name="settings_language_auto">System default</string>
|
||||
<string name="settings_report_problem">Report a problem</string>
|
||||
<string name="settings_report_problem_hint">Open the issue tracker</string>
|
||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula</string>
|
||||
<string name="crash_report_issue_title">Crash report</string>
|
||||
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new</string>
|
||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/src/branch/main/LICENSE</string>
|
||||
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||
<string name="settings_theme">Theme</string>
|
||||
<string name="settings_theme_system">Follow system</string>
|
||||
<string name="settings_theme_light">Light</string>
|
||||
<string name="settings_theme_dark">Dark</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
|
||||
<string name="settings_section_reminders">Reminders</string>
|
||||
<string name="settings_reminders">Due reminders</string>
|
||||
<string name="settings_reminders_hint">Notify me when a task is due</string>
|
||||
<string name="settings_default_reminder">When to remind</string>
|
||||
<string name="settings_exact_alarms">Exact timing</string>
|
||||
<string name="settings_exact_alarms_allowed">Reminders fire at the exact time</string>
|
||||
<string name="settings_exact_alarms_blocked">Blocked — tap to allow exact reminders</string>
|
||||
<string name="settings_section_tasks">Tasks</string>
|
||||
<string name="settings_default_list">Default list</string>
|
||||
<string name="settings_default_list_first">First available list</string>
|
||||
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
|
||||
<string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string>
|
||||
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
|
||||
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
|
||||
|
||||
<!-- Reminder lead times (custom amounts) -->
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%1$d minute before</item>
|
||||
<item quantity="other">%1$d minutes before</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="one">%1$d hour before</item>
|
||||
<item quantity="other">%1$d hours before</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="one">%1$d day before</item>
|
||||
<item quantity="other">%1$d days before</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="one">%1$d week before</item>
|
||||
<item quantity="other">%1$d weeks before</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme.Floret" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<style name="Theme.Agendula" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
|
||||
11
app/src/main/res/xml/locales_config.xml
Normal file
11
app/src/main/res/xml/locales_config.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The languages Agendula ships translations for. Single source of truth: each
|
||||
entry needs a matching res/values-<tag>/strings.xml, and is surfaced in both
|
||||
the in-app language picker (parsed at runtime by AppLanguage) and the system
|
||||
per-app language settings (Android 13+, via android:localeConfig). To add a
|
||||
translation, drop in the values-<tag> folder and add one <locale> line here.
|
||||
-->
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
</locale-config>
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
/** A [ColumnReader] backed by a Map, so mappers test without a real Cursor. */
|
||||
class MapColumnReader(private val values: Map<String, Any?>) : ColumnReader {
|
||||
@@ -1,11 +1,11 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.floret.domain.Priority
|
||||
import de.jeanlucmakiola.floret.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TaskMapperTest {
|
||||
@@ -1,10 +1,10 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.floret.domain.Priority
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -28,6 +28,50 @@ class TaskWriteMapperTest {
|
||||
assertThat(values[Tasks.TZ]).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `percent complete is written and clamped to 0-100`() {
|
||||
val values = TaskWriteMapper.taskValues(
|
||||
TaskForm(title = "x", listId = 1L, percentComplete = 140),
|
||||
tzId = "UTC",
|
||||
)
|
||||
assertThat(values[Tasks.PERCENT_COMPLETE]).isEqualTo(100)
|
||||
|
||||
val unset = TaskWriteMapper.taskValues(TaskForm(title = "x", listId = 1L), tzId = "UTC")
|
||||
assertThat(unset[Tasks.PERCENT_COMPLETE]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress keeps completion in sync both ways`() {
|
||||
// 100% completes the task (timestamp left to the provider / existing value).
|
||||
val done = TaskWriteMapper.taskValues(
|
||||
TaskForm(title = "x", listId = 1L, percentComplete = 100),
|
||||
tzId = "UTC",
|
||||
)
|
||||
assertThat(done[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_COMPLETED)
|
||||
assertThat(done.containsKey(Tasks.COMPLETED)).isFalse()
|
||||
|
||||
// Below 100% reopens it and clears the completion timestamp.
|
||||
val reopened = TaskWriteMapper.taskValues(
|
||||
TaskForm(title = "x", listId = 1L, percentComplete = 75),
|
||||
tzId = "UTC",
|
||||
)
|
||||
assertThat(reopened[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_IN_PROCESS)
|
||||
assertThat(reopened[Tasks.COMPLETED]).isNull()
|
||||
|
||||
// No percent in the form ⇒ status is left to the complete toggle.
|
||||
val untouched = TaskWriteMapper.taskValues(TaskForm(title = "x", listId = 1L), tzId = "UTC")
|
||||
assertThat(untouched.containsKey(Tasks.STATUS)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parent id is written so a task can be filed under another`() {
|
||||
val values = TaskWriteMapper.taskValues(
|
||||
TaskForm(title = "x", listId = 1L, parentId = 7L),
|
||||
tzId = "UTC",
|
||||
)
|
||||
assertThat(values[Tasks.PARENT_ID]).isEqualTo(7L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day task clears the timezone`() {
|
||||
val form = TaskForm(
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -35,4 +35,13 @@ class TaskFormTest {
|
||||
val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate()
|
||||
assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress and parent auto-reveal only when they carry a value`() {
|
||||
assertThat(TaskForm(title = "x", listId = 1).populatedFields()).isEmpty()
|
||||
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 0, parentId = 0).populatedFields())
|
||||
.isEmpty()
|
||||
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields())
|
||||
.containsExactly(TaskFormField.Progress, TaskFormField.Parent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class TaskSectionsTest {
|
||||
|
||||
private val dayMs = 86_400_000L
|
||||
private val todayStart = Instant.fromEpochMilliseconds(1000 * dayMs)
|
||||
private val todayEnd = Instant.fromEpochMilliseconds(1001 * dayMs)
|
||||
|
||||
private fun at(millis: Long) = Instant.fromEpochMilliseconds(millis)
|
||||
private fun sectionOf(task: Task) = TaskSections.sectionOf(task, todayStart, todayEnd)
|
||||
|
||||
@Test
|
||||
fun `buckets by due relative to today`() {
|
||||
assertThat(sectionOf(testTask(due = at(1000 * dayMs - 1)))).isEqualTo(TaskSection.OVERDUE)
|
||||
assertThat(sectionOf(testTask(due = at(1000 * dayMs + 1)))).isEqualTo(TaskSection.TODAY)
|
||||
assertThat(sectionOf(testTask(due = at(1001 * dayMs + 1)))).isEqualTo(TaskSection.UPCOMING)
|
||||
assertThat(sectionOf(testTask(due = null))).isEqualTo(TaskSection.NO_DATE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completed always lands in completed regardless of due`() {
|
||||
val overdueButDone = testTask(status = TaskStatus.COMPLETED, due = at(1000 * dayMs - 1))
|
||||
assertThat(sectionOf(overdueButDone)).isEqualTo(TaskSection.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `of returns only non-empty sections in fixed order`() {
|
||||
val tasks = listOf(
|
||||
testTask(id = 1, due = at(1001 * dayMs + 1)), // upcoming
|
||||
testTask(id = 2, due = at(1000 * dayMs - 1)), // overdue
|
||||
testTask(id = 3, status = TaskStatus.COMPLETED), // completed
|
||||
)
|
||||
val sections = TaskSections.of(tasks, todayStart, todayEnd)
|
||||
assertThat(sections.map { it.section })
|
||||
.containsExactly(TaskSection.OVERDUE, TaskSection.UPCOMING, TaskSection.COMPLETED)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty input yields no sections`() {
|
||||
assertThat(TaskSections.of(emptyList(), todayStart, todayEnd)).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
class DayWindowTest {
|
||||
|
||||
@Test
|
||||
fun `today spans exactly 24h in UTC and contains now`() {
|
||||
val zone = ZoneId.of("UTC")
|
||||
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
|
||||
val (start, end) = DayWindow.today(now, zone)
|
||||
|
||||
assertThat(end.toEpochMilliseconds() - start.toEpochMilliseconds()).isEqualTo(86_400_000L)
|
||||
assertThat(now >= start).isTrue()
|
||||
assertThat(now < end).isTrue()
|
||||
// In UTC, midnight aligns to a multiple of one day since the epoch.
|
||||
assertThat(start.toEpochMilliseconds() % 86_400_000L).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start is local midnight in a non-UTC zone`() {
|
||||
val zone = ZoneId.of("Europe/Berlin")
|
||||
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
|
||||
val (start, end) = DayWindow.today(now, zone)
|
||||
|
||||
val startLocal = java.time.Instant.ofEpochMilli(start.toEpochMilliseconds()).atZone(zone)
|
||||
assertThat(startLocal.hour).isEqualTo(0)
|
||||
assertThat(startLocal.minute).isEqualTo(0)
|
||||
assertThat(now >= start).isTrue()
|
||||
assertThat(now < end).isTrue()
|
||||
}
|
||||
}
|
||||
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 |
@@ -1,6 +1,6 @@
|
||||
# Floret — architecture
|
||||
# Agendula — architecture
|
||||
|
||||
This document describes how Floret is built **as it stands today**. For the
|
||||
This document describes how Agendula is built **as it stands today**. For the
|
||||
*why* behind the big decisions and the long-term plan, see
|
||||
[`PLAN.md`](PLAN.md); for status and what's next, see [`ROADMAP.md`](ROADMAP.md).
|
||||
|
||||
@@ -8,10 +8,10 @@ This document describes how Floret is built **as it stands today**. For the
|
||||
|
||||
## 1. The thesis in one sentence
|
||||
|
||||
Floret is a Material 3 Expressive **front-end** over the OpenTasks
|
||||
Agendula is a Material 3 Expressive **front-end** over the OpenTasks
|
||||
`TaskContract` provider — it reads, writes, and reminds on top of a tasks store
|
||||
that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over
|
||||
CalDAV. **Floret owns no database and no sync stack.** It is the task-list
|
||||
CalDAV. **Agendula owns no database and no sync stack.** It is the task-list
|
||||
sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula),
|
||||
which does the same thing for `CalendarContract`.
|
||||
|
||||
@@ -69,7 +69,7 @@ Both are bound in Hilt in `data/di/DataModule.kt`.
|
||||
|
||||
## 3. Module & package layout
|
||||
|
||||
Single `:app` module (Posture A). Package root `de.jeanlucmakiola.floret`.
|
||||
Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`.
|
||||
|
||||
| Package | Contents |
|
||||
|---|---|
|
||||
@@ -80,7 +80,7 @@ Single `:app` module (Posture A). Package root `de.jeanlucmakiola.floret`.
|
||||
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). |
|
||||
| `data/demo/` | `DemoSeeder` (debug-only sample data). |
|
||||
| `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. |
|
||||
| root | `FloretApp` (Hilt app), `MainActivity`. |
|
||||
| root | `AgendulaApp` (Hilt app), `MainActivity`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -106,7 +106,7 @@ runtime perms for the active provider.
|
||||
### 4.2 `TasksContract`
|
||||
|
||||
A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names,
|
||||
table paths, status/priority constants, the local-account type. Floret does
|
||||
table paths, status/priority constants, the local-account type. Agendula does
|
||||
**not** take a runtime dependency on OpenTasks; the authority is injected from
|
||||
`ProviderResolver`, never hardcoded in the contract.
|
||||
|
||||
@@ -117,7 +117,7 @@ occurrence is a row with the joined list colour, account, etc.), maps each
|
||||
cursor row through `ColumnReader` → `TaskMapper` → domain `Task`, and exposes
|
||||
the result as a `Flow`. A `ContentObserver` on the active authority's
|
||||
Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change
|
||||
re-emits** — Floret's own writes *and* external sync (DAVx5 pulling new tasks)
|
||||
re-emits** — Agendula's own writes *and* external sync (DAVx5 pulling new tasks)
|
||||
update the UI live, and multiple sync sources coexist in one list.
|
||||
|
||||
### 4.4 Writes — repository API
|
||||
@@ -170,7 +170,7 @@ priority / etc.). None of this touches Android, which is why it's all in
|
||||
## 6. Reminders — the one subsystem that does NOT mirror Calendula
|
||||
|
||||
Calendula relies on the calendar provider broadcasting `EVENT_REMINDER`. **Tasks
|
||||
providers broadcast nothing**, so Floret schedules its own (`data/reminders/`):
|
||||
providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`):
|
||||
|
||||
- **`ReminderScheduler.sync()`** reads upcoming, non-closed, due-dated tasks
|
||||
within a rolling **30-day window**, computes each trigger as `due − lead`
|
||||
@@ -186,7 +186,7 @@ providers broadcast nothing**, so Floret schedules its own (`data/reminders/`):
|
||||
sync changed the data). The store lets each run diff like Calendula diffs
|
||||
reminder rows.
|
||||
|
||||
This is the single largest piece of genuinely-new code in Floret.
|
||||
This is the single largest piece of genuinely-new code in Agendula.
|
||||
|
||||
---
|
||||
|
||||
@@ -201,7 +201,7 @@ This is the single largest piece of genuinely-new code in Floret.
|
||||
domain, and `TasksRepository` do not change** — only the resolver's default and
|
||||
some manifest perms.
|
||||
|
||||
Bundling the provider bundles **storage, not sync** — Floret stays a pure
|
||||
Bundling the provider bundles **storage, not sync** — Agendula stays a pure
|
||||
front-end over open backends either way.
|
||||
|
||||
---
|
||||
@@ -228,8 +228,8 @@ checkbox/FAB/swipe motion).
|
||||
|
||||
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module
|
||||
(`TasksDataSource` → `AndroidTasksDataSource`, `TasksRepository` →
|
||||
`TasksRepositoryImpl`) and a `@Provides` module (the `floret_prefs` DataStore,
|
||||
the `@IoDispatcher`). `FloretApp` is the `@HiltAndroidApp` entry point;
|
||||
`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore,
|
||||
the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
|
||||
`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected.
|
||||
|
||||
---
|
||||
@@ -243,7 +243,7 @@ the `@IoDispatcher`). `FloretApp` is the `@HiltAndroidApp` entry point;
|
||||
| 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 |
|
||||
| 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`) |
|
||||
| Distribution | F-Droid (`fdroid-metadata/`) |
|
||||
|
||||
|
||||
45
docs/PLAN.md
45
docs/PLAN.md
@@ -1,23 +1,25 @@
|
||||
# Floret — implementation plan
|
||||
# Agendula — implementation plan
|
||||
|
||||
> A modern Material 3 Expressive **task** app for Android. Reads, writes, and
|
||||
> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync /
|
||||
> DecSync over CalDAV), with no own sync stack.
|
||||
>
|
||||
> Sibling to **Calendula**. Calendula is a calendar over `CalendarContract`;
|
||||
> Floret is a to-do list over the **OpenTasks `TaskContract` provider**. A
|
||||
> Calendula flower head is made of many small *florets* — the individual items
|
||||
> that make up the bloom.
|
||||
> Agendula is a to-do list over the **OpenTasks `TaskContract` provider**. The
|
||||
> name mirrors Calendula's: *agenda* (Latin, “things to be done”) + the `-ula`
|
||||
> ending — and a Calendula flower head is itself a cluster of small *florets*,
|
||||
> so the two apps are florets of one bloom.
|
||||
|
||||
Working identifiers (placeholder, easily renamed):
|
||||
`applicationId = de.jeanlucmakiola.floret`, app name **Floret**.
|
||||
Identifiers: `applicationId = de.jeanlucmakiola.agendula`, app name **Agendula**
|
||||
(renamed from the working title *Floret*, which was promoted to the shared
|
||||
family / design-language name).
|
||||
|
||||
---
|
||||
|
||||
## 0. The thesis this app embodies
|
||||
|
||||
A nice M3-Expressive front end over open backends, no reinvented storage or
|
||||
sync. Calendula proved the pattern against the OS calendar provider. Floret
|
||||
sync. Calendula proved the pattern against the OS calendar provider. Agendula
|
||||
applies it to tasks. The crucial difference: **there is no OS tasks provider**,
|
||||
so we depend on a tasks *provider app* being present — exactly as Calendula
|
||||
depends on a sync app like DAVx5 for CalDAV.
|
||||
@@ -46,7 +48,7 @@ string leak above the data layer.**
|
||||
|
||||
Calendula's layering is the template. Lift these **verbatim or near-verbatim**:
|
||||
|
||||
| Area | From Calendula | Change for Floret |
|
||||
| Area | From Calendula | Change for Agendula |
|
||||
|---|---|---|
|
||||
| Gradle setup | `build.gradle.kts`, `settings.gradle.kts`, `gradle/libs.versions.toml`, wrapper, `key.properties` flow, versionCode-from-tag CI | namespace/appId only |
|
||||
| Build config | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, compileSdk 37 / minSdk 29 / targetSdk 36, Java 17 | identical |
|
||||
@@ -65,7 +67,7 @@ Calendula's layering is the template. Lift these **verbatim or near-verbatim**:
|
||||
`TaskContract` data layer, the task screens, and a **self-scheduled reminder
|
||||
engine** (see §6 — the one place Calendula's pattern does *not* carry over).
|
||||
|
||||
### What does NOT exist here (why Floret is simpler than Calendula)
|
||||
### What does NOT exist here (why Agendula is simpler than Calendula)
|
||||
|
||||
No month/week/day grid rendering. No recurrence-scoped writes ("this & following"
|
||||
vs "whole series"). No timezone/all-day gymnastics. Tasks are a flat-or-lightly-
|
||||
@@ -76,7 +78,7 @@ nested list with a due date and a checkbox.
|
||||
## 2. Module & package layout
|
||||
|
||||
Single `:app` module for A (mirrors Calendula). Package root
|
||||
`de.jeanlucmakiola.floret`.
|
||||
`de.jeanlucmakiola.agendula`.
|
||||
|
||||
```
|
||||
domain/
|
||||
@@ -111,10 +113,10 @@ ui/
|
||||
edit/ TaskEditScreen + VM + UiState
|
||||
settings/ (copy + adapt)
|
||||
permission/ provider-presence + permission + POST_NOTIFICATIONS onboarding
|
||||
FloretHost.kt nav host (mirrors CalendarHost)
|
||||
AgendulaHost.kt nav host (mirrors CalendarHost)
|
||||
RootScreen.kt
|
||||
widget/ Glance task widget (later milestone)
|
||||
FloretApp.kt, MainActivity.kt
|
||||
AgendulaApp.kt, MainActivity.kt
|
||||
```
|
||||
|
||||
---
|
||||
@@ -217,9 +219,9 @@ Material 3 Expressive throughout: `MaterialExpressiveTheme`,
|
||||
Three gates, in order:
|
||||
|
||||
1. **Provider present?** `ProviderResolver.detect()`. If none → a screen
|
||||
explaining Floret needs a tasks provider, with one-tap links to install
|
||||
explaining Agendula needs a tasks provider, with one-tap links to install
|
||||
**OpenTasks** (FOSS) or **tasks.org**, plus "I use DAVx5 — set its Tasks app".
|
||||
(This is Floret's "needs DAVx5" moment. Disappears entirely under Posture B.)
|
||||
(This is Agendula's "needs DAVx5" moment. Disappears entirely under Posture B.)
|
||||
2. **Tasks read/write permission** — request the active provider's runtime perms
|
||||
(`org.dmfs.permission.*` etc.). Declared in the manifest *and* requested at
|
||||
runtime; resolved dynamically from the detected provider.
|
||||
@@ -235,7 +237,7 @@ exactly like Calendula's launcher query.
|
||||
|
||||
Calendula relies on the **calendar provider broadcasting `EVENT_REMINDER`** and
|
||||
just posts the notification (Etar model). **Tasks providers do not broadcast
|
||||
reminders.** So Floret must schedule its own:
|
||||
reminders.** So Agendula must schedule its own:
|
||||
|
||||
- `DueReminderScheduler` (AlarmManager, exact alarms via `USE_EXACT_ALARM` /
|
||||
`SCHEDULE_EXACT_ALARM`) sets an alarm per task at `DUE` minus the chosen
|
||||
@@ -273,7 +275,7 @@ receiver. **No `EVENT_REMINDER` receiver** (doesn't apply).
|
||||
## 8. Milestones
|
||||
|
||||
- **M0 — Skeleton.** Copy Gradle/version-catalog/theme/DI/app+activity from
|
||||
Calendula, rename to Floret. Builds, shows themed empty scaffold. *(~½ day)*
|
||||
Calendula, rename to Agendula. Builds, shows themed empty scaffold. *(~½ day)*
|
||||
- **M1 — Read path.** `TasksContract` + `ProviderResolver` + `OpenTasksDataSource`
|
||||
+ repository. Lists overview + task list render real synced tasks (read-only),
|
||||
live-updating via ContentObserver. *(the meaty milestone)*
|
||||
@@ -297,21 +299,22 @@ M4 (subtasks) and M5 (reminders) are the two spots needing real thought.
|
||||
|
||||
## 9. Open decisions / to verify
|
||||
|
||||
1. **Name** — `Floret` is the working title (florets compose a Calendula head).
|
||||
Confirm or replace before M0 (touches appId, namespace, package).
|
||||
1. ~~**Name**~~ — **Agendula** (final): *agenda* + Calendula's `-ula`. Renamed
|
||||
from the working title *Floret*, which was promoted to the family /
|
||||
design-language name.
|
||||
2. **tasks.org provider authority** — verify on a real device; OpenTasks
|
||||
(`org.dmfs.tasks`) is the certain target for v1.
|
||||
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
|
||||
4. **B authority choice** — bundling `org.dmfs.tasks` makes Floret a *replacement*
|
||||
4. **B authority choice** — bundling `org.dmfs.tasks` makes Agendula a *replacement*
|
||||
for OpenTasks (one authority owner per device). Intended (one app instead of
|
||||
two), but a conscious choice.
|
||||
5. **Repo home** — sibling Gitea repo next to Calendula, MIT license, same CI.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sync sources Floret inherits for free (README copy)
|
||||
## 10. Sync sources Agendula inherits for free (README copy)
|
||||
|
||||
Because Floret builds on the provider, not on any one sync app, it works with
|
||||
Because Agendula builds on the provider, not on any one sync app, it works with
|
||||
**anything that writes to the tasks provider**: DAVx5 (CalDAV), SmoothSync,
|
||||
CalDAV-Sync, DecSync CC, and any Android sync adapter — no per-app integration.
|
||||
Google Tasks / Microsoft To Do are out of scope by design (proprietary, would
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Floret — documentation
|
||||
# Agendula — documentation
|
||||
|
||||
Floret is a Material 3 Expressive **task** app for Android: a pure front-end over
|
||||
Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over
|
||||
the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync
|
||||
over CalDAV), with no own database or sync stack. Sibling to
|
||||
[Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). See the
|
||||
@@ -10,7 +10,7 @@ top-level [`../README.md`](../README.md) for the project pitch.
|
||||
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Floret is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. |
|
||||
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Agendula is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. |
|
||||
| [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0–M6 + Posture B), what's done, open decisions, how to build/verify. |
|
||||
| [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". |
|
||||
| [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. |
|
||||
|
||||
@@ -1,101 +1,163 @@
|
||||
# Floret — releasing
|
||||
# Agendula — releasing
|
||||
|
||||
Floret is distributed through a **self-hosted F-Droid repo** (on Hetzner) with a
|
||||
human-readable **Gitea release** per tag. Both are produced automatically by
|
||||
`.gitea/workflows/release.yaml` when you push a tag. There are no APK assets on
|
||||
the Gitea release itself — distribution lives in the F-Droid repo; the release is
|
||||
the changelog of record.
|
||||
Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) with
|
||||
a human-readable **Gitea release** per version. Both are produced automatically
|
||||
by `.gitea/workflows/release.yaml` when a **bumped `versionName` reaches `main`**
|
||||
— the pipeline builds and publishes that version, then creates the matching
|
||||
`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
|
||||
release — CI substitutes them from the tag:
|
||||
A release is defined by the `versionName`/`versionCode` committed in
|
||||
`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`).
|
||||
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (e.g. `0.2.0` → `200`,
|
||||
`1.3.4` → `10304`).
|
||||
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `0.2.0`)
|
||||
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`0.2.0` → `200`,
|
||||
`1.3.4` → `10304`)
|
||||
|
||||
The values committed in `app/build.gradle.kts` are just the local/dev default;
|
||||
keep them roughly matching the latest released tag, but the tag wins at release
|
||||
time.
|
||||
So `MINOR` and `PATCH` each have room for 0–99. The release pipeline reads
|
||||
`versionName`, pins `versionCode` to the derived value, builds, publishes, and —
|
||||
once the APK is live — creates the tag `v<versionName>` at that commit. The tag
|
||||
is an **output** of a successful release, not its trigger, so a tag always marks
|
||||
a fully-shipped version (and a failure before publish leaves no tag, so
|
||||
re-running the workflow safely retries).
|
||||
|
||||
---
|
||||
|
||||
## Cutting a release
|
||||
|
||||
1. **Update `CHANGELOG.md`.** Move items out of `[Unreleased]` into a new
|
||||
`## [X.Y.Z]` section. The release pipeline extracts everything between
|
||||
`## [X.Y.Z]` and the next `## [` heading and uses it verbatim as both the
|
||||
Gitea release notes and the F-Droid per-version "What's New"
|
||||
(`changelogs/<versionCode>.txt`). If no matching section exists, a fallback
|
||||
line is used — so the heading **must** match the tag's version exactly.
|
||||
2. **Commit** the changelog on `main`.
|
||||
3. **Tag and push:**
|
||||
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
|
||||
feature/fix branches for this release into it. Everything below happens on
|
||||
that branch, before it reaches `main`.
|
||||
2. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items under a new
|
||||
`## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading
|
||||
and the next `## [` becomes both the Gitea release notes and the F-Droid
|
||||
per-version "What's New". The heading **must** match the version exactly.
|
||||
3. **Bump the committed `versionName`** (and `versionCode`) in
|
||||
`app/build.gradle.kts`. **This bump is what triggers the release** when the
|
||||
branch merges to `main`. Then run
|
||||
```sh
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
scripts/sync_changelog_to_fastlane.sh
|
||||
```
|
||||
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
|
||||
assemble and a Trivy scan, once per PR. Docs/metadata-only PRs skip the Android
|
||||
build but still report a green `CI` check.
|
||||
- **`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.
|
||||
|
||||
### Codeberg direct-download channel
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Secrets (Gitea → repo Settings → Actions → Secrets)
|
||||
|
||||
The workflow fails loudly if the F-Droid ones are missing — it will **never**
|
||||
auto-generate a repo key (that would rotate the repo fingerprint and break every
|
||||
user's pinned repo).
|
||||
|
||||
| Secret | Purpose |
|
||||
| --- | --- |
|
||||
| `ci` | Sanity gate: unit tests + a debug build (catches version-substitution drift). The other jobs depend on this. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | **App** signing key — signs the APK. Losing it means existing installs can't be updated. |
|
||||
| `FDROID_KEYSTORE_BASE64` | **F-Droid repo** signing key (`keystore.p12`, base64). Signs the repo index. |
|
||||
| `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. |
|
||||
| `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. |
|
||||
|
||||
Both deploy and release steps are **re-run safe** (idempotent upserts), so a
|
||||
failed run can be retried.
|
||||
The app key signs APKs; the repo key signs the index (its fingerprint is what
|
||||
users pin). Neither key nor `config.yml` is ever uploaded to the server — they
|
||||
live only in CI secrets and are reconstructed in-runner (nginx serves only
|
||||
`repo/`).
|
||||
|
||||
---
|
||||
|
||||
## Required CI secrets
|
||||
## F-Droid metadata (single source of truth)
|
||||
|
||||
Configured in the Gitea repo settings; the workflow fails loudly if the F-Droid
|
||||
ones are missing (it will **never** auto-generate a repo key — that would rotate
|
||||
the repo fingerprint and break every user's pinned repo).
|
||||
|
||||
| Secret | Used for |
|
||||
|---|---|
|
||||
| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | App signing keystore (the APK). |
|
||||
| `FDROID_KEYSTORE_BASE64`, `FDROID_CONFIG_BASE64` | The F-Droid **repo** signing key + `config.yml`. Never uploaded to the server. |
|
||||
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | SFTP target for the published `repo/` + `metadata/`. |
|
||||
| `GITHUB_TOKEN` | Gitea API (release create/patch, asset upload). |
|
||||
|
||||
The repo signing key and `config.yml` come from secrets at build time and are
|
||||
**never** pulled from or pushed back to the server, so they can't leak into the
|
||||
web-served tree (nginx serves only `repo/`).
|
||||
Store-listing text lives in **`fastlane/metadata/android/<locale>/`** — the same
|
||||
tree the official F-Droid repo harvests from source. At release time
|
||||
`scripts/fastlane_to_fdroid_localized.sh` transforms it into the F-Droid repo's
|
||||
"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.
|
||||
|
||||
---
|
||||
|
||||
## Key rotation / repo recovery
|
||||
## Crash deobfuscation
|
||||
|
||||
A manual `workflow_dispatch` run (ref = a branch, not a tag) skips all the
|
||||
tag-only build steps: it just re-signs the existing index with the configured
|
||||
repo key and re-uploads. Use this to recover the repo or rotate infrastructure
|
||||
**without** publishing a new APK.
|
||||
|
||||
---
|
||||
|
||||
## Push CI (non-tag)
|
||||
|
||||
Every push to any branch runs `.gitea/workflows/ci.yaml`: `lintDebug` →
|
||||
`testDebugUnitTest` → `assembleDebug`, plus a Trivy filesystem scan on `main`.
|
||||
Keep this green before tagging.
|
||||
|
||||
---
|
||||
|
||||
## F-Droid metadata
|
||||
|
||||
App store listing lives in `fdroid-metadata/` (`de.jeanlucmakiola.floret.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.
|
||||
Each release attaches `mapping-<version>.txt.gz` (the R8 mapping) to its Gitea
|
||||
release. To deobfuscate a user stacktrace, download the mapping for that version
|
||||
and run it through `retrace`.
|
||||
|
||||
118
docs/ROADMAP.md
118
docs/ROADMAP.md
@@ -1,4 +1,4 @@
|
||||
# Floret — roadmap
|
||||
# Agendula — roadmap
|
||||
|
||||
Where the project is and where it's going. This is the **status** view; the
|
||||
design rationale behind each milestone lives in [`PLAN.md`](PLAN.md), and how the
|
||||
@@ -11,9 +11,15 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
|
||||
## Current state (one line)
|
||||
|
||||
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
|
||||
provider is **done and unit-tested**; the Material 3 Expressive UI is being
|
||||
built screen by screen on top of it. App builds, gates on provider/permission,
|
||||
and renders the lists overview.
|
||||
provider is **done and unit-tested**, and the Material 3 Expressive UI is built
|
||||
through **M5**: lists → task list (swipe gestures, inline add, smart-list section
|
||||
headers) → detail / edit with full CRUD, date-time pickers, priority,
|
||||
percent-complete, conflict-safe saves, per-task reminders, and subtask
|
||||
create + reparent — plus a one-time reminder onboarding step and a Settings
|
||||
screen (theme, dynamic colour, due-reminder master toggle + default offset +
|
||||
exact-alarm status, default list, and the add-a-subtask-row opt-out). Remaining
|
||||
work is M6 (Glance widget, translations, F-Droid release; the Settings screen
|
||||
landed early with M5 and still needs a language entry).
|
||||
|
||||
---
|
||||
|
||||
@@ -39,36 +45,88 @@ The complete non-visual stack:
|
||||
- JVM unit tests across mappers, filtering, sorting, form, value mapping, and
|
||||
day windows.
|
||||
|
||||
### 🚧 M2 — Screens: lists, task list, complete & CRUD
|
||||
Replace the functional scaffold with the real Material 3 Expressive UI, screen
|
||||
by screen, against the already-built ViewModels.
|
||||
### ✅ M2 — Screens: lists, task list, complete & CRUD
|
||||
The real Material 3 Expressive UI, built screen by screen against the M1
|
||||
ViewModels.
|
||||
- ✅ Provider/permission onboarding gate (`RootScreen`).
|
||||
- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
|
||||
- 🚧 Navigation host wiring (the `onOpenFilter` / `onNewTask` callbacks in
|
||||
`RootScreen` are currently stubs).
|
||||
- ⬜ Task list screen — checkbox rows, swipe-to-complete / swipe-to-delete,
|
||||
inline add field, section headers for smart lists, FAB.
|
||||
- ⬜ Toggle complete (swipe + checkbox), create / edit / delete, local-list
|
||||
creation wired to the UI.
|
||||
- ✅ Navigation host wiring (`AgendulaNavHost` + `Dest` route table: lists → task
|
||||
list → detail / edit, each binding its M1 ViewModel from route args).
|
||||
- ✅ Task list screen — checkbox rows + toggle-complete, swipe-to-complete /
|
||||
-delete (`SwipeToDismissBox`), inline add field (`InlineAdd` → `quickAdd`),
|
||||
smart-list section headers (`TaskSections`).
|
||||
- ✅ Detail + edit screens — detail shows title / description / subtasks with
|
||||
edit+delete; edit has the full CRUD form (title, description, save) wired to a
|
||||
list picker.
|
||||
|
||||
### ⬜ M3 — Detail / edit polish
|
||||
Task detail and edit screens: due/start date-time pickers, priority,
|
||||
percent-complete, conflict-safe saves (re-check before overwrite), smart-list
|
||||
section presentation.
|
||||
### ✅ M3 — Detail / edit polish
|
||||
- ✅ Due / start date-time pickers (`DateTimePickerFlow` in `TaskEditScreen`).
|
||||
- ✅ Priority — coloured by level: green / amber / red pastels (`priorityFill`;
|
||||
M3 has no priority role, only `error`). A shared `ui/common/PriorityChip` on the
|
||||
list and detail screens, and the edit form's M3 segmented selector tints its
|
||||
active segment in the chosen level's hue.
|
||||
- ✅ Smart-list section presentation (`TaskSections` headers on the task list).
|
||||
- ✅ Percent-complete field — optional "Progress" slider (5% detents) on the edit
|
||||
form; writes `Tasks.PERCENT_COMPLETE` (clamped 0–100, status left to the
|
||||
complete toggle).
|
||||
- ✅ Conflict-safe saves — `updateTask` re-checks `last_modified` against the
|
||||
value captured when the form loaded and throws `TaskConflictException`; the
|
||||
editor offers overwrite-or-cancel instead of clobbering an external change.
|
||||
|
||||
### ⬜ M4 — Subtasks (UI)
|
||||
`RELATED-TO` hierarchy in the UI: indentation, create subtask, reparent. The
|
||||
data layer already reads `parentId`; this is the trickiest UI piece.
|
||||
### ✅ M4 — Subtasks (UI)
|
||||
`RELATED-TO` hierarchy in the UI. The data layer already reads `parentId`.
|
||||
- ✅ Create subtask (`AddSubtaskField` → `TaskDetailViewModel.addSubtask` sets
|
||||
`parentId`); subtasks render as a grouped section in the detail screen. Tapping
|
||||
a subtask opens its own detail (a new `TaskDetail` entry, so it can have
|
||||
children too), and the subtask's detail shows a tappable "Part of …" parent
|
||||
card so it never reads as a stray standalone task.
|
||||
- ✅ Inline expansion on the task list — a parent row has a dedicated expand
|
||||
button (trailing chevron, separate from the count chip). Each section flattens
|
||||
into one grouped run (parents + their expanded children) and corners are chosen
|
||||
per-edge (`cornerPosition`): top-level tasks keep their own run, an expanded
|
||||
parent opens its bottom to its children, and the child group rounds off on its
|
||||
last segment while the next top-level task stays mid-run. Children are
|
||||
full-width, set a step down in tone (`surfaceContainer` vs the parents'
|
||||
`surfaceContainerHigh`) and with no colour bar (checkbox stays aligned). An expanded group ends with an inline "add a subtask"
|
||||
row (on by default; opt out in Settings → Tasks since M5).
|
||||
Offered only where the list holds all the children (a real list; smart lists
|
||||
that omit off-day children stay collapsed). `TaskDetail.parent` carries the
|
||||
parent for the detail card.
|
||||
- ✅ Reparent — a full-width, searchable "Parent task" sheet on the edit form
|
||||
groups active candidates by due-date section (Overdue / Today / Upcoming / No
|
||||
date) and files a task under any top-level task in its list (or "None" to
|
||||
promote it); switching list clears the now-invalid parent. Candidates stay
|
||||
active + top-level to keep nesting one level deep, matching the detail screen.
|
||||
|
||||
### ⬜ M5 — Reminders onboarding & polish
|
||||
The engine exists (M1). Remaining: the `POST_NOTIFICATIONS` + exact-alarm
|
||||
onboarding flow, per-task / default reminder-offset settings UI, and
|
||||
end-to-end verification on device.
|
||||
### ✅ M5 — Reminders onboarding & polish
|
||||
The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
|
||||
`DueReminderReceiver`, `TaskNotifier`).
|
||||
- ✅ Per-task reminder-offset UI (`ReminderPickerDialog` → `TaskForm`
|
||||
`reminderMinutesBeforeDue`).
|
||||
- ✅ `POST_NOTIFICATIONS` onboarding flow — a one-time `ReminderOnboardingScreen`
|
||||
(Calendula's shell + copy adapted for tasks) gated in `RootScreen` after the
|
||||
provider/permission grant; it requests the runtime permission (API 33+) and
|
||||
records the choice (`reminderOnboardingDone`). Re-requestable from Settings.
|
||||
- ✅ Exact-alarm surface — a status row in Settings → Reminders, shown only on
|
||||
Android 12 (where `SCHEDULE_EXACT_ALARM` is revocable), deep-linking to the
|
||||
system grant screen; on 13+ the app holds `USE_EXACT_ALARM` (always granted).
|
||||
- ✅ Default reminder-offset settings UI — exposed as "When to remind" in
|
||||
Settings → Reminders (`reminderLeadMinutes`), behind a master `remindersEnabled`
|
||||
switch that gates the entire engine (`ReminderScheduler` clears all alarms when
|
||||
off; `DueReminderReceiver` suppresses any in-flight fire).
|
||||
- ⬜ End-to-end verification on device (build + unit tests green; not yet run on
|
||||
a device with a live provider).
|
||||
|
||||
### ⬜ M6 — Settings, widget, i18n, release
|
||||
Settings screen (theme / dynamic-color / language, default list, default
|
||||
reminder), Glance task-list widget, translations, finalize F-Droid metadata,
|
||||
confirm CI release flow.
|
||||
### 🚧 M6 — Settings, widget, i18n, release
|
||||
- ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
|
||||
- ✅ Settings screen — landed early with M5 (`SettingsScreen` in the nav graph,
|
||||
reached by the gear on the lists overview). Covers theme, dynamic colour, due
|
||||
reminders (toggle + default offset + exact-alarm status), default list, and the
|
||||
add-a-subtask-row opt-out. Still ⬜ a **language** entry (deferred until there
|
||||
are translations to switch to).
|
||||
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
|
||||
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
|
||||
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
|
||||
|
||||
### ⬜ Posture B (separate track, later)
|
||||
Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`;
|
||||
@@ -82,12 +140,12 @@ permissions; ship self-contained. UI / repository / domain untouched — see
|
||||
|
||||
These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through.
|
||||
|
||||
1. ~~**Name** — `Floret`~~ confirmed (appId `de.jeanlucmakiola.floret`).
|
||||
1. ~~**Name** — `Agendula`~~ confirmed (appId `de.jeanlucmakiola.agendula`).
|
||||
2. ~~**tasks.org provider authority**~~ verified on device:
|
||||
`org.tasks.opentasks` + `org.tasks.permission.*`.
|
||||
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
|
||||
(Not in the candidate list today.)
|
||||
4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Floret a
|
||||
4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Agendula a
|
||||
*replacement* for OpenTasks (one authority owner per device). Intended, but a
|
||||
conscious choice.
|
||||
5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists);
|
||||
|
||||
0
fastlane/metadata/android/en-US/changelogs/.gitkeep
Normal file
0
fastlane/metadata/android/en-US/changelogs/.gitkeep
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user