Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89775e44a7 | ||
|
|
c0eb03e5df | ||
|
|
7bfda26de9 | ||
|
|
f65aed4fe2 | ||
|
|
84bb1116ec | ||
|
|
7fde7ae290 | ||
|
|
5d273e05c7 | ||
|
|
59582b3641 | ||
|
|
3540d2fb7c | ||
|
|
3812e950ca | ||
|
|
c6d62c36f4 | ||
|
|
a4fbbf43c6 | ||
|
|
d81e99084e | ||
|
|
9a87837917 | ||
|
|
9630bd104d | ||
|
|
7c3b62eb0d |
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
+6
-36
@@ -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
|
||||||
+4
-6
@@ -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"
|
||||||
+5
-1
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user