16 Commits
Author SHA1 Message Date
Jean-Luc Makiolaandmakiolaj 89775e44a7 chore: merge main into release/v1.1.0 (#42)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Successful in 26m27s
Brings the exact tag checks from #40 into the release branch. beta.yaml runs from the branch's own files, and the old prefix-matching lookup treated v1.1.0-beta.1 as a shipped v1.1.0, so the 1.1.0-beta.2 run was refused.

The one conflict was in publish_codeberg_release.sh: kept #40's stricter upload handling plus the offline APK assets from #41. scripts/release_gate.sh now reports cut=true for 1.1.0-beta.2.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/42
2026-10-06 19:19:06 +02:00
Jean-Luc Makiola c0eb03e5df Merge pull request 'feat: offline flavor without sync or network access (#39)' (#41) from feat/39-offline-flavor into release/v1.1.0
Beta — Codeberg pre-release / detect (push) Failing after 6s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-06 19:14:57 +02:00
makiolaj 7bfda26de9 chore: 1.1.0-beta.2 2026-10-06 19:11:50 +02:00
makiolaj f65aed4fe2 ci: install JetBrains 21 directly instead of fetching it via foojay 2026-10-06 19:00:48 +02:00
makiolaj 84bb1116ec feat: offline flavor without sync or network access (#39) 2026-10-06 18:34:33 +02:00
makiolaj 7fde7ae290 fix: restore the floret glance dependency lost in the rebase
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 10m34s
2026-10-05 18:58:03 +02:00
makiolaj 5d273e05c7 chore: 1.1.0-beta.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Failing after 2m31s
2026-10-05 18:51:20 +02:00
makiolaj 59582b3641 docs(release): keep CHANGELOG entries under Unreleased while betas go out 2026-10-05 18:49:31 +02:00
makiolaj 3540d2fb7c chore: release 1.1.0 2026-10-05 18:49:15 +02:00
makiolaj 3812e950ca fix: remove double inset on the list editor's smart-lists row 2026-10-05 18:49:08 +02:00
makiolaj c6d62c36f4 fix(widget): regenerate picker previews to match the final widget designs 2026-10-05 18:49:08 +02:00
makiolaj a4fbbf43c6 feat: allow editing synced lists, not just device-only ones
The edit pencil was gated to local lists even though rename/delete already
worked end to end for synced collections. Read-only shares keep the icon
but show it disabled, with a hint on tap explaining why.

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

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

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

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

Closes #25

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

Closes #27

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/31
2026-10-05 18:49:08 +02:00
167 changed files with 4225 additions and 1097 deletions
+2 -2
View File
@@ -26,14 +26,14 @@ Which of these ran green, and anything you exercised by hand. On-device notes
are especially useful for UI changes, and for anything touching the provider are especially useful for UI changes, and for anything touching the provider
read/write paths (OpenTasks / tasks.org installed). read/write paths (OpenTasks / tasks.org installed).
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug ./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug
python3 scripts/check_translations.py python3 scripts/check_translations.py
--> -->
### Checklist ### Checklist
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally - [ ] `./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug` passes locally
- [ ] New domain logic comes with JVM unit tests under `app/src/test/` - [ ] New domain logic comes with JVM unit tests under `app/src/test/`
- [ ] Provider details stay inside `data/tasks/` - [ ] Provider details stay inside `data/tasks/`
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine) - [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
+8 -6
View File
@@ -127,10 +127,12 @@ jobs:
- name: Setup Java - name: Setup Java
if: steps.scope.outputs.code == 'true' if: steps.scope.outputs.code == 'true'
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
# here keeps Gradle from downloading it via foojay.
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'jetbrains'
java-version: '17' java-version: '21'
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against # Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
# data.forgejo.org, Forgejo's own action mirror — actions/checkout, # data.forgejo.org, Forgejo's own action mirror — actions/checkout,
@@ -178,18 +180,18 @@ jobs:
# No --no-daemon: the daemon lives only as long as this job container # No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration. # and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variant only) - name: Lint (debug variants only)
if: steps.scope.outputs.code == 'true' if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug run: ./gradlew lintFullDebug lintOfflineDebug
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only # :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
# that task would compile the vendored suite and run none of it, which is # that task would compile the vendored suite and run none of it, which is
# the whole safety argument in dav/PROVENANCE.md. # the whole safety argument in dav/PROVENANCE.md.
- name: Unit tests - name: Unit tests
if: steps.scope.outputs.code == 'true' if: steps.scope.outputs.code == 'true'
run: ./gradlew testDebugUnitTest :dav:test :caldav:test run: ./gradlew testFullDebugUnitTest testOfflineDebugUnitTest :dav:test :caldav:test
- name: Assemble debug APK - name: Assemble debug APKs
if: steps.scope.outputs.code == 'true' if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug run: ./gradlew assembleDebug
+1 -1
View File
@@ -2,7 +2,7 @@ name: Translations
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which # Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android # only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation). # build. The deeper checks still run in CI via lintFullDebug (ExtraTranslation).
# #
# Runs on every PR (no path filter) so the required "Translations / check" # Runs on every PR (no path filter) so the required "Translations / check"
# status is always reported — like the `ci` job. A path-filtered workflow is # status is always reported — like the `ci` job. A path-filtered workflow is
+11 -6
View File
@@ -70,10 +70,12 @@ jobs:
submodules: recursive submodules: recursive
- name: Setup Java - name: Setup Java
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
# here keeps Gradle from downloading it via foojay.
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'jetbrains'
java-version: '17' java-version: '21'
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
@@ -126,7 +128,7 @@ jobs:
grep -E 'versionName|versionCode' app/build.gradle.kts grep -E 'versionName|versionCode' app/build.gradle.kts
- name: Unit tests - name: Unit tests
run: ./gradlew testDebugUnitTest run: ./gradlew testFullDebugUnitTest
# The real app key, same as a stable release: a beta has to update in # The real app key, same as a stable release: a beta has to update in
# place to the next beta and to the stable version. # place to the next beta and to the stable version.
@@ -145,7 +147,8 @@ jobs:
storeFile=upload-keystore.jks storeFile=upload-keystore.jks
EOF EOF
- name: Build release APK # Both flavors; the offline one goes to Codeberg only (issue #39).
- name: Build release APKs
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else # Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
@@ -160,7 +163,8 @@ jobs:
bash scripts/release_notes.sh "$VERSION" > release-notes.md bash scripts/release_notes.sh "$VERSION" > release-notes.md
cat release-notes.md cat release-notes.md
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \ TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \ MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
bash scripts/publish_gitea_release.sh bash scripts/publish_gitea_release.sh
# The point of the whole workflow, so NOT continue-on-error. # The point of the whole workflow, so NOT continue-on-error.
@@ -172,5 +176,6 @@ jobs:
run: | run: |
set -e set -e
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \ TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \ APK=app/build/outputs/apk/full/release/app-full-release.apk \
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
bash scripts/publish_codeberg_release.sh bash scripts/publish_codeberg_release.sh
+16 -10
View File
@@ -120,10 +120,12 @@ jobs:
submodules: recursive submodules: recursive
- name: Setup Java - name: Setup Java
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
# here keeps Gradle from downloading it via foojay.
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'jetbrains'
java-version: '17' java-version: '21'
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
@@ -182,7 +184,7 @@ jobs:
# Test the exact commit being shipped (only on a real release). # Test the exact commit being shipped (only on a real release).
- name: Unit tests - name: Unit tests
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
run: ./gradlew testDebugUnitTest run: ./gradlew testFullDebugUnitTest
- name: Setup Android Keystore - name: Setup Android Keystore
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
@@ -200,7 +202,9 @@ jobs:
storeFile=upload-keystore.jks storeFile=upload-keystore.jks
EOF EOF
- name: Build release APK # Both flavors. Only full goes to F-Droid and Play; the offline one
# (issue #39) is a Codeberg release asset and nothing else.
- name: Build release APKs
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
@@ -256,7 +260,7 @@ jobs:
run: | run: |
set -e set -e
mkdir -p fdroid/repo mkdir -p fdroid/repo
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk" cp app/build/outputs/apk/full/release/app-full-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
# Per-version "What's New": ensure this version's changelog exists in the # 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 # fastlane tree (committed at release-cut time for the official repo; this
@@ -318,7 +322,8 @@ jobs:
set -e set -e
bash scripts/release_notes.sh "$VERSION" > release-notes.md bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \ TAG="v$VERSION" NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \ MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
bash scripts/publish_gitea_release.sh bash scripts/publish_gitea_release.sh
# Publish the release on Codeberg, which is canonical for tags and # Publish the release on Codeberg, which is canonical for tags and
@@ -341,7 +346,8 @@ jobs:
set -e set -e
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md [ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \ TAG="v$VERSION" NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \ APK=app/build/outputs/apk/full/release/app-full-release.apk \
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
bash scripts/publish_codeberg_release.sh bash scripts/publish_codeberg_release.sh
# Play takes an App Bundle, not the APK: a second artifact from the same # Play takes an App Bundle, not the APK: a second artifact from the same
@@ -356,7 +362,7 @@ jobs:
- name: Build release AAB - name: Build release AAB
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
continue-on-error: true continue-on-error: true
run: ./gradlew bundleRelease run: ./gradlew bundleFullRelease
# NOT actions/upload-artifact@v4: its client refuses any non-github.com # NOT actions/upload-artifact@v4: its client refuses any non-github.com
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that # server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
@@ -368,7 +374,7 @@ jobs:
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4 uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
with: with:
name: release-aab-${{ needs.detect.outputs.version }} name: release-aab-${{ needs.detect.outputs.version }}
path: app/build/outputs/bundle/release/app-release.aab path: app/build/outputs/bundle/fullRelease/app-full-release.aab
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
@@ -462,7 +468,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
# Absolute: a lane body runs from fastlane/, not the workspace root. # Absolute: a lane body runs from fastlane/, not the workspace root.
AAB="$GITHUB_WORKSPACE/dist/app-release.aab" AAB="$GITHUB_WORKSPACE/dist/app-full-release.aab"
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; } test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
bundle exec fastlane deploy \ bundle exec fastlane deploy \
aab:"$AAB" \ aab:"$AAB" \
+20
View File
@@ -7,6 +7,26 @@ All notable changes to this project are documented here. The format follows
## [Unreleased] ## [Unreleased]
### Added
- Four new home-screen widgets: Today, Up next, Lists and Week.
- A widget can show tasks from several lists at once, optionally grouped by
list.
- A setting under Settings → Task form pre-fills a new task's start with today.
- An offline version of Agendula with no sync and no network access at all,
published as a separate APK on Codeberg (#39).
### Changed
- The Tasks widget has agenda-style rows, an optional row colour and an "All
caught up" state. Ticking a task in any widget shows it as done for a moment
before it disappears.
- The "At a glance" widget is gone; the Tasks and Today widgets cover what it
showed.
- Synced lists can be renamed and deleted, not only device-only ones. A
read-only share shows the edit button greyed out and explains why on tap.
### Fixed
- The list editor's Where and smart-lists rows no longer have double padding.
## [1.0.0] - 2026-09-21 ## [1.0.0] - 2026-09-21
### Added ### Added
+3 -3
View File
@@ -28,9 +28,9 @@ layer.
## Build, test, lint ## Build, test, lint
```sh ```sh
./gradlew :app:assembleDebug # build the debug APK ./gradlew :app:assembleDebug # build the debug APKs (full + offline)
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine) ./gradlew :app:testFullDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
./gradlew lintDebug # Android lint (CI runs this on every PR) ./gradlew lintFullDebug lintOfflineDebug # Android lint (CI runs this on every PR)
``` ```
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check, CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
+12
View File
@@ -119,9 +119,21 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository
serves. For automatic updates from there, use serves. For automatic updates from there, use
**[Obtainium](https://github.com/ImranR98/Obtainium)** and **[Obtainium](https://github.com/ImranR98/Obtainium)** and
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**. **[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
Each release carries two APKs (see [Offline version](#offline-version)), so set
*Filter APKs by regular expression* to `^agendula_v` to always get the regular
app.
Betas of upcoming versions are published there too, as pre-releases; to test Betas of upcoming versions are published there too, as pre-releases; to test
them, switch on *Include prereleases* for Agendula in Obtainium. them, switch on *Include prereleases* for Agendula in Obtainium.
### Offline version
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
without CalDAV sync and **without the network permission**, so it cannot
connect to anything. It installs alongside the regular app rather than replacing
it; move tasks between the two with Settings → Storage → Export / Import. In
Obtainium, pick it with *Filter APKs by regular expression* set to
`^agendula-offline_v`.
### Build from source ### Build from source
```sh ```sh
+54 -7
View File
@@ -1,3 +1,4 @@
import com.android.build.api.artifact.SingleArtifact
import java.util.Properties import java.util.Properties
import java.io.FileInputStream import java.io.FileInputStream
@@ -32,8 +33,8 @@ android {
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001, // (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match. // 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md. // See docs/RELEASING.md.
versionCode = 10000 versionCode = 1010002
versionName = "1.0.0" versionName = "1.1.0-beta.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -46,6 +47,20 @@ android {
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync") resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
} }
// `offline` (#39): no sync code, network libraries or network permission.
flavorDimensions += "network"
productFlavors {
create("full") {
dimension = "network"
buildConfigField("boolean", "SYNC_ENABLED", "true")
}
create("offline") {
dimension = "network"
applicationIdSuffix = ".offline"
buildConfigField("boolean", "SYNC_ENABLED", "false")
}
}
signingConfigs { signingConfigs {
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
create("release") { create("release") {
@@ -208,26 +223,26 @@ dependencies {
// @HiltWorker plumbing. // @HiltWorker plumbing.
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
// Custom Tabs: the Nextcloud login flow hands the browser an approval page. // Custom Tabs: the Nextcloud login flow hands the browser an approval page.
implementation(libs.androidx.browser) "fullImplementation"(libs.androidx.browser)
implementation(libs.androidx.hilt.work) implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler) ksp(libs.androidx.hilt.compiler)
// Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages. // Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages.
implementation(libs.unifiedpush.connector) "fullImplementation"(libs.unifiedpush.connector)
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin. // RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
implementation(libs.dmfs.lib.recur) implementation(libs.dmfs.lib.recur)
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md. // Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
implementation(project(":dav")) "fullImplementation"(project(":dav"))
// Discovery, auth and Nextcloud Login Flow v2. // Discovery, auth and Nextcloud Login Flow v2.
implementation(project(":caldav")) "fullImplementation"(project(":caldav"))
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares // :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM // xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
// with no framework, and android.jar's stub factory returns null under // with no framework, and android.jar's stub factory returns null under
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a // isReturnDefaultValues — so anything touching XmlUtils would NPE without a
// real implementation here. // real implementation here.
testImplementation(libs.xpp3) "testFullImplementation"(libs.xpp3)
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
@@ -246,6 +261,7 @@ dependencies {
implementation(libs.floret.core.crash) implementation(libs.floret.core.crash)
implementation(libs.floret.identity) implementation(libs.floret.identity)
implementation(libs.floret.components) implementation(libs.floret.components)
implementation(libs.floret.glance)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
@@ -265,3 +281,34 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
} }
/** Fails the build if the offline flavor's merged manifest asks for the network. */
abstract class VerifyNoNetworkPermissions : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val manifest: RegularFileProperty
@TaskAction
fun verify() {
val text = manifest.get().asFile.readText()
val found = listOf("INTERNET", "ACCESS_NETWORK_STATE", "ACCESS_WIFI_STATE", "CHANGE_NETWORK_STATE")
.filter { "\"android.permission.$it\"" in text }
if (found.isNotEmpty()) {
throw GradleException(
"The offline flavor's merged manifest declares ${found.joinToString()}. " +
"Remove it in app/src/offline/AndroidManifest.xml with tools:node=\"remove\".",
)
}
}
}
androidComponents {
onVariants(selector().withFlavor("network" to "offline")) { variant ->
val name = variant.name.replaceFirstChar { it.uppercase() }
val verify = tasks.register<VerifyNoNetworkPermissions>("verify${name}Manifest") {
manifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST))
}
tasks.matching { it.name in setOf("package$name", "package${name}Bundle", "check") }
.configureEach { dependsOn(verify) }
}
}
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- CalDAV sync and UnifiedPush; everything here needs the network. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
but it shows in F-Droid's permission diff, so declare it deliberately
rather than letting it appear from nowhere.
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
(whose failure mode is a fatal RemoteServiceException) and a Play
requirement for a video demo.
Two more permissions appear in the merged manifest without being
declared here, and both come from work-runtime: WAKE_LOCK, and
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
API 31 WorkManager implements expedited work with a foreground service,
and minSdk is 29, so it is load-bearing for the "Sync now" button.
Removing it with tools:node="remove" would break expedited work on
exactly the older devices that need it most. Noted because it shows in
F-Droid's permission diff and would otherwise look unexplained. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<queries>
<!-- Custom Tabs provider detection. Without this entry it silently finds
nothing on API 30+, and the Nextcloud login flow falls back to an
external browser for no visible reason. -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application android:networkSecurityConfig="@xml/network_security_config">
<!-- Sync plumbing. The stub provider exists only to give the sync
adapter an authority to register against: Agendula publishes no real
ContentProvider since :provider was deleted, and without an authority
ContentService.hasAuthorityAccess() makes every ContentResolver sync
call a silent no-op at targetSdk >= 34. -->
<provider
android:name=".data.sync.SyncStubProvider"
android:authorities="${applicationId}.sync"
android:exported="false"
android:syncable="true" />
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
<service
android:name=".data.sync.AuthenticatorService"
android:exported="true"
android:permission="android.permission.ACCOUNT_MANAGER">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".data.sync.SyncAdapterService"
android:exported="true"
android:permission="android.permission.BIND_SYNC_ADAPTER">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_adapter" />
</service>
<!-- UnifiedPush: the connector binds this to deliver endpoints and
WebDAV-Push messages. Not exported; the connector's own receiver is
what distributors talk to. -->
<service
android:name=".data.sync.push.AgendulaPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,131 @@
package de.jeanlucmakiola.agendula.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
import de.jeanlucmakiola.agendula.data.sync.SyncRequests
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
import javax.inject.Provider
import javax.inject.Singleton
/** See [CredentialsDataStore] for why this is a separate file. */
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
name = CREDENTIALS_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/** See [SyncStateDataStore] for why this is a separate file. */
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
name = SYNC_STATE_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
*/
const val CREDENTIALS_DATASTORE = "agendula_credentials"
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
*/
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
/** CalDAV sync: the `full` flavor's side of the seams `main` declares. */
@Module
@InstallIn(SingletonComponent::class)
abstract class SyncBindModule {
@Binds
@Singleton
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
@Binds
@Singleton
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
@Binds
@Singleton
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
@Binds
@Singleton
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
@Binds
@Singleton
abstract fun bindRemoteLists(impl: RemoteListRepository): RemoteLists
@Binds
@Singleton
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
@Binds
@IntoSet
abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher
}
@Module
@InstallIn(SingletonComponent::class)
object SyncProvideModule {
@Provides
@Singleton
@CredentialsDataStore
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.credentialsDataStore
@Provides
@Singleton
@SyncStateDataStore
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.syncStateDataStore
/**
* Sync hard on app open: the periodic worker's interval is a floor, and in
* the `rare` and `restricted` App Standby buckets it may not have run at
* all. `KEEP` makes rescheduling idempotent, so this also repairs a schedule
* lost to "clear app data" or to a restore.
*/
@Provides
@IntoSet
fun syncOnOpenHook(
accounts: Provider<AccountRepository>,
syncTrigger: Provider<SyncTrigger>,
pendingLoginFlows: Provider<PendingLoginFlowStore>,
push: Provider<PushRegistrar>,
): LaunchHook = LaunchHook {
runCatching {
accounts.get().rescheduleAll()
accounts.get().syncable().forEach { syncTrigger.get().enqueue(it.displayName) }
// A login flow the previous process died in the middle of.
// Its password, if the user approved, exists nowhere else.
pendingLoginFlows.get().reclaim()
}
// Last and on its own: it waits on the network, and must not hold up
// the reclaim above. Re-registering on open is what the connector
// recommends.
runCatching { push.get().updateAll() }
}
}
@@ -0,0 +1,34 @@
package de.jeanlucmakiola.agendula.data.di
import javax.inject.Qualifier
/**
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
*
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
* `datastore/`, and a restored ciphertext is permanently undecryptable because
* Keystore keys are non-exportable. Its own file is what lets the backup rules
* exclude the credentials and nothing else — excluding the whole database or
* all of DataStore would trade a latent bug for a live one.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class CredentialsDataStore
/**
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
* counters and the full-reconciliation clock.
*
* Its own file for the same reason the credentials have one: Auto Backup
* includes `datastore/`, and every value in here is a statement about *this*
* device's conversation with a server. Restored onto a new install they are all
* lies, and two of them are dangerous — a restored "reconciled recently" makes
* the engine trust a sync token for another day, which is precisely the silently
* pruned change log the full path exists to catch, and a restored quarantine
* count silently skips resources that were never tried here.
*
* Not user data, so nothing is lost by excluding it.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncStateDataStore
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.data.sync package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
import de.jeanlucmakiola.agendula.data.sync.push.PushStore import de.jeanlucmakiola.agendula.data.sync.push.PushStore
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.StorageMode
@@ -53,38 +54,7 @@ class RemoteListRepository @Inject constructor(
private val resolver: ProviderResolver, private val resolver: ProviderResolver,
private val push: PushStore, private val push: PushStore,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) { ) : RemoteLists {
/** Why a collection write did not happen, in a form the UI can translate. */
sealed interface Outcome {
data object Done : Outcome
/** The server said no, and will say no again. */
data class Refused(val code: Int) : Outcome
/** The server could not be reached. Worth another try. */
data object Unreachable : Outcome
/** This account cannot make collections at all — iCloud, Posteo, Google. */
data object Unsupported : Outcome
/** Ours is a read-only share; the write belongs to whoever owns it. */
data object ReadOnly : Outcome
/** The account is gone, stopped, or has no credential we can decrypt. */
data object NoAccount : Outcome
/**
* The server answered something this call cannot make sense of.
*
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
* update and delete, so each of them has branches the other's method
* can return and its own cannot — and mapping those to [Unreachable]
* told someone sitting on wifi that they were offline. Unreachable is a
* claim about the network, and this is not one.
*/
data object Unexpected : Outcome
}
/** /**
* The accounts a new list may be created on, freshest answer first. * The accounts a new list may be created on, freshest answer first.
@@ -95,7 +65,7 @@ class RemoteListRepository @Inject constructor(
* able to say so, and the only moment that costs nothing is while the user * able to say so, and the only moment that costs nothing is while the user
* is looking at the picker. * is looking at the picker.
*/ */
suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) { override suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
// ⚠️ Empty in External mode, whatever the accounts table holds. The // ⚠️ Empty in External mode, whatever the accounts table holds. The
// lists on screen then come from a third-party provider, so a row // lists on screen then come from a third-party provider, so a row
// inserted into ours would exist, sync, and be visible to nobody. // inserted into ours would exist, sync, and be visible to nobody.
@@ -120,7 +90,7 @@ class RemoteListRepository @Inject constructor(
* *
* @return the new list's local id, or why there is none. * @return the new list's local id, or why there is none.
*/ */
suspend fun create( override suspend fun create(
accountId: Long, accountId: Long,
name: String, name: String,
color: Int, color: Int,
@@ -189,7 +159,7 @@ class RemoteListRepository @Inject constructor(
* a row that has already been renamed locally by then reads as a rename that * a row that has already been renamed locally by then reads as a rename that
* worked and then quietly reverted on the next sync. * worked and then quietly reverted on the next sync.
*/ */
suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) { override suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
if (list.isReadOnly) return@withContext Outcome.ReadOnly if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
@@ -227,7 +197,7 @@ class RemoteListRepository @Inject constructor(
* grades 404 and 410 that way — because otherwise a collection someone * grades 404 and 410 that way — because otherwise a collection someone
* removed from another client leaves a row here that nothing can get rid of. * removed from another client leaves a row here that nothing can get rid of.
*/ */
suspend fun delete(listId: Long): Outcome = withContext(io) { override suspend fun delete(listId: Long): Outcome = withContext(io) {
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
if (list.isReadOnly) return@withContext Outcome.ReadOnly if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.MainActivity import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -33,7 +34,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class SyncNoticeNotifier @Inject constructor( class SyncNoticeNotifier @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) : ChannelRefresher {
fun canPost(): Boolean { fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
@@ -123,7 +124,7 @@ class SyncNoticeNotifier @Inject constructor(
* extra names the account for routing to Settings → Accounts → it. * extra names the account for routing to Settings → Accounts → it.
*/ */
private fun signInIntent(accountId: Long): Intent = private fun signInIntent(accountId: Long): Intent =
MainActivity.openIntent(context).putExtra(EXTRA_SIGN_IN_ACCOUNT_ID, accountId) MainActivity.openIntent(context).putExtra(MainActivity.EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
private fun ensureSignInChannel() { private fun ensureSignInChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
@@ -170,16 +171,13 @@ class SyncNoticeNotifier @Inject constructor(
} }
/** Re-create the channel, if it exists, in the current language. */ /** Re-create the channel, if it exists, in the current language. */
fun refreshChannel() { override fun refreshChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel() if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
} }
companion object { companion object {
/** The account a sign-in notification is about, on the intent it opens. */
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
private const val CHANNEL_ID = "sync_notices" private const val CHANNEL_ID = "sync_notices"
private const val NOTIFICATION_ID = 2 private const val NOTIFICATION_ID = 2
private const val SIGN_IN_CHANNEL_ID = "account_sign_in" private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
@@ -35,7 +35,11 @@ import kotlin.time.Duration.Companion.seconds
@Singleton @Singleton
class SyncTrigger @Inject constructor( class SyncTrigger @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) : SyncRequests {
override fun syncNow(accountName: String) {
enqueue(accountName)
}
/** /**
* Starts a sync now. * Starts a sync now.
@@ -0,0 +1,35 @@
package de.jeanlucmakiola.agendula.ui.onboarding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
/** The inline wizard's shortest length, before a provider is picked. */
internal const val ACCOUNT_STEP_SLOTS = ADD_ACCOUNT_STEPS
/** [OnboardingStep.Account]: the add-account wizard, given the whole screen. */
@Composable
internal fun AccountStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
// The same instance the wizard resolves for itself, so its length can be
// read here without keeping a second copy of its state.
val account: AddAccountViewModel = hiltViewModel()
val accountState by account.state.collectAsStateWithLifecycle()
// ⚠️ The wizard grows a step for a provider that needs an app password
// minted first, and the outer bar has to grow with it — otherwise first
// run counts one flow while the screen inside it counts another.
LaunchedEffect(accountState.totalSteps) {
viewModel.onAccountStepsChanged(accountState.totalSteps)
}
AddAccountScreen(
onDone = { viewModel.onAccountFinished(added = true) },
onBack = { viewModel.onAccountFinished(added = false) },
stepOffset = state.position - 1,
totalSteps = state.total,
viewModel = account,
)
}
@@ -0,0 +1,78 @@
package de.jeanlucmakiola.agendula.ui.settings
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
/** Settings → Accounts and the screens under it, layered over the hub. */
@Composable
internal fun AccountSections(
section: SettingsSection?,
initialAccountId: Long?,
onSection: (SettingsSection?) -> Unit,
) {
// Hoisted so the add flow can refresh the list it returns to.
val accountsViewModel: AccountsViewModel = hiltViewModel()
// Shared with the add flow's own lookup, so "sign in again" can prefill it.
val addAccountViewModel: AddAccountViewModel = hiltViewModel()
val signInAgain: (AccountEntity) -> Unit = { account ->
addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username)
onSection(SettingsSection.AddAccount)
}
// Which account the detail screen is showing; the section alone cannot say.
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
// Accounts stays composed under Add account, for the same reason Storage
// stays composed under Export: the deeper screen slides over it.
val accountsOpen = section == SettingsSection.Accounts ||
section?.parent == SettingsSection.Accounts
SlideInSection(visible = accountsOpen) {
AccountsScreen(
onAddAccount = {
// A sign-in abandoned half-way must not turn this into one.
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
onSection(SettingsSection.AddAccount)
},
onSignInAgain = signInAgain,
onOpenAccount = {
openAccount = it
onSection(SettingsSection.Account)
},
onOpenStorage = { onSection(SettingsSection.Storage) },
onBack = { onSection(null) },
viewModel = accountsViewModel,
)
}
SlideInSection(visible = section == SettingsSection.Account) {
openAccount?.let { id ->
AccountDetailScreen(
accountId = id,
onBack = { onSection(SettingsSection.Accounts) },
onRemoved = { onSection(SettingsSection.Accounts) },
onSignInAgain = signInAgain,
onOpenStorage = { onSection(SettingsSection.Storage) },
viewModel = accountsViewModel,
)
}
}
SlideInSection(visible = section == SettingsSection.AddAccount) {
AddAccountScreen(
// The list stays composed underneath, so nothing re-runs its
// init and OnResume never fires on a section change. It no longer
// needs to: the accounts come from an observed query, so a new
// account — and every later sync — arrives on its own.
onDone = { onSection(SettingsSection.Accounts) },
onBack = { onSection(SettingsSection.Accounts) },
viewModel = addAccountViewModel,
)
}
}
+52 -78
View File
@@ -16,29 +16,6 @@
<uses-permission android:name="org.tasks.permission.READ_TASKS" /> <uses-permission android:name="org.tasks.permission.READ_TASKS" />
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" /> <uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
but it shows in F-Droid's permission diff, so declare it deliberately
rather than letting it appear from nowhere.
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
(whose failure mode is a fatal RemoteServiceException) and a Play
requirement for a video demo.
Two more permissions appear in the merged manifest without being
declared here, and both come from work-runtime: WAKE_LOCK, and
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
API 31 WorkManager implements expedited work with a foreground service,
and minSdk is 29, so it is load-bearing for the "Sync now" button.
Removing it with tools:node="remove" would break expedited work on
exactly the older devices that need it most. Noted because it shows in
F-Droid's permission diff and would otherwise look unexplained. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for <!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for
@@ -55,12 +32,6 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent> </intent>
<!-- Custom Tabs provider detection. Without this entry it silently finds
nothing on API 30+, and the Nextcloud login flow falls back to an
external browser for no visible reason. -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries> </queries>
<application <application
@@ -71,7 +42,6 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:localeConfig="@xml/locales_config" android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Agendula" android:theme="@style/Theme.Agendula"
@@ -185,6 +155,58 @@
android:resource="@xml/appwidget_info_tasks" /> android:resource="@xml/appwidget_info_tasks" />
</receiver> </receiver>
<!-- "Today" home-screen widget (Glance): a progress ring for today's tasks. -->
<receiver
android:name=".widget.TodayWidgetReceiver"
android:label="@string/widget_today_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_today" />
</receiver>
<!-- "Up next" home-screen widget (Glance): the single nearest due task. -->
<receiver
android:name=".widget.UpNextWidgetReceiver"
android:label="@string/widget_up_next_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_up_next" />
</receiver>
<!-- "Lists" home-screen widget (Glance): one shortcut tile per list. -->
<receiver
android:name=".widget.ListsWidgetReceiver"
android:label="@string/widget_lists_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_lists" />
</receiver>
<!-- "Week" home-screen widget (Glance): a seven-day strip of task counts. -->
<receiver
android:name=".widget.WeekWidgetReceiver"
android:label="@string/widget_week_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_week" />
</receiver>
<!-- Per-widget list picker, launched by the host on placement / reconfigure. --> <!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
<activity <activity
android:name=".widget.TaskWidgetConfigActivity" android:name=".widget.TaskWidgetConfigActivity"
@@ -211,54 +233,6 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- Sync plumbing. The stub provider exists only to give the sync
adapter an authority to register against: Agendula publishes no real
ContentProvider since :provider was deleted, and without an authority
ContentService.hasAuthorityAccess() makes every ContentResolver sync
call a silent no-op at targetSdk >= 34. -->
<provider
android:name=".data.sync.SyncStubProvider"
android:authorities="${applicationId}.sync"
android:exported="false"
android:syncable="true" />
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
<service
android:name=".data.sync.AuthenticatorService"
android:exported="true"
android:permission="android.permission.ACCOUNT_MANAGER">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".data.sync.SyncAdapterService"
android:exported="true"
android:permission="android.permission.BIND_SYNC_ADAPTER">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_adapter" />
</service>
<!-- UnifiedPush: the connector binds this to deliver endpoints and
WebDAV-Push messages. Not exported; the connector's own receiver is
what distributors talk to. -->
<service
android:name=".data.sync.push.AgendulaPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
<!-- WorkManager's on-demand initialisation. Removing the default <!-- WorkManager's on-demand initialisation. Removing the default
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
@HiltWorker workers can take injected dependencies. --> @HiltWorker workers can take injected dependencies. -->
@@ -10,10 +10,10 @@ import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
@@ -105,7 +105,7 @@ class AgendulaApp : Application(), Configuration.Provider {
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java) val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
runCatching { runCatching {
entryPoint.taskNotifier().refreshChannel() entryPoint.taskNotifier().refreshChannel()
entryPoint.syncNoticeNotifier().refreshChannel() entryPoint.channelRefreshers().forEach { it.refreshChannel() }
} }
} }
@@ -120,7 +120,7 @@ class AgendulaApp : Application(), Configuration.Provider {
fun applicationScope(): CoroutineScope fun applicationScope(): CoroutineScope
fun databaseCheckpoint(): DatabaseCheckpoint fun databaseCheckpoint(): DatabaseCheckpoint
fun taskNotifier(): TaskNotifier fun taskNotifier(): TaskNotifier
fun syncNoticeNotifier(): SyncNoticeNotifier fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
fun taskWidgetUpdater(): TaskWidgetUpdater fun taskWidgetUpdater(): TaskWidgetUpdater
} }
} }
@@ -25,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.di.LaunchHook import de.jeanlucmakiola.agendula.data.di.LaunchHook
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
@@ -33,10 +32,6 @@ import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.RootScreen import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReportDialog
@@ -55,14 +50,6 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook> @Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
@Inject lateinit var accounts: AccountRepository
@Inject lateinit var syncTrigger: SyncTrigger
@Inject lateinit var pendingLoginFlows: PendingLoginFlowStore
@Inject lateinit var push: PushRegistrar
// A captured crash report awaiting the user's decision, surfaced as a dialog // 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 // over the app on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below. // crash-loop is handled out of band, before setContent — see below.
@@ -94,34 +81,9 @@ class MainActivity : ComponentActivity() {
// Surface a single captured crash as a dialog on the next launch. // Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
// Sync hard on app open: the periodic worker's interval is a floor, and // Each hook on its own, so one waiting on the network holds up no other.
// in the `rare` and `restricted` App Standby buckets it may not have run
// at all. `KEEP` makes rescheduling idempotent, so this also repairs a
// schedule lost to "clear app data" or to a restore.
//
// ⚠️ Only on a genuine open. This activity declares no `configChanges`,
// so onCreate runs again on every rotation, theme switch, locale change
// and font-scale change — each of which would otherwise start a fresh
// network sync the moment the previous one finished.
if (savedInstanceState == null) { if (savedInstanceState == null) {
lifecycleScope.launch { launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } }
runCatching {
accounts.rescheduleAll()
accounts.syncable().forEach { syncTrigger.enqueue(it.displayName) }
// A login flow the previous process died in the middle of.
// Its password, if the user approved, exists nowhere else.
pendingLoginFlows.reclaim()
}
// Last and on its own: it waits on the network, and must not
// hold up the reclaim above. Re-registering on open is what
// the connector recommends.
runCatching { push.updateAll() }
}
}
// Variant hooks: the demo seeder in debug builds, nothing in release.
if (savedInstanceState == null) {
lifecycleScope.launch { launchHooks.forEach { runCatching { it.onLaunch(intent) } } }
} }
setContent { setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
@@ -180,12 +142,16 @@ class MainActivity : ComponentActivity() {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID" const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START" const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS" private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
/** The account a sign-in notification is about, on the intent it opens. */
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK" const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY" const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART" private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST" private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST" private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID" private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
private const val SHARED_TITLE_LIMIT = 500 private const val SHARED_TITLE_LIMIT = 500
private const val NO_OCCURRENCE = -1L private const val NO_OCCURRENCE = -1L
@@ -206,9 +172,14 @@ class MainActivity : ComponentActivity() {
.putExtra(EXTRA_OPEN_ACCOUNTS, true) .putExtra(EXTRA_OPEN_ACCOUNTS, true)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** The launcher shortcut's and Quick Settings tile's "New task". */ /**
fun newTaskIntent(context: Context): Intent = * The launcher shortcut's, Quick Settings tile's and a widget's "New
Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java) * task". [listId] preset the list when it names exactly one; the data
* URI keeps each widget's PendingIntent apart from the others.
*/
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
fun todayIntent(context: Context): Intent = fun todayIntent(context: Context): Intent =
@@ -229,11 +200,15 @@ class MainActivity : ComponentActivity() {
internal fun navRequestOf(intent: Intent?): NavRequest? { internal fun navRequestOf(intent: Intent?): NavRequest? {
if (intent == null) return null if (intent == null) return null
intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L } if (BuildConfig.SYNC_ENABLED) {
?.let { return NavRequest.OpenAccount(it) } intent.getLongExtra(EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts ?.let { return NavRequest.OpenAccount(it) }
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
}
when (intent.action) { when (intent.action) {
ACTION_NEW_TASK -> return NavRequest.NewTask() ACTION_NEW_TASK -> return NavRequest.NewTask(
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
)
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY) ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST) ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
?.let { name -> SmartList.entries.firstOrNull { it.name == name } } ?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.agendula.data.di
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
/**
* A notification channel a build variant adds, re-created in the current
* language on start and on a configuration change. The `full` flavor
* contributes the sync notices; `offline` has none.
*/
fun interface ChannelRefresher {
fun refreshChannel()
}
@Module
@InstallIn(SingletonComponent::class)
abstract class ChannelRefresherModule {
@Multibinds
abstract fun channelRefreshers(): Set<ChannelRefresher>
}
@@ -12,13 +12,6 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
@@ -27,7 +20,6 @@ import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
@@ -51,37 +43,13 @@ import javax.inject.Singleton
* reconciled", and the credential store to accounts that ask to be signed in * reconciled", and the credential store to accounts that ask to be signed in
* again — all states the app already knows how to be in, unlike a launch loop. * again — all states the app already knows how to be in, unlike a launch loop.
*/ */
private fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() } internal fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore( private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
name = "agendula_prefs", name = "agendula_prefs",
corruptionHandler = replaceCorrupted(), corruptionHandler = replaceCorrupted(),
) )
/** See [CredentialsDataStore] for why this is a separate file. */
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
name = CREDENTIALS_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/** See [SyncStateDataStore] for why this is a separate file. */
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
name = SYNC_STATE_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
*/
const val CREDENTIALS_DATASTORE = "agendula_credentials"
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
*/
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class DataBindModule { abstract class DataBindModule {
@@ -94,22 +62,6 @@ abstract class DataBindModule {
@Singleton @Singleton
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
@Binds
@Singleton
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
@Binds
@Singleton
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
@Binds
@Singleton
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
@Binds
@Singleton
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
// Deliberately unqualified-free of the routing above: this is the external // Deliberately unqualified-free of the routing above: this is the external
// store itself, for the one caller that has to read it while another store is // store itself, for the one caller that has to read it while another store is
// the active one. // the active one.
@@ -128,18 +80,6 @@ object DataProvideModule {
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.agendulaDataStore context.agendulaDataStore
@Provides
@Singleton
@CredentialsDataStore
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.credentialsDataStore
@Provides
@Singleton
@SyncStateDataStore
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.syncStateDataStore
@Provides @Provides
@Singleton @Singleton
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase = fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
@@ -7,9 +7,8 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds import dagger.multibindings.Multibinds
/** /**
* Something a build variant wants to run when the app is launched with [Intent]. * Something a build variant runs on a genuine app open (not on a configuration
* Release builds contribute none; the debug source set adds the demo seeder, so * change): sync in the `full` flavor, the demo seeder in debug builds.
* that class never ships.
*/ */
fun interface LaunchHook { fun interface LaunchHook {
suspend fun onLaunch(intent: Intent) suspend fun onLaunch(intent: Intent)
@@ -17,37 +17,6 @@ annotation class IoDispatcher
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope annotation class ApplicationScope
/**
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
*
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
* `datastore/`, and a restored ciphertext is permanently undecryptable because
* Keystore keys are non-exportable. Its own file is what lets the backup rules
* exclude the credentials and nothing else — excluding the whole database or
* all of DataStore would trade a latent bug for a live one.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class CredentialsDataStore
/**
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
* counters and the full-reconciliation clock.
*
* Its own file for the same reason the credentials have one: Auto Backup
* includes `datastore/`, and every value in here is a statement about *this*
* device's conversation with a server. Restored onto a new install they are all
* lies, and two of them are dangerous — a restored "reconciled recently" makes
* the engine trust a sync token for another day, which is precisely the silently
* pruned change log the full path exists to catch, and a restored quarantine
* count silently skips resources that were never tried here.
*
* Not user data, so nothing is lost by excluding it.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncStateDataStore
/** /**
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks * Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than * .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
@@ -95,6 +95,8 @@ data class Settings(
val pushEnabled: Boolean = true, val pushEnabled: Boolean = true,
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */ /** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
val hiddenFromSmartLists: Set<Long> = emptySet(), val hiddenFromSmartLists: Set<Long> = emptySet(),
/** Pre-fill a new task's start with today's date. */
val defaultStartToday: Boolean = false,
) { ) {
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */ /** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> = fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
@@ -153,6 +155,7 @@ class SettingsPrefs @Inject constructor(
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() } .mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
.toSet() .toSet()
}, },
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
) )
} }
@@ -166,6 +169,8 @@ class SettingsPrefs @Inject constructor(
suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled } suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
suspend fun setDefaultStartToday(enabled: Boolean) = dataStore.edit { it[DEFAULT_START_TODAY] = enabled }
suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name } suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit { suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
@@ -282,6 +287,7 @@ class SettingsPrefs @Inject constructor(
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes") val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
val PUSH_ENABLED = booleanPreferencesKey("push_enabled") val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden") val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
} }
} }
@@ -0,0 +1,52 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
/**
* Task lists that live on a server: making, renaming, recolouring and deleting
* them. The `full` flavor talks CalDAV; the `offline` one has no accounts, so
* every list is device-only and none of this is ever reached with a real one.
*/
interface RemoteLists {
/** Why a collection write did not happen, in a form the UI can translate. */
sealed interface Outcome {
data object Done : Outcome
/** The server said no, and will say no again. */
data class Refused(val code: Int) : Outcome
/** The server could not be reached. Worth another try. */
data object Unreachable : Outcome
/** This account cannot make collections at all — iCloud, Posteo, Google. */
data object Unsupported : Outcome
/** Ours is a read-only share; the write belongs to whoever owns it. */
data object ReadOnly : Outcome
/** The account is gone, stopped, or has no credential we can decrypt. */
data object NoAccount : Outcome
/**
* The server answered something this call cannot make sense of.
*
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
* update and delete, so each of them has branches the other's method
* can return and its own cannot — and mapping those to [Unreachable]
* told someone sitting on wifi that they were offline. Unreachable is a
* claim about the network, and this is not one.
*/
data object Unexpected : Outcome
}
/** The accounts a new list may be created on. */
suspend fun creatableAccounts(): List<AccountEntity>
/** Makes a collection on [accountId]'s home set and a row pointing at it. */
suspend fun create(accountId: Long, name: String, color: Int): Outcome
suspend fun rename(listId: Long, name: String, color: Int): Outcome
suspend fun delete(listId: Long): Outcome
}
@@ -0,0 +1,6 @@
package de.jeanlucmakiola.agendula.data.sync
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
fun interface SyncRequests {
fun syncNow(accountName: String)
}
@@ -63,6 +63,9 @@ class TasksRepositoryImpl @Inject constructor(
// parent's subtask-progress reflects ALL its children — completed ones // parent's subtask-progress reflects ALL its children — completed ones
// included — even on smart lists that hide completed tasks themselves. // included — even on smart lists that hide completed tasks themselves.
is TaskFilter.Smart -> TaskQuery(includeCompleted = true) is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
// several lists are read in full and membership is filtered in-memory.
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
} }
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = SeriesCollapse.visible(dataSource.tasks(query)) val all = SeriesCollapse.visible(dataSource.tasks(query))
@@ -71,7 +71,7 @@ data class TaskListEntity(
* *
* ⚠️ It was set by a rename and read by nobody. A collection's name and * ⚠️ It was set by a rename and read by nobody. A collection's name and
* colour are now written server-first by * colour are now written server-first by
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], so there is * [de.jeanlucmakiola.agendula.data.sync.RemoteLists], so there is
* no local edit left waiting to be pushed — and a flag that means "owed to * no local edit left waiting to be pushed — and a flag that means "owed to
* the server" while nothing ever pays it is worse than no flag at all. * the server" while nothing ever pays it is worse than no flag at all.
*/ */
@@ -520,7 +520,7 @@ class RoomTasksDataSource @Inject constructor(
/** /**
* ⚠️ Device-only lists. An account-backed collection is renamed through * ⚠️ Device-only lists. An account-backed collection is renamed through
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], which sends * [de.jeanlucmakiola.agendula.data.sync.RemoteLists], which sends
* the PROPPATCH first and writes here only once the server has taken it. * the PROPPATCH first and writes here only once the server has taken it.
* *
* This used to set `is_dirty` for an account list instead, on the theory * This used to set `is_dirty` for an account list instead, on the theory
@@ -8,6 +8,8 @@ enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
/** What a task screen is showing: one real list, or a smart list. */ /** What a task screen is showing: one real list, or a smart list. */
sealed interface TaskFilter { sealed interface TaskFilter {
data class OfList(val listId: Long) : TaskFilter data class OfList(val listId: Long) : TaskFilter
/** Several lists shown together, e.g. one widget covering more than one list. */
data class OfLists(val listIds: Set<Long>) : TaskFilter
data class Smart(val list: SmartList) : TaskFilter data class Smart(val list: SmartList) : TaskFilter
} }
@@ -21,6 +23,7 @@ object TaskFiltering {
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean = fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
when (filter) { when (filter) {
is TaskFilter.OfList -> task.listId == filter.listId is TaskFilter.OfList -> task.listId == filter.listId
is TaskFilter.OfLists -> task.listId in filter.listIds
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd) is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
} }
@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -122,10 +123,11 @@ class TaskEditViewModel @Inject constructor(
val settings = settingsPrefs.settings.first() val settings = settingsPrefs.settings.first()
defaultFields = settings.defaultEditFields defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
// A read-only or vanished default would give the task a list it // A read-only or vanished default — or a widget's preset list, if it was
// since deleted or turned read-only — would give the task a list it
// cannot be saved to. // cannot be saved to.
val writable = lists.filter { it.acceptsWrites } val writable = lists.filter { it.acceptsWrites }
val defaultList = presetListId val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } }
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } } ?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } }
?: writable.firstOrNull { !it.isLocal }?.id ?: writable.firstOrNull { !it.isLocal }?.id
?: writable.firstOrNull()?.id ?: writable.firstOrNull()?.id
@@ -135,6 +137,7 @@ class TaskEditViewModel @Inject constructor(
isNew = true, isNew = true,
autofocusTitle = settings.autofocusTitle, autofocusTitle = settings.autofocusTitle,
listId = defaultList, listId = defaultList,
start = if (settings.defaultStartToday) Clock.System.now() else null,
parentId = parentId, parentId = parentId,
lists = lists, lists = lists,
parentCandidates = loadParents(defaultList, selfId = null), parentCandidates = loadParents(defaultList, selfId = null),
@@ -4,7 +4,7 @@ import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger import de.jeanlucmakiola.agendula.data.sync.SyncRequests
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
@@ -76,7 +76,7 @@ class ImportViewModel @Inject constructor(
repository: TasksRepository, repository: TasksRepository,
private val resolver: ProviderResolver, private val resolver: ProviderResolver,
private val importer: IcsImport, private val importer: IcsImport,
private val syncTrigger: SyncTrigger, private val syncRequests: SyncRequests,
) : ViewModel() { ) : ViewModel() {
private val ownStore = MutableStateFlow(resolver.mode() == StorageMode.OWN) private val ownStore = MutableStateFlow(resolver.mode() == StorageMode.OWN)
@@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor(
val result = importer.import(ready.parsed, target) val result = importer.import(ready.parsed, target)
(chosen as? ImportChoice.Existing)?.list (chosen as? ImportChoice.Existing)?.list
?.takeIf { it.accountId != null && result.imported > 0 } ?.takeIf { it.accountId != null && result.imported > 0 }
?.let { runCatching { syncTrigger.enqueue(it.accountName) } } ?.let { runCatching { syncRequests.syncNow(it.accountName) } }
ImportStep.Done( ImportStep.Done(
listName = listName, listName = listName,
imported = result.imported, imported = result.imported,
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.agendula.ui.licences package de.jeanlucmakiola.agendula.ui.licences
import de.jeanlucmakiola.agendula.BuildConfig
/** /**
* The third-party code Agendula ships, and what each licence obliges us to say. * The third-party code Agendula ships, and what each licence obliges us to say.
* *
@@ -30,6 +32,8 @@ data class Attribution(
val licence: Licence, val licence: Licence,
/** Where the source can actually be obtained — MPL-2.0 §3.2(a)'s requirement. */ /** Where the source can actually be obtained — MPL-2.0 §3.2(a)'s requirement. */
val sourceUrl: String, val sourceUrl: String,
/** Only in the `full` flavor, which carries CalDAV sync and push. */
val syncOnly: Boolean = false,
) )
enum class Licence(val spdxId: String, val url: String) { enum class Licence(val spdxId: String, val url: String) {
@@ -54,30 +58,35 @@ object OpenSourceLicences {
copyright = "© bitfire web engineering (Ricki Hirner, Bernhard Stockmann)", copyright = "© bitfire web engineering (Ricki Hirner, Bernhard Stockmann)",
licence = Licence.MPL_2, licence = Licence.MPL_2,
sourceUrl = "https://github.com/bitfireAT/dav4jvm", sourceUrl = "https://github.com/bitfireAT/dav4jvm",
syncOnly = true,
), ),
Attribution( Attribution(
name = "dnsjava", name = "dnsjava",
copyright = "© Brian Wellington and the dnsjava contributors", copyright = "© Brian Wellington and the dnsjava contributors",
licence = Licence.BSD_3, licence = Licence.BSD_3,
sourceUrl = "https://github.com/dnsjava/dnsjava", sourceUrl = "https://github.com/dnsjava/dnsjava",
syncOnly = true,
), ),
Attribution( Attribution(
name = "UnifiedPush Android connector", name = "UnifiedPush Android connector",
copyright = "© UnifiedPush contributors", copyright = "© UnifiedPush contributors",
licence = Licence.APACHE_2, licence = Licence.APACHE_2,
sourceUrl = "https://codeberg.org/UnifiedPush/android-connector", sourceUrl = "https://codeberg.org/UnifiedPush/android-connector",
syncOnly = true,
), ),
Attribution( Attribution(
name = "Tink", name = "Tink",
copyright = "© Google LLC", copyright = "© Google LLC",
licence = Licence.APACHE_2, licence = Licence.APACHE_2,
sourceUrl = "https://github.com/tink-crypto/tink-java", sourceUrl = "https://github.com/tink-crypto/tink-java",
syncOnly = true,
), ),
Attribution( Attribution(
name = "OkHttp", name = "OkHttp",
copyright = "© Square, Inc.", copyright = "© Square, Inc.",
licence = Licence.APACHE_2, licence = Licence.APACHE_2,
sourceUrl = "https://github.com/square/okhttp", sourceUrl = "https://github.com/square/okhttp",
syncOnly = true,
), ),
Attribution( Attribution(
name = "Okio", name = "Okio",
@@ -121,5 +130,5 @@ object OpenSourceLicences {
licence = Licence.MIT, licence = Licence.MIT,
sourceUrl = "https://codeberg.org/jlmakiola/floret-kit", sourceUrl = "https://codeberg.org/jlmakiola/floret-kit",
), ),
) ).filter { BuildConfig.SYNC_ENABLED || !it.syncOnly }
} }
@@ -153,7 +153,6 @@ fun ListEditorSheet(
GroupedRow( GroupedRow(
title = destination.label, title = destination.label,
position = Position.Alone, position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp),
leading = { Icon(destinationIcon(destination), contentDescription = null) }, leading = { Icon(destinationIcon(destination), contentDescription = null) },
trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) }, trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) },
onClick = { choosingDestination = true }, onClick = { choosingDestination = true },
@@ -176,7 +175,6 @@ fun ListEditorSheet(
title = stringResource(R.string.list_in_smart_lists), title = stringResource(R.string.list_in_smart_lists),
summary = stringResource(R.string.list_in_smart_lists_hint), summary = stringResource(R.string.list_in_smart_lists_hint),
position = Position.Alone, position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp),
trailing = { trailing = {
Switch( Switch(
checked = !hiddenFromSmartLists, checked = !hiddenFromSmartLists,
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.floret.time.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
@@ -54,19 +54,19 @@ enum class ListWriteFailure {
} }
/** Maps a remote outcome onto the wording the screens already know how to show. */ /** Maps a remote outcome onto the wording the screens already know how to show. */
internal fun RemoteListRepository.Outcome.asFailure(local: ListWriteFailure): ListWriteFailure? = internal fun RemoteLists.Outcome.asFailure(local: ListWriteFailure): ListWriteFailure? =
when (this) { when (this) {
RemoteListRepository.Outcome.Done -> null RemoteLists.Outcome.Done -> null
is RemoteListRepository.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED is RemoteLists.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED
RemoteListRepository.Outcome.Unreachable -> ListWriteFailure.OFFLINE RemoteLists.Outcome.Unreachable -> ListWriteFailure.OFFLINE
RemoteListRepository.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED RemoteLists.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED
RemoteListRepository.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY RemoteLists.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY
// The account is gone, stopped or undecryptable, or the server said // The account is gone, stopped or undecryptable, or the server said
// something this call cannot place. Nothing specific to say beyond // something this call cannot place. Nothing specific to say beyond
// "that did not save", which is what [local] is — and specifically not // "that did not save", which is what [local] is — and specifically not
// "you are offline", which neither of them is a claim about. // "you are offline", which neither of them is a claim about.
RemoteListRepository.Outcome.NoAccount, RemoteLists.Outcome.NoAccount,
RemoteListRepository.Outcome.Unexpected, RemoteLists.Outcome.Unexpected,
-> local -> local
} }
@@ -96,7 +96,7 @@ private const val UPCOMING_PREVIEW = 3
@HiltViewModel @HiltViewModel
class ListsViewModel @Inject constructor( class ListsViewModel @Inject constructor(
private val repository: TasksRepository, private val repository: TasksRepository,
private val remoteLists: RemoteListRepository, private val remoteLists: RemoteLists,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
) : ViewModel() { ) : ViewModel() {
@@ -215,7 +215,7 @@ class ListsViewModel @Inject constructor(
// ⚠️ Server first. A row written before the MKCALENDAR would be a list // ⚠️ Server first. A row written before the MKCALENDAR would be a list
// that exists on the phone and nowhere else, with nothing to say so. // that exists on the phone and nowhere else, with nothing to say so.
val outcome = runCatching { remoteLists.create(accountId, name.trim(), color) } val outcome = runCatching { remoteLists.create(accountId, name.trim(), color) }
.getOrElse { RemoteListRepository.Outcome.Unreachable } .getOrElse { RemoteLists.Outcome.Unreachable }
_writeFailure.value = outcome.asFailure(ListWriteFailure.SAVE) _writeFailure.value = outcome.asFailure(ListWriteFailure.SAVE)
} }
} }
@@ -52,7 +52,8 @@ fun AgendulaNavHost(
nav.navigate(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)) { launchSingleTop = true } nav.navigate(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)) { launchSingleTop = true }
is NavRequest.OpenAccount -> is NavRequest.OpenAccount ->
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true } nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
is NavRequest.NewTask -> nav.navigate(Dest.TaskEdit.buildNew(title = navRequest.title)) is NavRequest.NewTask ->
nav.navigate(Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title))
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) { is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
launchSingleTop = true launchSingleTop = true
} }
@@ -69,6 +69,8 @@ object Dest {
fun build(filter: TaskFilter): String = when (filter) { fun build(filter: TaskFilter): String = when (filter) {
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}" is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}" is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
} }
/** Reconstruct the filter from a destination's arguments. */ /** Reconstruct the filter from a destination's arguments. */
@@ -18,8 +18,11 @@ sealed interface NavRequest {
/** One account's detail — where "sign in again" lives. */ /** One account's detail — where "sign in again" lives. */
data class OpenAccount(val accountId: Long) : NavRequest data class OpenAccount(val accountId: Long) : NavRequest
/** A new task, optionally titled with text shared from another app. */ /**
data class NewTask(val title: String? = null) : NavRequest * A new task, optionally titled with text shared from another app, or
* preset to a list — a widget's own add button, when it shows one list.
*/
data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */ /** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
data class OpenSmart(val list: SmartList) : NavRequest data class OpenSmart(val list: SmartList) : NavRequest
@@ -13,7 +13,6 @@ import androidx.compose.material3.Text
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -24,8 +23,6 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
import de.jeanlucmakiola.floret.components.OnboardingProgress import de.jeanlucmakiola.floret.components.OnboardingProgress
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -47,23 +44,7 @@ fun OnboardingFlow(
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
if (state.step == OnboardingStep.Account) { if (state.step == OnboardingStep.Account) {
// The same instance the wizard resolves for itself, so its length can be AccountStep(state, viewModel)
// read here without keeping a second copy of its state.
val account: AddAccountViewModel = hiltViewModel()
val accountState by account.state.collectAsStateWithLifecycle()
// ⚠️ The wizard grows a step for a provider that needs an app password
// minted first, and the outer bar has to grow with it — otherwise first
// run counts one flow while the screen inside it counts another.
LaunchedEffect(accountState.totalSteps) {
viewModel.onAccountStepsChanged(accountState.totalSteps)
}
AddAccountScreen(
onDone = { viewModel.onAccountFinished(added = true) },
onBack = { viewModel.onAccountFinished(added = false) },
stepOffset = state.position - 1,
totalSteps = state.total,
viewModel = account,
)
return return
} }
@@ -5,10 +5,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.BuildConfig
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS
import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -93,7 +93,7 @@ class OnboardingViewModel @Inject constructor(
* provider is chosen, so the bar is honest from the first frame and stays * provider is chosen, so the bar is honest from the first frame and stays
* honest when the choice makes the flow longer. * honest when the choice makes the flow longer.
*/ */
private val accountSlots = MutableStateFlow(ADD_ACCOUNT_STEPS) private val accountSlots = MutableStateFlow(ACCOUNT_STEP_SLOTS)
fun onAccountStepsChanged(count: Int) { fun onAccountStepsChanged(count: Int) {
accountSlots.value = count accountSlots.value = count
@@ -145,11 +145,11 @@ class OnboardingViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
prefs.setRemindersEnabled(enabled) prefs.setRemindersEnabled(enabled)
needsExactAlarms.value = enabled && !context.canScheduleExactAlarms() needsExactAlarms.value = enabled && !context.canScheduleExactAlarms()
advanceTo(if (needsExactAlarms.value) OnboardingStep.ExactAlarms else OnboardingStep.SyncOffer) advanceTo(if (needsExactAlarms.value) OnboardingStep.ExactAlarms else afterReminders())
} }
} }
fun onExactAlarmsDone() = advanceTo(OnboardingStep.SyncOffer) fun onExactAlarmsDone() = advanceTo(afterReminders())
fun onSyncAnswered(connect: Boolean) { fun onSyncAnswered(connect: Boolean) {
connectsAccount.value = connect connectsAccount.value = connect
@@ -192,11 +192,9 @@ class OnboardingViewModel @Inject constructor(
step.value = when (step.value) { step.value = when (step.value) {
OnboardingStep.Reminders -> OnboardingStep.Welcome OnboardingStep.Reminders -> OnboardingStep.Welcome
OnboardingStep.ExactAlarms -> OnboardingStep.Reminders OnboardingStep.ExactAlarms -> OnboardingStep.Reminders
// Once granted there is nothing left to ask, so back skips the step. OnboardingStep.SyncOffer -> beforeSyncOffer()
OnboardingStep.SyncOffer -> OnboardingStep.FirstList ->
if (needsExactAlarms.value && !context.canScheduleExactAlarms()) OnboardingStep.ExactAlarms if (BuildConfig.SYNC_ENABLED) OnboardingStep.SyncOffer else beforeSyncOffer()
else OnboardingStep.Reminders
OnboardingStep.FirstList -> OnboardingStep.SyncOffer
else -> return else -> return
} }
} }
@@ -230,6 +228,15 @@ class OnboardingViewModel @Inject constructor(
} }
} }
/** Without sync built in there is nothing to offer, and reminders lead straight on. */
private fun afterReminders(): OnboardingStep =
if (BuildConfig.SYNC_ENABLED) OnboardingStep.SyncOffer else OnboardingStep.FirstList
/** Once granted there is nothing left to ask, so back skips the exact-alarm step. */
private fun beforeSyncOffer(): OnboardingStep =
if (needsExactAlarms.value && !context.canScheduleExactAlarms()) OnboardingStep.ExactAlarms
else OnboardingStep.Reminders
private fun afterFirstList() { private fun afterFirstList() {
step.value = stepAfterFirstList() step.value = stepAfterFirstList()
} }
@@ -241,7 +248,7 @@ class OnboardingViewModel @Inject constructor(
add(OnboardingStep.Welcome) add(OnboardingStep.Welcome)
add(OnboardingStep.Reminders) add(OnboardingStep.Reminders)
if (needsExact) add(OnboardingStep.ExactAlarms) if (needsExact) add(OnboardingStep.ExactAlarms)
add(OnboardingStep.SyncOffer) if (BuildConfig.SYNC_ENABLED) add(OnboardingStep.SyncOffer)
if (sync) add(OnboardingStep.Account) if (sync) add(OnboardingStep.Account)
if (needsList) add(OnboardingStep.FirstList) if (needsList) add(OnboardingStep.FirstList)
if (!sync) add(OnboardingStep.Backup) if (!sync) add(OnboardingStep.Backup)
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.NotificationsActive import androidx.compose.material.icons.rounded.NotificationsActive
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -14,6 +15,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.BuildConfig
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.components.BenefitRow import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingSpace import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -56,10 +58,18 @@ internal fun WelcomeStep(chrome: StepChrome, onContinue: () -> Unit) {
body = stringResource(R.string.onboarding_welcome_reminders_body), body = stringResource(R.string.onboarding_welcome_reminders_body),
) )
Spacer(Modifier.height(OnboardingSpace.sm)) Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow( if (BuildConfig.SYNC_ENABLED) {
icon = Icons.Rounded.CloudSync, BenefitRow(
title = stringResource(R.string.onboarding_welcome_sync_title), icon = Icons.Rounded.CloudSync,
body = stringResource(R.string.onboarding_welcome_sync_body), title = stringResource(R.string.onboarding_welcome_sync_title),
) body = stringResource(R.string.onboarding_welcome_sync_body),
)
} else {
BenefitRow(
icon = Icons.Rounded.CloudOff,
title = stringResource(R.string.onboarding_welcome_offline_title),
body = stringResource(R.string.onboarding_welcome_offline_body),
)
}
} }
} }
@@ -40,13 +40,8 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.BuildConfig
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
import de.jeanlucmakiola.agendula.ui.export.ExportScreen import de.jeanlucmakiola.agendula.ui.export.ExportScreen
import de.jeanlucmakiola.agendula.ui.imports.ImportScreen import de.jeanlucmakiola.agendula.ui.imports.ImportScreen
import de.jeanlucmakiola.agendula.ui.licences.LicencesScreen import de.jeanlucmakiola.agendula.ui.licences.LicencesScreen
@@ -64,7 +59,7 @@ import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.locale.AppLanguage import de.jeanlucmakiola.floret.locale.AppLanguage
/** The settings sub-screens reached from the hub's category rows. */ /** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { internal enum class SettingsSection {
Appearance, Appearance,
TaskForm, TaskForm,
Reminders, Reminders,
@@ -111,16 +106,6 @@ fun SettingsScreen(
else SettingsSection.entries.firstOrNull { it.name == initialSection }, else SettingsSection.entries.firstOrNull { it.name == initialSection },
) )
} }
// Hoisted so the add flow can refresh the list it returns to.
val accountsViewModel: AccountsViewModel = hiltViewModel()
// Shared with the add flow's own lookup, so "sign in again" can prefill it.
val addAccountViewModel: AddAccountViewModel = hiltViewModel()
val signInAgain: (AccountEntity) -> Unit = { account ->
addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username)
section = SettingsSection.AddAccount
}
// Which account the detail screen is showing; the section alone cannot say.
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
// Inside a sub-screen, system back (button or gesture) returns to the hub // Inside a sub-screen, system back (button or gesture) returns to the hub
// rather than popping the whole Settings destination to the lists overview. // rather than popping the whole Settings destination to the lists overview.
@@ -159,58 +144,19 @@ fun SettingsScreen(
SlideInSection(visible = section == SettingsSection.Import) { SlideInSection(visible = section == SettingsSection.Import) {
ImportScreen(onBack = { section = SettingsSection.Storage }) ImportScreen(onBack = { section = SettingsSection.Storage })
} }
// Accounts stays composed under Add account, for the same reason Storage
// stays composed under Export: the deeper screen slides over it.
val accountsOpen = section == SettingsSection.Accounts ||
section?.parent == SettingsSection.Accounts
SlideInSection(visible = accountsOpen) {
AccountsScreen(
onAddAccount = {
// A sign-in abandoned half-way must not turn this into one.
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
section = SettingsSection.AddAccount
},
onSignInAgain = signInAgain,
onOpenAccount = {
openAccount = it
section = SettingsSection.Account
},
onOpenStorage = { section = SettingsSection.Storage },
onBack = { section = null },
viewModel = accountsViewModel,
)
}
SlideInSection(visible = section == SettingsSection.Licences) { SlideInSection(visible = section == SettingsSection.Licences) {
LicencesScreen(onBack = { section = null }) LicencesScreen(onBack = { section = null })
} }
SlideInSection(visible = section == SettingsSection.Account) { AccountSections(
openAccount?.let { id -> section = section,
AccountDetailScreen( initialAccountId = initialAccountId,
accountId = id, onSection = { section = it },
onBack = { section = SettingsSection.Accounts }, )
onRemoved = { section = SettingsSection.Accounts },
onSignInAgain = signInAgain,
onOpenStorage = { section = SettingsSection.Storage },
viewModel = accountsViewModel,
)
}
}
SlideInSection(visible = section == SettingsSection.AddAccount) {
AddAccountScreen(
// The list stays composed underneath, so nothing re-runs its
// init and OnResume never fires on a section change. It no longer
// needs to: the accounts come from an observed query, so a new
// account — and every later sync — arrives on its own.
onDone = { section = SettingsSection.Accounts },
onBack = { section = SettingsSection.Accounts },
viewModel = addAccountViewModel,
)
}
} }
} }
@Composable @Composable
private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) { internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = slideInHorizontally { it } + fadeIn(), enter = slideInHorizontally { it } + fadeIn(),
@@ -267,17 +213,19 @@ private fun SettingsHub(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
GroupedSectionHeader(stringResource(R.string.settings_group_data)) GroupedSectionHeader(stringResource(R.string.settings_group_data))
GroupedRow( if (BuildConfig.SYNC_ENABLED) {
title = stringResource(R.string.settings_section_accounts), GroupedRow(
summary = stringResource(R.string.settings_accounts_subtitle), title = stringResource(R.string.settings_section_accounts),
position = Position.Top, summary = stringResource(R.string.settings_accounts_subtitle),
leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) }, position = Position.Top,
onClick = { onOpenSection(SettingsSection.Accounts) }, leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) },
) onClick = { onOpenSection(SettingsSection.Accounts) },
)
}
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_section_storage), title = stringResource(R.string.settings_section_storage),
summary = stringResource(R.string.settings_storage_subtitle), summary = stringResource(R.string.settings_storage_subtitle),
position = Position.Bottom, position = if (BuildConfig.SYNC_ENABLED) Position.Bottom else Position.Alone,
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) }, leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) },
onClick = { onOpenSection(SettingsSection.Storage) }, onClick = { onOpenSection(SettingsSection.Storage) },
) )
@@ -241,6 +241,7 @@ class SettingsViewModel @Inject constructor(
fun setSnoozeMinutes(minutes: Int) = viewModelScope.launch { prefs.setSnoozeMinutes(minutes) } fun setSnoozeMinutes(minutes: Int) = viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) } fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) } fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
fun setDefaultStartToday(enabled: Boolean) = viewModelScope.launch { prefs.setDefaultStartToday(enabled) }
/** Toggle whether [field] shows by default on a new task's edit form. */ /** Toggle whether [field] shows by default on a new task's edit form. */
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch { fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
@@ -99,6 +99,18 @@ internal fun TaskFormScreen(
}, },
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) }, onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
) )
GroupedRow(
title = stringResource(R.string.settings_default_start_today),
summary = stringResource(R.string.settings_default_start_today_hint),
position = Position.Middle,
trailing = {
Switch(
checked = state.settings.defaultStartToday,
onCheckedChange = viewModel::setDefaultStartToday,
)
},
onClick = { viewModel.setDefaultStartToday(!state.settings.defaultStartToday) },
)
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_bottom_add_bar), title = stringResource(R.string.settings_bottom_add_bar),
summary = stringResource(R.string.settings_bottom_add_bar_hint), summary = stringResource(R.string.settings_bottom_add_bar_hint),
@@ -52,6 +52,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -139,9 +140,12 @@ fun TaskListScreen(
val list = content?.list val list = content?.list
val listName = list?.name val listName = list?.name
val listId = (filter as? TaskFilter.OfList)?.listId val listId = (filter as? TaskFilter.OfList)?.listId
// Editing is offered for a device-only list. A collection that belongs to an // Editing is offered for any writable list, device or synced — renaming and
// account is the server's to rename or remove, not ours. // recolouring a synced collection is a server write the same sheet already
// handles. A read-only share keeps the icon but disables it: tapping it
// explains why rather than the button silently not being there.
var editingList by rememberSaveable { mutableStateOf(false) } var editingList by rememberSaveable { mutableStateOf(false) }
var readOnlyHint by rememberSaveable { mutableStateOf(false) }
val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle() val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle()
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle() val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
// The list this screen is about is gone; there is nothing left to show. // The list this screen is about is gone; there is nothing left to show.
@@ -152,6 +156,12 @@ fun TaskListScreen(
viewModel.clearListWriteFailure() viewModel.clearListWriteFailure()
} }
} }
LaunchedEffect(readOnlyHint) {
if (readOnlyHint) {
delay(4_000)
readOnlyHint = false
}
}
// One add affordance, never two: a real list with the setting on gets a pinned // One add affordance, never two: a real list with the setting on gets a pinned
// bottom quick-add bar; everything else (incl. smart lists, which have no single // bottom quick-add bar; everything else (incl. smart lists, which have no single
// target list) gets the floating "New task" button. // target list) gets the floating "New task" button.
@@ -181,11 +191,20 @@ fun TaskListScreen(
IconButton(onClick = { showSort = true }, enabled = content != null) { IconButton(onClick = { showSort = true }, enabled = content != null) {
Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title)) Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title))
} }
if (list != null && list.isLocal) { if (list != null) {
IconButton(onClick = { editingList = true }) { IconButton(
onClick = {
if (list.isReadOnly) readOnlyHint = true else editingList = true
},
) {
Icon( Icon(
Icons.Rounded.Edit, Icons.Rounded.Edit,
contentDescription = stringResource(R.string.list_edit_title), contentDescription = stringResource(R.string.list_edit_title),
tint = if (list.isReadOnly) {
LocalContentColor.current.copy(alpha = 0.38f)
} else {
LocalContentColor.current
},
) )
} }
} }
@@ -230,23 +249,28 @@ fun TaskListScreen(
.height(SnackChipHeight), .height(SnackChipHeight),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
// One chip, one anchor: the undo receipt takes precedence, and a // One chip, one anchor, in priority order: the undo receipt first,
// refused list write reports itself once the undo window is clear. // then a refused list write once the undo window is clear, then the
// read-only edit hint — the quietest of the three, so it never
// outranks something the other two are actually reporting.
val failure = listWriteFailure val failure = listWriteFailure
if (undo != null || failure == null) { when {
SnackChip( undo != null -> SnackChip(
visible = undo != null, visible = true,
message = stringResource( message = stringResource(
if (undo is UndoReceipt.Completed) R.string.task_completed else R.string.task_deleted, if (undo is UndoReceipt.Completed) R.string.task_completed else R.string.task_deleted,
), ),
actionLabel = stringResource(R.string.undo), actionLabel = stringResource(R.string.undo),
onAction = viewModel::undo, onAction = viewModel::undo,
) )
} else { failure != null -> SnackChip(
SnackChip(
visible = true, visible = true,
message = stringResource(listWriteFailureMessage(failure)), message = stringResource(listWriteFailureMessage(failure)),
) )
else -> SnackChip(
visible = readOnlyHint,
message = stringResource(R.string.list_read_only),
)
} }
} }
} }
@@ -1148,6 +1172,8 @@ private fun CenteredMessage(text: String, inner: PaddingValues) {
@Composable @Composable
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) { private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title) is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
is TaskFilter.Smart -> stringResource( is TaskFilter.Smart -> stringResource(
when (filter.list) { when (filter.list) {
SmartList.TODAY -> R.string.smart_today SmartList.TODAY -> R.string.smart_today
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
@@ -77,7 +77,7 @@ private const val UNDO_WINDOW_MS = 4_000L
class TaskListViewModel @Inject constructor( class TaskListViewModel @Inject constructor(
private val repository: TasksRepository, private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs, private val settingsPrefs: SettingsPrefs,
private val remoteLists: RemoteListRepository, private val remoteLists: RemoteLists,
@ApplicationScope private val appScope: CoroutineScope, @ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() { ) : ViewModel() {
@@ -103,6 +103,8 @@ class TaskListViewModel @Inject constructor(
} }
is TaskFilter.Smart -> is TaskFilter.Smart ->
tasks.map { TaskListUiState.Content(it) } tasks.map { TaskListUiState.Content(it) }
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
} }
// Drop rows pending an undoable delete so the row vanishes on swipe // Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit, // while the actual provider delete waits for the snackbar to commit,
@@ -298,7 +300,7 @@ class TaskListViewModel @Inject constructor(
return@launch return@launch
} }
val outcome = runCatching { remoteLists.rename(listId, name.trim(), color) } val outcome = runCatching { remoteLists.rename(listId, name.trim(), color) }
.getOrElse { RemoteListRepository.Outcome.Unreachable } .getOrElse { RemoteLists.Outcome.Unreachable }
_listWriteFailure.value = outcome.asFailure(ListWriteFailure.SAVE) _listWriteFailure.value = outcome.asFailure(ListWriteFailure.SAVE)
} }
@@ -320,7 +322,7 @@ class TaskListViewModel @Inject constructor(
// existing for every other client, and could only be got back by // existing for every other client, and could only be got back by
// re-adding the whole account. // re-adding the whole account.
val outcome = runCatching { remoteLists.delete(listId) } val outcome = runCatching { remoteLists.delete(listId) }
.getOrElse { RemoteListRepository.Outcome.Unreachable } .getOrElse { RemoteLists.Outcome.Unreachable }
val failure = outcome.asFailure(ListWriteFailure.DELETE) val failure = outcome.asFailure(ListWriteFailure.DELETE)
if (failure == null) _listDeleted.value = true else _listWriteFailure.value = failure if (failure == null) _listDeleted.value = true else _listWriteFailure.value = failure
} }
@@ -0,0 +1,235 @@
package de.jeanlucmakiola.agendula.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.provideContent
import androidx.glance.color.ColorProvider
import androidx.glance.currentState
import androidx.glance.unit.ColorProvider as GlanceColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetActionButton
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.WidgetSurface
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.glance.WidgetTile
import de.jeanlucmakiola.floret.glance.R as GlanceR
import kotlinx.coroutines.flow.first
import kotlin.math.min
/**
* "Lists" home-screen widget: one shortcut tile per list, in the list's own
* colour, showing its open count — navigation to a list rather than a
* preview of its tasks, which is what [TaskWidget] already does.
*/
class ListsWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact, not Responsive: the grid is sized to the real placement so every
// tile fits without scrolling.
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
provideContent {
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
val listIds = parseWidgetListIds(currentState(WIDGET_LIST_IDS_KEY))
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
FloretGlanceTheme(
lightScheme = AgendulaLightFallback,
darkScheme = AgendulaDarkFallback,
dynamicColor = ready?.snapshot?.dynamicColor ?: true,
mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
) {
ListsWidgetBody(data, background, listIds)
}
}
}
}
@Composable
private fun ListsWidgetBody(data: WidgetStatsData?, background: WidgetBackground, listIds: Set<Long>?) {
val context = LocalContext.current
val ready = data as? WidgetStatsData.Ready
val tiles = ready?.snapshot?.lists?.let { lists -> listIds?.let { ids -> lists.filter { it.id in ids } } ?: lists }
val size = LocalSize.current
WidgetSurface(background = background) {
Column(modifier = GlanceModifier.fillMaxSize().padding(Padding - Gap / 2)) {
when {
data == null -> Unit
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable))
tiles.isNullOrEmpty() -> WidgetMessage(context.getString(R.string.widget_lists_empty))
else -> {
val grid = listGrid(tiles.size, size.width - Padding * 2, size.height - Padding * 2)
val rows = tiles.take(grid.columns * grid.rows).chunked(grid.columns)
// Gaps are each tile's padding rather than Spacers, which would
// eat into Glance's 10-children-per-Row/Column cap.
rows.forEach { rowTiles ->
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
rowTiles.forEach { tile ->
// Glance has no margin — padding on the tile itself lands inside its fill.
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(Gap / 2)) {
ListTileCell(tile, grid, GlanceModifier.fillMaxSize())
}
}
}
}
}
}
}
}
}
internal data class ListGrid(val columns: Int, val rows: Int, val tileWidth: Dp, val tileHeight: Dp)
/**
* Picks the column count whose tiles come closest to [TileAspect] at the
* largest size, among those that fit [count] tiles into [width] x [height].
* Tiles too narrow for the one-line layout must be tall enough to stack, so a
* narrow widget shows fewer, readable tiles. Lists that don't fit are dropped
* rather than scrolled.
*/
internal fun listGrid(count: Int, width: Dp, height: Dp): ListGrid {
fun span(total: Dp, cells: Int) = (total - Gap * (cells - 1)) / cells
fun maxRows(columns: Int): Int {
val minHeight = if (span(width, columns) < MinOneLineTileWidth) MinNarrowTileHeight else MinTileHeight
return ((height + Gap) / (minHeight + Gap)).toInt().coerceIn(1, MaxCells)
}
val maxColumns = ((width + Gap) / (MinTileWidth + Gap)).toInt().coerceIn(1, MaxCells)
val best = (1..min(count, maxColumns))
.map { columns -> columns to (count + columns - 1) / columns }
.filter { (columns, rows) -> rows <= maxRows(columns) }
.maxByOrNull { (columns, rows) -> min(span(width, columns).value / TileAspect, span(height, rows).value) }
val (columns, rows) = best ?: (maxColumns to maxRows(maxColumns))
return ListGrid(columns, rows, span(width, columns), span(height, rows))
}
@Composable
private fun ListTileCell(tile: ListTile, grid: ListGrid, modifier: GlanceModifier) {
val context = LocalContext.current
val tint = ColorProvider(day = pastelize(tile.color, false), night = pastelize(tile.color, true))
val onTint = GlanceColorProvider(Color.White)
val showAdd = tile.acceptsWrites && grid.tileWidth >= MinTileWidthWithAdd
WidgetTile(
color = tint,
modifier = modifier,
onClick = actionStartActivity(MainActivity.listIntent(context, tile.id)),
) {
if (grid.tileWidth < MinOneLineTileWidth && grid.tileHeight >= MinNarrowTileHeight) {
// Narrow: the name gets the full width and wraps; count and + share the bottom row.
Column(modifier = GlanceModifier.fillMaxSize()) {
Text(
text = tile.name,
maxLines = if (grid.tileHeight >= MinTwoLineNameHeight) 2 else 1,
style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium),
)
Spacer(GlanceModifier.defaultWeight())
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${tile.openCount}",
modifier = GlanceModifier.defaultWeight(),
style = TextStyle(color = onTint, fontSize = 18.sp, fontWeight = FontWeight.Medium),
)
if (showAdd) ListAddButton(tile)
}
}
} else if (grid.tileHeight >= MinStackedTileHeight) {
Column(modifier = GlanceModifier.fillMaxSize()) {
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = tile.name,
maxLines = 1,
modifier = GlanceModifier.defaultWeight(),
style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium),
)
if (showAdd) ListAddButton(tile)
}
Spacer(GlanceModifier.defaultWeight())
Text(
text = "${tile.openCount}",
style = TextStyle(color = onTint, fontSize = 18.sp, fontWeight = FontWeight.Medium),
)
}
} else {
Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = tile.name,
maxLines = 1,
modifier = GlanceModifier.defaultWeight(),
style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium),
)
Text(
text = "${tile.openCount}",
modifier = GlanceModifier.padding(horizontal = 4.dp),
style = TextStyle(color = onTint, fontSize = 15.sp, fontWeight = FontWeight.Bold),
)
if (showAdd) ListAddButton(tile)
}
}
}
}
@Composable
private fun ListAddButton(tile: ListTile) {
val context = LocalContext.current
WidgetActionButton(
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
contentDescription = context.getString(R.string.new_task),
action = actionStartActivity(MainActivity.newTaskIntent(context, tile.id)),
style = WidgetActionStyle.TONAL,
size = 20.dp,
)
}
private val Padding = 8.dp
private val Gap = 8.dp
private val MinTileWidth = 72.dp
private val MinTileHeight = 36.dp
private val MinTileWidthWithAdd = 64.dp
private val MinStackedTileHeight = 60.dp
private val MinNarrowTileHeight = 52.dp
private val MinTwoLineNameHeight = 70.dp
private val MinOneLineTileWidth = 120.dp
private const val TileAspect = 1.5f
/** Glance's per-Row/Column child cap. */
private const val MaxCells = 10
@@ -0,0 +1,27 @@
package de.jeanlucmakiola.agendula.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.floret.glance.WidgetRollover
/** Host-facing receiver for [ListsWidget]; also keeps the midnight rollover armed. */
class ListsWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = ListsWidget()
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
}
@@ -1,17 +1,18 @@
package de.jeanlucmakiola.agendula.widget package de.jeanlucmakiola.agendula.widget
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.GlanceModifier import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider import androidx.glance.ImageProvider
import androidx.glance.LocalContext import androidx.glance.LocalContext
import androidx.glance.action.ActionParameters import androidx.glance.action.ActionParameters
@@ -22,12 +23,10 @@ import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.background
import androidx.glance.color.ColorProvider import androidx.glance.color.ColorProvider
import androidx.glance.currentState import androidx.glance.currentState
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
@@ -44,20 +43,40 @@ import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight import androidx.glance.text.FontWeight
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextDecoration
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider as GlanceColorProvider
import de.jeanlucmakiola.agendula.MainActivity import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetCheckButton
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import de.jeanlucmakiola.floret.glance.WidgetDoneState
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.WidgetSurface
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.time.formatDateTimeCompact import de.jeanlucmakiola.floret.time.formatDateTimeCompact
import de.jeanlucmakiola.floret.time.formatTime import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.floret.glance.R as GlanceR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* "Tasks" home-screen widget: one smart list (Today by default) or one real * "Tasks" home-screen widget: one smart list (Today by default) or one or more
* list per instance, with a checkbox to complete each task in place. * real lists per instance, with a Calendar-agenda look — a plain section label
* per group (by list, or by due bucket: Overdue / Today / Tomorrow / Later /
* No date) above a run of standalone, fully-rounded task pills, each with a
* trailing check to complete it in place.
* *
* The data is collected live inside the composition rather than loaded once in * The data is collected live inside the composition rather than loaded once in
* the `provideGlance` preamble: an `update` on a running session only * the `provideGlance` preamble: an `update` on a running session only
@@ -68,200 +87,295 @@ class TaskWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Single override val sizeMode = SizeMode.Responsive(setOf(CompactSize, RegularSize))
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val stored = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[WIDGET_FILTER_KEY] val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
val initialFilter = parseWidgetFilter(stored) val initialFilter = parseWidgetFilter(state[WIDGET_FILTER_KEY])
val initial = runCatching { context.taskWidgetData(initialFilter).first() } val initialGroupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
val initial = runCatching { context.taskWidgetData(initialFilter, initialGroupByList).first() }
.getOrDefault(TaskWidgetData.Unavailable) .getOrDefault(TaskWidgetData.Unavailable)
provideContent { provideContent {
val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY)) val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY))
val data by remember(filter) { context.taskWidgetData(filter) } val groupByList = currentState(WIDGET_GROUP_BY_LIST_KEY) ?: false
.collectAsState(initial = if (filter == initialFilter) initial else null) val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
val rowColor = parseWidgetRowColor(currentState(WIDGET_ROW_COLOR_KEY))
val data by remember(filter, groupByList) { context.taskWidgetData(filter, groupByList) }
.collectAsState(initial = if (filter == initialFilter && groupByList == initialGroupByList) initial else null)
val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready
AgendulaGlanceTheme( FloretGlanceTheme(
lightScheme = AgendulaLightFallback,
darkScheme = AgendulaDarkFallback,
dynamicColor = ready?.dynamicColor ?: true, dynamicColor = ready?.dynamicColor ?: true,
themeMode = ready?.themeMode ?: ThemeMode.SYSTEM, mode = ready?.effectiveThemeMode(themeOverride) ?: WidgetThemeMode.SYSTEM,
) { ) {
TaskWidgetBody(filter, data) TaskWidgetBody(filter, data, background, rowColor)
} }
} }
} }
private companion object {
val CompactSize = DpSize(110.dp, 110.dp)
val RegularSize = DpSize(250.dp, 110.dp)
}
} }
/** Completes the tapped task — just the one occurrence for a recurring series. */ /** Completes the tapped task — just the one occurrence for a recurring series. */
class CompleteTaskAction : ActionCallback { class CompleteTaskAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val taskId = parameters[TaskIdKey] ?: return val taskId = parameters[TaskIdKey] ?: return
val occurrence = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE } val occurrenceMillis = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE }
?.let { Instant.fromEpochMilliseconds(it) } val key = WidgetCompletions.key(taskId, occurrenceMillis)
if (!WidgetCompletions.start(key)) return
val app = context.applicationContext
// This callback is shared by every widget's rows, and glanceId only
// tells us the tapped widget's instance, not which provider class it
// belongs to — updating the wrong one by id would push this class's
// RemoteViews into another widget's AppWidget slot. updateAll is
// class-scoped, so redrawing every widget is the immediate, correct
// option; it also covers every other open instance showing this task.
// First the ticked frame, then the real completion a beat later.
updateAllWidgets(app)
delay(TICK_FRAME_MILLIS)
runCatching { runCatching {
context.widgetEntryPoint().tasksRepository().setCompleted(taskId, occurrence, true) context.widgetEntryPoint().tasksRepository()
.setCompleted(taskId, occurrenceMillis?.let { Instant.fromEpochMilliseconds(it) }, true)
} }
TaskWidget().update(context.applicationContext, glanceId) updateAllWidgets(app)
// Hold the ticked look until the store's change has redrawn the row away.
delay(TICK_RELEASE_MILLIS)
WidgetCompletions.finish(key)
updateAllWidgets(app)
} }
companion object { companion object {
val TaskIdKey = ActionParameters.Key<Long>("taskId") val TaskIdKey = ActionParameters.Key<Long>("taskId")
val OccurrenceKey = ActionParameters.Key<Long>("occurrenceStart") val OccurrenceKey = ActionParameters.Key<Long>("occurrenceStart")
const val NO_OCCURRENCE = Long.MIN_VALUE const val NO_OCCURRENCE = Long.MIN_VALUE
private const val TICK_FRAME_MILLIS = 600L
private const val TICK_RELEASE_MILLIS = 1500L
} }
} }
@Composable @Composable
private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) { private fun TaskWidgetBody(
filter: TaskFilter,
data: TaskWidgetData?,
background: WidgetBackground,
rowColor: WidgetRowColor,
) {
val context = LocalContext.current val context = LocalContext.current
val ready = data as? TaskWidgetData.Ready
val title = when (filter) { val title = when (filter) {
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list)) is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
is TaskFilter.OfList -> (data as? TaskWidgetData.Ready)?.listName is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
?: context.getString(R.string.widget_tasks_label) ?: context.getString(R.string.widget_tasks_label)
} }
Column( WidgetSurface(background = background) {
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Header(title, filter)
Spacer(GlanceModifier.height(4.dp))
when (data) {
null -> Unit
TaskWidgetData.Unavailable -> Message(context.getString(R.string.widget_unavailable))
is TaskWidgetData.Ready ->
if (data.rows.isEmpty()) {
Message(context.getString(R.string.widget_empty))
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(data.rows.size) { index -> TaskRow(data.rows[index], data.use24Hour) }
}
}
}
}
}
@Composable
private fun Header(title: String, filter: TaskFilter) {
val context = LocalContext.current
val open = when (filter) {
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId)
}
Row(
modifier = GlanceModifier.fillMaxWidth().padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.primary,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier.defaultWeight().clickable(actionStartActivity(open)),
)
Box(
modifier = GlanceModifier
.size(40.dp)
.cornerRadius(20.dp)
.background(GlanceTheme.colors.primaryContainer)
.clickable(actionStartActivity(MainActivity.newTaskIntent(context))),
contentAlignment = Alignment.Center,
) {
Image(
provider = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.new_task),
colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimaryContainer),
modifier = GlanceModifier.size(22.dp),
)
}
}
}
@Composable
private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) {
val context = LocalContext.current
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
val open = actionStartActivity(
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
)
Row(
modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = GlanceModifier
.width(4.dp)
.height(32.dp)
.cornerRadius(2.dp)
.background(ColorProvider(day = pastelize(row.color, false), night = pastelize(row.color, true))),
) {}
Box(
modifier = GlanceModifier
.size(40.dp)
.clickable(
actionRunCallback<CompleteTaskAction>(
actionParametersOf(
CompleteTaskAction.TaskIdKey to row.taskId,
CompleteTaskAction.OccurrenceKey to
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
),
),
),
contentAlignment = Alignment.Center,
) {
Image(
provider = ImageProvider(R.drawable.ic_widget_checkbox),
contentDescription = context.getString(R.string.widget_complete_task, title),
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(22.dp),
)
}
Column( Column(
modifier = GlanceModifier.defaultWeight().padding(end = 8.dp, top = 4.dp, bottom = 4.dp) modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),
.clickable(open),
) { ) {
Text( TaskWidgetHeader(title, filter, ready?.singleListId)
text = title, Spacer(GlanceModifier.height(2.dp))
maxLines = 1, when (data) {
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp), null -> Unit
) TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable))
dueText(context, row, use24Hour)?.let { due -> is TaskWidgetData.Ready ->
Text( if (data.rows.isEmpty()) {
text = due, WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
maxLines = 1, } else {
style = TextStyle( val allSections = widgetSections(
color = if (row.isOverdue) GlanceTheme.colors.error data.rows,
else GlanceTheme.colors.onSurfaceVariant, data.groupByList,
fontSize = 12.sp, data.listNames,
), ) { kind -> context.getString(dueBucketLabel(kind)) }
) // A lone group whose bucket repeats the widget's own smart list
// (the "Today" smart list showing one "Today" bucket) earns no
// place of its own. A lone group for any other bucket (a
// real-list widget whose only bucket is "Tomorrow") still has to
// say so — it's the one thing telling the rows apart from today.
// Compared by due kind, not by rendered label text, since the two
// labels come from separately translated string resources and
// aren't guaranteed to read identically in every locale.
val onlyHeader = allSections.singleOrNull { it is WidgetSection.Header } as? WidgetSection.Header
val redundantDueKind = when ((filter as? TaskFilter.Smart)?.list) {
SmartList.TODAY -> DueKind.TODAY
SmartList.OVERDUE -> DueKind.PAST
else -> null
}
val redundantHeader = onlyHeader != null && redundantDueKind != null &&
onlyHeader.key == "due:${redundantDueKind.name}"
val sections = if (redundantHeader) allSections.filterNot { it is WidgetSection.Header } else allSections
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(sections.size) { index ->
when (val section = sections[index]) {
is WidgetSection.Header -> SectionHeader(section.name)
is WidgetSection.TaskItem -> {
val container = if (rowColor == WidgetRowColor.BY_LIST) {
ColorProvider(
day = pastelize(section.row.color, false),
night = pastelize(section.row.color, true),
)
} else {
GlanceTheme.colors.surfaceVariant
}
// A LazyColumn item's host view isn't a linear layout on
// its own, so the row and its gap need an explicit Column
// to stack rather than overlap.
Column {
AgendaTaskRow(
row = section.row,
use24Hour = data.use24Hour,
color = container,
colored = rowColor == WidgetRowColor.BY_LIST,
// The due-bucket header above already names the day
// (Today / Tomorrow) whenever one is shown — and when
// it isn't, that's only because it would have repeated
// the title, which names the same day just as well.
suppressDayLabel = !data.groupByList,
)
Spacer(GlanceModifier.height(8.dp))
}
}
}
}
}
}
} }
} }
} }
} }
private fun dueBucketLabel(kind: DueKind?): Int = when (kind) {
DueKind.PAST -> R.string.widget_section_overdue
DueKind.TODAY -> R.string.home_due_today
DueKind.TOMORROW -> R.string.home_due_tomorrow
DueKind.LATER -> R.string.widget_section_later
null -> R.string.widget_section_no_date
}
@Composable @Composable
private fun Message(text: String) { private fun SectionHeader(name: String) {
Box( Text(
modifier = GlanceModifier.fillMaxSize().padding(16.dp), text = name,
contentAlignment = Alignment.Center, maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
)
}
@Composable
private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Long?) {
val context = LocalContext.current
val open = when (filter) {
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId)
// Several lists have no single list screen to open; a single selection
// still opens straight to it, otherwise this just raises the app.
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
WidgetHeader(
title = title,
titleAction = actionStartActivity(open),
) { ) {
Text( WidgetHeaderAction(
text = text, icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp), contentDescription = context.getString(R.string.new_task),
action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)),
) )
} }
} }
/** "Today · 14:00", "Tomorrow", or the compact date; `null` without a due date. */ /**
private fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String? { * A Calendar-agenda-style task chip: its own fully-rounded pill (list colour
* or a neutral tone, per [WidgetRowColor]), a leading check to complete it,
* title and due to its right. [colored] pills use fixed light text/icon —
* [pastelize][de.jeanlucmakiola.floret.components.pastelize] keeps them
* mid-bright rather than pale, so the theme's own `onSurface` (tuned for the
* neutral tonal fill) reads too dark against them.
*/
@Composable
internal fun AgendaTaskRow(
row: WidgetTaskRow,
use24Hour: Boolean,
color: GlanceColorProvider,
colored: Boolean,
suppressDayLabel: Boolean,
) {
val context = LocalContext.current
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
val open = actionStartActivity(
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
)
val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface
val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant
val onColorError = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.error
val completing = WidgetCompletions.isCompleting(row)
WidgetGroupedRow(position = Position.Alone, color = color) {
Row(
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
WidgetCheckButton(
contentDescription = context.getString(R.string.widget_complete_task, title),
action = actionRunCallback<CompleteTaskAction>(
actionParametersOf(
CompleteTaskAction.TaskIdKey to row.taskId,
CompleteTaskAction.OccurrenceKey to
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
),
),
tint = onColorVariant,
checked = completing,
)
Spacer(GlanceModifier.width(8.dp))
Column(modifier = GlanceModifier.defaultWeight().clickable(open)) {
Text(
text = title,
maxLines = 1,
style = TextStyle(
color = if (completing) onColorVariant else onColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
textDecoration = if (completing) TextDecoration.LineThrough else TextDecoration.None,
),
)
dueText(context, row, use24Hour, suppressDayLabel)?.let { due ->
Text(
text = due,
maxLines = 1,
style = TextStyle(
color = if (row.isOverdue) onColorError else onColorVariant,
fontSize = 12.sp,
),
)
}
}
}
}
}
/**
* The chip's own due line — bare "14:00" when [suppressDayLabel] (the due-bucket
* header above already says "Today"/"Tomorrow", Calendar-agenda style: the day
* is the group, the chip only needs the time), otherwise "Today · 14:00" /
* "Tomorrow", or the compact date for anything else. `null` without a due date,
* or for an all-day task once its day is already said by the header.
*/
internal fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean, suppressDayLabel: Boolean): String? {
val due = row.due ?: return null val due = row.due ?: return null
val relative = when (row.dueKind) { val relative = when (row.dueKind) {
DueKind.TODAY -> context.getString(R.string.home_due_today) DueKind.TODAY -> context.getString(R.string.home_due_today)
DueKind.TOMORROW -> context.getString(R.string.home_due_tomorrow) DueKind.TOMORROW -> context.getString(R.string.home_due_tomorrow)
else -> null else -> null
} ?: return due.formatDateTimeCompact(row.isAllDay, use24Hour) } ?: return due.formatDateTimeCompact(row.isAllDay, use24Hour)
return if (row.isAllDay) relative else "$relative · ${due.formatTime(use24Hour)}" if (row.isAllDay) return if (suppressDayLabel) null else relative
val time = due.formatTime(use24Hour)
return if (suppressDayLabel) time else "$relative · $time"
} }
@@ -1,18 +1,24 @@
package de.jeanlucmakiola.agendula.widget package de.jeanlucmakiola.agendula.widget
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -22,6 +28,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
@@ -40,17 +48,50 @@ import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** Which widget provider a configure session is for — decides which sections [WidgetFilterPicker] shows. */
internal enum class WidgetKind { TASKS, TODAY, UP_NEXT, LISTS, WEEK }
/** Reads [appWidgetId]'s provider class from the host, since that's the only thing naming which widget this is. */
private fun widgetKindOf(context: Context, appWidgetId: Int): WidgetKind? {
val provider = AppWidgetManager.getInstance(context).getAppWidgetInfo(appWidgetId)?.provider ?: return null
return when (provider.className) {
TaskWidgetReceiver::class.java.name -> WidgetKind.TASKS
TodayWidgetReceiver::class.java.name -> WidgetKind.TODAY
UpNextWidgetReceiver::class.java.name -> WidgetKind.UP_NEXT
ListsWidgetReceiver::class.java.name -> WidgetKind.LISTS
WeekWidgetReceiver::class.java.name -> WidgetKind.WEEK
else -> null
}
}
private fun widgetFor(kind: WidgetKind): GlanceAppWidget = when (kind) {
WidgetKind.TASKS -> TaskWidget()
WidgetKind.TODAY -> TodayWidget()
WidgetKind.UP_NEXT -> UpNextWidget()
WidgetKind.LISTS -> ListsWidget()
WidgetKind.WEEK -> WeekWidget()
}
/** /**
* Picks what one widget instance shows: a smart list or a real list. Runs when * Picks what one widget instance shows. [WidgetKind.TASKS] picks a smart
* the widget is placed (below API 31) and on "reconfigure" from the launcher. * list or one or more real lists (with an optional "group by list" sort);
* [WidgetKind.LISTS] picks which lists get a tile; [WidgetKind.UP_NEXT] can be
* narrowed to one list; every other kind only
* offers background and theme, since they're always the smart-list scope.
* Runs when the widget is placed (below API 31) and on "reconfigure" from
* the launcher.
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class TaskWidgetConfigActivity : ComponentActivity() { class TaskWidgetConfigActivity : ComponentActivity() {
@@ -68,6 +109,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
finish() finish()
return return
} }
val kind = widgetKindOf(this, appWidgetId) ?: WidgetKind.TASKS
enableEdgeToEdge() enableEdgeToEdge()
val glanceId = GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) val glanceId = GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId)
val listsFlow = runCatching { repository.taskLists() }.getOrDefault(emptyFlow()) val listsFlow = runCatching { repository.taskLists() }.getOrDefault(emptyFlow())
@@ -77,10 +119,19 @@ class TaskWidgetConfigActivity : ComponentActivity() {
val settings by settingsPrefs.settings.collectAsState(initial = Settings()) val settings by settingsPrefs.settings.collectAsState(initial = Settings())
val lists by remember { listsFlow }.collectAsState(initial = emptyList()) val lists by remember { listsFlow }.collectAsState(initial = emptyList())
var current by remember { mutableStateOf<TaskFilter?>(null) } var current by remember { mutableStateOf<TaskFilter?>(null) }
var groupByList by remember { mutableStateOf(false) }
var background by remember { mutableStateOf(WidgetBackground.SOLID) }
var themeOverride by remember { mutableStateOf<WidgetThemeMode?>(null) }
var rowColor by remember { mutableStateOf(WidgetRowColor.NEUTRAL) }
var selectedListIds by remember { mutableStateOf<Set<Long>?>(null) }
LaunchedEffect(glanceId) { LaunchedEffect(glanceId) {
current = parseWidgetFilter( val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)[WIDGET_FILTER_KEY], current = parseWidgetFilter(state[WIDGET_FILTER_KEY])
) groupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
background = parseWidgetBackground(state[WIDGET_BACKGROUND_KEY], kind)
themeOverride = parseWidgetThemeOverride(state[WIDGET_THEME_KEY])
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
} }
val dark = when (settings.themeMode) { val dark = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme() ThemeMode.SYSTEM -> isSystemInDarkTheme()
@@ -90,15 +141,40 @@ class TaskWidgetConfigActivity : ComponentActivity() {
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) { AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
WidgetFilterPicker( WidgetFilterPicker(
kind = kind,
lists = lists.filter { it.isVisible }, lists = lists.filter { it.isVisible },
dark = dark, dark = dark,
selected = current ?: DEFAULT_WIDGET_FILTER, selected = current ?: DEFAULT_WIDGET_FILTER,
onSelect = { filter -> groupByList = groupByList,
onGroupByListChange = { groupByList = it },
background = background,
onBackgroundChange = { background = it },
themeOverride = themeOverride,
onThemeOverrideChange = { themeOverride = it },
rowColor = rowColor,
onRowColorChange = { rowColor = it },
selectedListIds = selectedListIds,
onSelectedListIdsChange = { selectedListIds = it },
onSave = { filter ->
lifecycleScope.launch { lifecycleScope.launch {
updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) { updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) {
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue() when (kind) {
WidgetKind.TASKS -> {
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
it[WIDGET_GROUP_BY_LIST_KEY] = groupByList
it[WIDGET_ROW_COLOR_KEY] = rowColor.name
}
WidgetKind.LISTS, WidgetKind.UP_NEXT -> {
selectedListIds?.let { ids -> it[WIDGET_LIST_IDS_KEY] = ids.widgetListIdsStorageValue() }
?: it.remove(WIDGET_LIST_IDS_KEY)
}
WidgetKind.TODAY, WidgetKind.WEEK -> Unit
}
it[WIDGET_BACKGROUND_KEY] = background.name
themeOverride?.let { mode -> it[WIDGET_THEME_KEY] = mode.name }
?: it.remove(WIDGET_THEME_KEY)
} }
TaskWidget().update(this@TaskWidgetConfigActivity, glanceId) widgetFor(kind).update(this@TaskWidgetConfigActivity, glanceId)
setResult(RESULT_OK, resultIntent(appWidgetId)) setResult(RESULT_OK, resultIntent(appWidgetId))
finish() finish()
} }
@@ -115,40 +191,202 @@ class TaskWidgetConfigActivity : ComponentActivity() {
@Composable @Composable
private fun WidgetFilterPicker( private fun WidgetFilterPicker(
kind: WidgetKind,
lists: List<TaskList>, lists: List<TaskList>,
selected: TaskFilter, selected: TaskFilter,
dark: Boolean, dark: Boolean,
onSelect: (TaskFilter) -> Unit, groupByList: Boolean,
onGroupByListChange: (Boolean) -> Unit,
background: WidgetBackground,
onBackgroundChange: (WidgetBackground) -> Unit,
themeOverride: WidgetThemeMode?,
onThemeOverrideChange: (WidgetThemeMode?) -> Unit,
rowColor: WidgetRowColor,
onRowColorChange: (WidgetRowColor) -> Unit,
selectedListIds: Set<Long>?,
onSelectedListIdsChange: (Set<Long>?) -> Unit,
onSave: (TaskFilter) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
FullScreenPicker(title = stringResource(R.string.widget_config_title), onDismiss = onDismiss) { var showBackgroundPicker by remember { mutableStateOf(false) }
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists)) var showThemePicker by remember { mutableStateOf(false) }
WIDGET_SMART_LISTS.forEachIndexed { index, smart -> var showRowColorPicker by remember { mutableStateOf(false) }
val filter = TaskFilter.Smart(smart) // Smart lists and real lists are mutually exclusive; only real lists support
val isSelected = filter == selected // picking more than one. `selectedLists` empty + `selectedSmart` non-null is
GroupedRow( // a smart-list pick; the reverse (or both empty, before the stored filter
title = stringResource(smartListLabel(smart)), // loads) is a list pick.
position = positionOf(index, WIDGET_SMART_LISTS.size), var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
selected = isSelected, var selectedLists by remember(selected) {
trailing = if (isSelected) ({ SelectedCheck() }) else null, mutableStateOf(
onClick = { onSelect(filter) }, when (selected) {
) is TaskFilter.OfList -> setOf(selected.listId)
} is TaskFilter.OfLists -> selected.listIds
if (lists.isNotEmpty()) { is TaskFilter.Smart -> emptySet()
},
)
}
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || selectedLists.isNotEmpty()
FullScreenPicker(
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
onDismiss = onDismiss,
actions = {
TextButton(
enabled = canSave,
onClick = {
// The toggle row only shows (and so can only be turned off) with
// more than one list selected; narrowing back down to one, or to
// a smart list, has to clear it here instead; otherwise it's
// stuck on with no way left to see or undo it.
if (selectedLists.size <= 1 && groupByList) onGroupByListChange(false)
selectedSmart?.let { onSave(TaskFilter.Smart(it)) }
?: onSave(TaskFilter.OfLists(selectedLists))
},
) { Text(stringResource(android.R.string.ok)) }
},
) {
if (kind == WidgetKind.TASKS) {
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
val isSelected = smart == selectedSmart
GroupedRow(
title = stringResource(smartListLabel(smart)),
position = positionOf(index, WIDGET_SMART_LISTS.size),
selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { selectedSmart = smart; selectedLists = emptySet() },
)
}
if (lists.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.lists_header))
lists.forEachIndexed { index, list ->
val isSelected = list.id in selectedLists
GroupedRow(
title = list.name,
position = positionOf(index, lists.size),
selected = isSelected,
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = {
selectedSmart = null
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
},
)
}
if (selectedLists.size > 1) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.widget_config_group_by_list),
summary = stringResource(R.string.widget_config_group_by_list_hint),
position = Position.Alone,
trailing = { Switch(checked = groupByList, onCheckedChange = onGroupByListChange) },
onClick = { onGroupByListChange(!groupByList) },
)
}
}
Spacer(Modifier.height(24.dp))
} else if (kind == WidgetKind.LISTS && lists.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.lists_header)) GroupedSectionHeader(stringResource(R.string.lists_header))
lists.forEachIndexed { index, list -> lists.forEachIndexed { index, list ->
val filter = TaskFilter.OfList(list.id) // `null` means "every list" (the default) until the user narrows it down.
val isSelected = filter == selected val isSelected = selectedListIds?.let { list.id in it } ?: true
GroupedRow( GroupedRow(
title = list.name, title = list.name,
position = positionOf(index, lists.size), position = positionOf(index, lists.size),
selected = isSelected, selected = isSelected,
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) }, leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
trailing = if (isSelected) ({ SelectedCheck() }) else null, trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onSelect(filter) }, onClick = {
val current = selectedListIds ?: lists.map { it.id }.toSet()
onSelectedListIdsChange(if (isSelected) current - list.id else current + list.id)
},
) )
} }
Spacer(Modifier.height(24.dp))
} else if (kind == WidgetKind.UP_NEXT && lists.isNotEmpty()) {
// Single choice: `null` is every list, otherwise a one-element set.
val chosen = selectedListIds?.singleOrNull()
GroupedSectionHeader(stringResource(R.string.lists_header))
GroupedRow(
title = stringResource(R.string.widget_up_next_any_list),
position = positionOf(0, lists.size + 1),
selected = chosen == null,
trailing = if (chosen == null) ({ SelectedCheck() }) else null,
onClick = { onSelectedListIdsChange(null) },
)
lists.forEachIndexed { index, list ->
val isSelected = list.id == chosen
GroupedRow(
title = list.name,
position = positionOf(index + 1, lists.size + 1),
selected = isSelected,
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onSelectedListIdsChange(setOf(list.id)) },
)
}
Spacer(Modifier.height(24.dp))
} }
GroupedRow(
title = stringResource(R.string.widget_config_background),
summary = stringResource(backgroundLabel(background)),
position = Position.Top,
onClick = { showBackgroundPicker = true },
)
if (kind == WidgetKind.TASKS) {
GroupedRow(
title = stringResource(R.string.widget_config_theme),
summary = stringResource(themeOverrideLabel(themeOverride)),
position = Position.Middle,
onClick = { showThemePicker = true },
)
GroupedRow(
title = stringResource(R.string.widget_config_row_color),
summary = stringResource(rowColorLabel(rowColor)),
position = Position.Bottom,
onClick = { showRowColorPicker = true },
)
} else {
GroupedRow(
title = stringResource(R.string.widget_config_theme),
summary = stringResource(themeOverrideLabel(themeOverride)),
position = Position.Bottom,
onClick = { showThemePicker = true },
)
}
}
if (showBackgroundPicker) {
OptionPicker(
title = stringResource(R.string.widget_config_background),
options = kind.backgrounds(),
selected = background,
label = { stringResource(backgroundLabel(it)) },
summary = { stringResource(backgroundHint(it)) },
onSelect = onBackgroundChange,
onDismiss = { showBackgroundPicker = false },
)
}
if (showThemePicker) {
OptionPicker(
title = stringResource(R.string.widget_config_theme),
options = listOf<WidgetThemeMode?>(null, WidgetThemeMode.LIGHT, WidgetThemeMode.DARK),
selected = themeOverride,
label = { stringResource(themeOverrideLabel(it)) },
onSelect = onThemeOverrideChange,
onDismiss = { showThemePicker = false },
)
}
if (showRowColorPicker) {
OptionPicker(
title = stringResource(R.string.widget_config_row_color),
options = WidgetRowColor.entries,
selected = rowColor,
label = { stringResource(rowColorLabel(it)) },
summary = { stringResource(rowColorHint(it)) },
onSelect = onRowColorChange,
onDismiss = { showRowColorPicker = false },
)
} }
} }
@@ -158,3 +396,32 @@ internal fun smartListLabel(list: SmartList): Int = when (list) {
SmartList.OVERDUE -> R.string.smart_overdue SmartList.OVERDUE -> R.string.smart_overdue
else -> R.string.smart_all else -> R.string.smart_all
} }
private fun backgroundLabel(background: WidgetBackground): Int = when (background) {
WidgetBackground.SOLID -> R.string.widget_config_background_solid
WidgetBackground.TINTED -> R.string.widget_config_background_tinted
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
}
private fun backgroundHint(background: WidgetBackground): Int = when (background) {
WidgetBackground.SOLID -> R.string.widget_config_background_solid_hint
WidgetBackground.TINTED -> R.string.widget_config_background_tinted_hint
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent_hint
}
private fun themeOverrideLabel(mode: WidgetThemeMode?): Int = when (mode) {
null -> R.string.widget_config_theme_follow_app
WidgetThemeMode.LIGHT -> R.string.widget_config_theme_light
WidgetThemeMode.DARK -> R.string.widget_config_theme_dark
WidgetThemeMode.SYSTEM -> R.string.widget_config_theme_follow_app
}
private fun rowColorLabel(rowColor: WidgetRowColor): Int = when (rowColor) {
WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral
WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list
}
private fun rowColorHint(rowColor: WidgetRowColor): Int = when (rowColor) {
WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral_hint
WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list_hint
}
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
import android.content.Context import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.floret.glance.WidgetRollover
/** Host-facing receiver for [TaskWidget]; also keeps the midnight rollover armed. */ /** Host-facing receiver for [TaskWidget]; also keeps the midnight rollover armed. */
class TaskWidgetReceiver : GlanceAppWidgetReceiver() { class TaskWidgetReceiver : GlanceAppWidgetReceiver() {
@@ -11,17 +12,17 @@ class TaskWidgetReceiver : GlanceAppWidgetReceiver() {
override fun onEnabled(context: Context) { override fun onEnabled(context: Context) {
super.onEnabled(context) super.onEnabled(context)
WidgetRolloverScheduler.sync(context) WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
} }
override fun onDisabled(context: Context) { override fun onDisabled(context: Context) {
super.onDisabled(context) super.onDisabled(context)
WidgetRolloverScheduler.sync(context) WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
} }
/** Self-heal on the system's `updatePeriodMillis` wake-up, should the alarm have been dropped. */ /** Self-heal on the system's `updatePeriodMillis` wake-up, should the alarm have been dropped. */
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds) super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context) WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
} }
} }

Some files were not shown because too many files have changed in this diff Show More