diff --git a/.forgejo/PULL_REQUEST_TEMPLATE.md b/.forgejo/PULL_REQUEST_TEMPLATE.md index 94555d0..de65dca 100644 --- a/.forgejo/PULL_REQUEST_TEMPLATE.md +++ b/.forgejo/PULL_REQUEST_TEMPLATE.md @@ -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 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 --> ### 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/` - [ ] Provider details stay inside `data/tasks/` - [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine) diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index 6e85308..6e82c5f 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -127,10 +127,12 @@ jobs: - name: Setup Java 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 with: - distribution: 'zulu' - java-version: '17' + distribution: 'jetbrains' + java-version: '21' # Fully qualified on purpose. Codeberg resolves bare `uses:` refs against # 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 # 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' - run: ./gradlew lintDebug + run: ./gradlew lintFullDebug lintOfflineDebug # :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 # the whole safety argument in dav/PROVENANCE.md. - name: Unit tests 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' run: ./gradlew assembleDebug diff --git a/.forgejo/workflows/translations.yaml b/.forgejo/workflows/translations.yaml index c621258..2fb81d0 100644 --- a/.forgejo/workflows/translations.yaml +++ b/.forgejo/workflows/translations.yaml @@ -2,7 +2,7 @@ name: Translations # Fast, SDK-free parity check for translation resources, so Weblate PRs (which # 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" # status is always reported — like the `ci` job. A path-filtered workflow is diff --git a/.gitea/workflows/beta.yaml b/.gitea/workflows/beta.yaml index f4523f9..65ca61e 100644 --- a/.gitea/workflows/beta.yaml +++ b/.gitea/workflows/beta.yaml @@ -70,10 +70,12 @@ jobs: submodules: recursive - 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 with: - distribution: 'zulu' - java-version: '17' + distribution: 'jetbrains' + java-version: '21' - name: Setup Android SDK uses: android-actions/setup-android@v3 @@ -126,7 +128,7 @@ jobs: grep -E 'versionName|versionCode' app/build.gradle.kts - name: Unit tests - run: ./gradlew testDebugUnitTest + run: ./gradlew testFullDebugUnitTest # 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. @@ -145,7 +147,8 @@ jobs: storeFile=upload-keystore.jks EOF - - name: Build release APK + # Both flavors; the offline one goes to Codeberg only (issue #39). + - name: Build release APKs run: ./gradlew assembleRelease # 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 cat 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 # The point of the whole workflow, so NOT continue-on-error. @@ -172,5 +176,6 @@ jobs: run: | set -e 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 diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 0062fb8..216c35f 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -120,10 +120,12 @@ jobs: submodules: recursive - 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 with: - distribution: 'zulu' - java-version: '17' + distribution: 'jetbrains' + java-version: '21' - name: Setup Android SDK uses: android-actions/setup-android@v3 @@ -182,7 +184,7 @@ jobs: # Test the exact commit being shipped (only on a real release). - name: Unit tests if: env.IS_RELEASE == 'true' - run: ./gradlew testDebugUnitTest + run: ./gradlew testFullDebugUnitTest - name: Setup Android Keystore if: env.IS_RELEASE == 'true' @@ -200,7 +202,9 @@ jobs: storeFile=upload-keystore.jks 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' run: ./gradlew assembleRelease @@ -256,7 +260,7 @@ jobs: run: | set -e 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 # fastlane tree (committed at release-cut time for the official repo; this @@ -318,7 +322,8 @@ jobs: set -e bash scripts/release_notes.sh "$VERSION" > 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 # Publish the release on Codeberg, which is canonical for tags and @@ -341,7 +346,8 @@ jobs: set -e [ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > 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 # Play takes an App Bundle, not the APK: a second artifact from the same @@ -356,7 +362,7 @@ jobs: - name: Build release AAB if: env.IS_RELEASE == 'true' continue-on-error: true - run: ./gradlew bundleRelease + run: ./gradlew bundleFullRelease # NOT actions/upload-artifact@v4: its client refuses any non-github.com # 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 with: 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 retention-days: 14 @@ -462,7 +468,7 @@ jobs: run: | set -euo pipefail # 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; } bundle exec fastlane deploy \ aab:"$AAB" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index e66eebf..afb1971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [1.1.0] - 2026-10-10 + +### 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). +- Tags. Add them while editing a task, see them on the task and in lists, + browse them from the home screen and filter any list by them. They are saved + as `CATEGORIES`, so they sync with Nextcloud Tasks, tasks.org and DAVx⁵ (#35). +- Filters. Save your own view of the open tasks by due date (overdue, today, + upcoming, no date, in any combination), lists, tags and minimum priority. + They live on the home screen and can be shown in the Tasks widget, so today + and overdue tasks can now share one widget (#47). + +### 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. +- The sort button in task lists now opens "Sort and filter", which can also + filter by tag. +- While editing a task, its title moves into the top bar once you scroll past + it. + +### Fixed +- The list editor's Where and smart-lists rows no longer have double padding. +- A field you tap now scrolls fully above the keyboard, and the "add subtask" + row in task lists no longer ends up hidden behind it. +- All-day tasks west of UTC no longer show up a day early in Overdue, Today + and Upcoming. + ## [1.0.0] - 2026-09-21 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68700b3..d855c0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,9 +28,9 @@ layer. ## Build, test, lint ```sh -./gradlew :app:assembleDebug # build the debug APK -./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine) -./gradlew lintDebug # Android lint (CI runs this on every PR) +./gradlew :app:assembleDebug # build the debug APKs (full + offline) +./gradlew :app:testFullDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine) +./gradlew lintFullDebug lintOfflineDebug # Android lint (CI runs this on every PR) ``` CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check, diff --git a/README.md b/README.md index 2756ed3..5a72dda 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,16 @@ back to a full comparison where it does not. Everything the store does not model is kept verbatim and sent back unchanged, so passing your tasks through Agendula does not quietly lose fields another client wrote. +### Tested With + +The following CalDAV Services have been Tested + + + +| Service | Version | Date | Status | User | +|---|---|---|---|---| +| **Nextcloud** | 25.0.0 | Oct 9th 2026 | ✅ | [@jlmakiola](https://codeberg.org/jlmakiola) [@Schanbro](https://codeberg.org/Schanbro) + ## Where your tasks live | | Where | Sync | Needs | @@ -119,9 +129,21 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository serves. For automatic updates from there, use **[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)**. +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 them, switch on *Include prereleases* for Agendula in Obtainium. +### Offline version + +Each Codeberg release also carries `agendula-offline_v.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 ```sh diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 578ace0..248701d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.api.artifact.SingleArtifact import java.util.Properties 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.1.0 -> 1010099), and CI fails if the committed one doesn't match. // See docs/RELEASING.md. - versionCode = 10000 - versionName = "1.0.0" + versionCode = 1010099 + versionName = "1.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -46,6 +47,20 @@ android { 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 { if (keystorePropertiesFile.exists()) { create("release") { @@ -208,26 +223,26 @@ dependencies { // @HiltWorker plumbing. implementation(libs.androidx.work.runtime.ktx) // 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) ksp(libs.androidx.hilt.compiler) // 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. implementation(libs.dmfs.lib.recur) // Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md. - implementation(project(":dav")) + "fullImplementation"(project(":dav")) // 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 // 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 // isReturnDefaultValues — so anything touching XmlUtils would NPE without a // real implementation here. - testImplementation(libs.xpp3) + "testFullImplementation"(libs.xpp3) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) @@ -246,6 +261,7 @@ dependencies { implementation(libs.floret.core.crash) implementation(libs.floret.identity) implementation(libs.floret.components) + implementation(libs.floret.glance) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) @@ -265,3 +281,34 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) 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("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) } + } +} diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/2.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/2.json new file mode 100644 index 0000000..0d38395 --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/2.json @@ -0,0 +1,529 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "14b159c51416ba27653f6320ac7f6f03", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principalUrl", + "columnName": "principal_url", + "affinity": "TEXT" + }, + { + "fieldPath": "homeSetUrl", + "columnName": "home_set_url", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncError", + "columnName": "last_sync_error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "isVisible", + "columnName": "is_visible", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "isSynced", + "columnName": "is_synced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "ctag", + "columnName": "ctag", + "affinity": "TEXT" + }, + { + "fieldPath": "syncToken", + "columnName": "sync_token", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_lists_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "list_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "percentComplete", + "columnName": "percent_complete", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAt", + "columnName": "completed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "classification", + "columnName": "classification", + "affinity": "INTEGER" + }, + { + "fieldPath": "dtstart", + "columnName": "dtstart", + "affinity": "INTEGER" + }, + { + "fieldPath": "due", + "columnName": "due", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT" + }, + { + "fieldPath": "isAllDay", + "columnName": "is_all_day", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT" + }, + { + "fieldPath": "rrule", + "columnName": "rrule", + "affinity": "TEXT" + }, + { + "fieldPath": "rdate", + "columnName": "rdate", + "affinity": "TEXT" + }, + { + "fieldPath": "exdate", + "columnName": "exdate", + "affinity": "TEXT" + }, + { + "fieldPath": "recurrenceId", + "columnName": "recurrence_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "masterId", + "columnName": "master_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "unknownProperties", + "columnName": "unknown_properties", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_list_id_is_deleted", + "unique": false, + "columnNames": [ + "list_id", + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)" + }, + { + "name": "index_tasks_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_tasks_master_id_recurrence_id", + "unique": false, + "columnNames": [ + "master_id", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)" + }, + { + "name": "index_tasks_is_dirty", + "unique": false, + "columnNames": [ + "is_dirty" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)" + }, + { + "name": "index_tasks_list_id_uid_recurrence_id", + "unique": true, + "columnNames": [ + "list_id", + "uid", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)" + } + ], + "foreignKeys": [ + { + "table": "task_lists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "list_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "master_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "parent_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "task_alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesBefore", + "columnName": "minutes_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reference", + "columnName": "reference", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DUE'" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_alarms_task_id", + "unique": false, + "columnNames": [ + "task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '14b159c51416ba27653f6320ac7f6f03')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/assets/tasks-v23.db b/app/src/androidTest/assets/tasks-v23.db index e305e90..d4e5b53 100644 Binary files a/app/src/androidTest/assets/tasks-v23.db and b/app/src/androidTest/assets/tasks-v23.db differ diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt index c040421..36d23a2 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt @@ -212,6 +212,15 @@ class OneShotImportTest { assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands") } + @Test + fun importsCategoryPropertiesAsTags() { + importer.importFrom(fixture()) + val tasks = taskRows() + + assertThat(tasks.getValue("Buy milk").categories).containsExactly("Errands") + assertThat(tasks.getValue("Ship the release").categories).isEmpty() + } + // --- running it ----------------------------------------------------------- @Test diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt index cf4b272..ca0d824 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt @@ -8,27 +8,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -/** - * The migration harness, proven against the committed schema in `app/schemas/`. - * - * There is one schema version today, so all there is to assert is that the helper - * can build v1 from the exported JSON, seed it, and validate it back — i.e. the - * export, the assets wiring and the identity hash all line up. That is the point: - * the first real migration only has to add its own case. - * - * **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s - * `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the - * database, and add a test here shaped like this: - * - * ``` - * helper.createDatabase(TEST_DB, 1).use { db -> - * db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)") - * } - * helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db -> - * // read the seeded rows back — validation proves the shape, not the data - * } - * ``` - */ +/** Migrations, proven against the committed schemas in `app/schemas/`. */ @RunWith(AndroidJUnit4::class) class TasksDatabaseMigrationTest { @@ -62,6 +42,39 @@ class TasksDatabaseMigrationTest { } } + @Test + fun liftsCategoriesOutOfTheResidueWithoutDirtyingTheTask() { + helper.createDatabase(TEST_DB, 1).use { db -> + db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Work', 0)") + db.execSQL( + "INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)", + arrayOf(1, "uid-1", "Claimed", "X-FOO:bar\r\nCATEGORIES:Work,Home\r\n"), + ) + db.execSQL( + "INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)", + arrayOf(2, "uid-2", "Split", "CATEGORIES:a\r\nCATEGORIES:b\r\n"), + ) + db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (3, 1, 'uid-3', 'Untagged')") + } + + helper.runMigrationsAndValidate(TEST_DB, 2, true, TasksDatabase.MIGRATION_1_2).use { db -> + db.query("SELECT categories, unknown_properties, is_dirty FROM tasks ORDER BY id").use { c -> + c.moveToNext() + assertThat(c.getString(0)).isEqualTo("Work,Home") + assertThat(c.getString(1)).doesNotContain("CATEGORIES") + assertThat(c.getString(1)).contains("X-FOO:bar") + assertThat(c.getInt(2)).isEqualTo(0) + + c.moveToNext() + assertThat(c.getString(0)).isEqualTo("a,b") + assertThat(c.getString(1)).isEqualTo("CATEGORIES:a\r\nCATEGORIES:b\r\n") + + c.moveToNext() + assertThat(c.getString(0)).isEmpty() + } + } + } + private companion object { const val TEST_DB = "migration-test.db" } diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt index 874fc6f..8bc1eb3 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt @@ -107,6 +107,17 @@ class ExternalImportTest { assertThat(importer.hasRun.first()).isTrue() } + @Test + fun copiesTagsCleanedUp() = runBlocking { + source.lists = listOf(list(7, "Errands")) + source.tasks = mapOf(7L to listOf(task(100, "Milk", tags = listOf("Shop", " Shop ", "", "Home")))) + + importer.run() + + assertThat(db.tasks().tasks(listId = null, includeCompleted = true).single().task.categories) + .containsExactly("Shop", "Home").inOrder() + } + /** Every list arrives device-only: the account belongs to the sync app. */ @Test fun importedListsAreDeviceOnly() = runBlocking { @@ -241,6 +252,7 @@ class ExternalImportTest { title: String, uid: String? = "uid-$id", parentId: Long? = null, + tags: List = emptyList(), ) = ExportTask( taskId = id, uid = uid, @@ -260,6 +272,7 @@ class ExternalImportTest { rrule = null, rdate = null, parentId = parentId, + tags = tags, ) private companion object { diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt b/app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt similarity index 100% rename from app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt rename to app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt b/app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt similarity index 100% rename from app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt rename to app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml new file mode 100644 index 0000000..d6a1f62 --- /dev/null +++ b/app/src/full/AndroidManifest.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt new file mode 100644 index 0000000..ac27212 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt @@ -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 by preferencesDataStore( + name = CREDENTIALS_DATASTORE, + corruptionHandler = replaceCorrupted(), +) + +/** See [SyncStateDataStore] for why this is a separate file. */ +private val Context.syncStateDataStore: DataStore 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 = + context.credentialsDataStore + + @Provides + @Singleton + @SyncStateDataStore + fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore = + 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, + syncTrigger: Provider, + pendingLoginFlows: Provider, + push: Provider, + ): 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() } + } +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncQualifiers.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncQualifiers.kt new file mode 100644 index 0000000..0da660d --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncQualifiers.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/QuarantineStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/QuarantineStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/QuarantineStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/QuarantineStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt similarity index 89% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt index ea0bfc4..3fb8f29 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.agendula.data.sync 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.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.StorageMode @@ -53,38 +54,7 @@ class RemoteListRepository @Inject constructor( private val resolver: ProviderResolver, private val push: PushStore, @IoDispatcher private val io: CoroutineDispatcher, -) { - - /** 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 - } +) : RemoteLists { /** * 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 * is looking at the picker. */ - suspend fun creatableAccounts(): List = withContext(io) { + override suspend fun creatableAccounts(): List = withContext(io) { // ⚠️ Empty in External mode, whatever the accounts table holds. The // lists on screen then come from a third-party provider, so a row // 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. */ - suspend fun create( + override suspend fun create( accountId: Long, name: String, 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 * 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 if (list.isReadOnly) return@withContext Outcome.ReadOnly 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 * 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 if (list.isReadOnly) return@withContext Outcome.ReadOnly val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAvailability.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncAvailability.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAvailability.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncAvailability.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncCadenceStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncCadenceStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncCadenceStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncCadenceStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt similarity index 96% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt index d10e7b5..64130e9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt @@ -14,6 +14,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.agendula.MainActivity +import de.jeanlucmakiola.agendula.data.di.ChannelRefresher import de.jeanlucmakiola.agendula.R import javax.inject.Inject import javax.inject.Singleton @@ -33,7 +34,7 @@ import javax.inject.Singleton @Singleton class SyncNoticeNotifier @Inject constructor( @ApplicationContext private val context: Context, -) { +) : ChannelRefresher { fun canPost(): Boolean { 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. */ 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() { 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. */ - fun refreshChannel() { + override fun refreshChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = context.getSystemService(NotificationManager::class.java) if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel() } 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 NOTIFICATION_ID = 2 private const val SIGN_IN_CHANNEL_ID = "account_sign_in" diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncOnEdit.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncOnEdit.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncOnEdit.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncOnEdit.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncPushWorker.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncPushWorker.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncPushWorker.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncPushWorker.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncReport.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncReport.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncReport.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncReport.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt similarity index 98% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt index fc796f2..9818c7b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt @@ -35,7 +35,11 @@ import kotlin.time.Duration.Companion.seconds @Singleton class SyncTrigger @Inject constructor( @ApplicationContext private val context: Context, -) { +) : SyncRequests { + + override fun syncNow(accountName: String) { + enqueue(accountName) + } /** * Starts a sync now. diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/AgendulaPushService.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/AgendulaPushService.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/AgendulaPushService.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/AgendulaPushService.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushDistributors.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushDistributors.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushDistributors.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushDistributors.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushMessageHandler.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushMessageHandler.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushMessageHandler.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushMessageHandler.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRenewalWorker.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRenewalWorker.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRenewalWorker.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRenewalWorker.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountMessage.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountMessage.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountMessage.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountMessage.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountParts.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountParts.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountParts.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountParts.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ListsStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ListsStep.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ListsStep.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ListsStep.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ProviderStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ProviderStep.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ProviderStep.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ProviderStep.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SetupStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/SetupStep.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SetupStep.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/SetupStep.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SignInStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/SignInStep.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SignInStep.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/SignInStep.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SummaryStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/SummaryStep.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SummaryStep.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/SummaryStep.kt diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt new file mode 100644 index 0000000..e26f6a9 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt @@ -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, + ) +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt new file mode 100644 index 0000000..e2bb836 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt @@ -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, + ) + } +} diff --git a/app/src/main/res/xml/authenticator.xml b/app/src/full/res/xml/authenticator.xml similarity index 100% rename from app/src/main/res/xml/authenticator.xml rename to app/src/full/res/xml/authenticator.xml diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/full/res/xml/network_security_config.xml similarity index 100% rename from app/src/main/res/xml/network_security_config.xml rename to app/src/full/res/xml/network_security_config.xml diff --git a/app/src/main/res/xml/sync_adapter.xml b/app/src/full/res/xml/sync_adapter.xml similarity index 100% rename from app/src/main/res/xml/sync_adapter.xml rename to app/src/full/res/xml/sync_adapter.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 997cb36..c2fc549 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,29 +16,6 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt index 544ccea..08bb3e4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt @@ -10,10 +10,10 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import dagger.hilt.components.SingletonComponent 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.ReminderScheduler 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.StartupGate 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) runCatching { entryPoint.taskNotifier().refreshChannel() - entryPoint.syncNoticeNotifier().refreshChannel() + entryPoint.channelRefreshers().forEach { it.refreshChannel() } } } @@ -120,7 +120,7 @@ class AgendulaApp : Application(), Configuration.Provider { fun applicationScope(): CoroutineScope fun databaseCheckpoint(): DatabaseCheckpoint fun taskNotifier(): TaskNotifier - fun syncNoticeNotifier(): SyncNoticeNotifier + fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher> fun taskWidgetUpdater(): TaskWidgetUpdater } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt index cb68472..1ac4cd6 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt @@ -25,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.agendula.data.di.LaunchHook -import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.ui.imports.importIntentUri 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.crash.CrashReportActivity 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.theme.AgendulaTheme import de.jeanlucmakiola.floret.crash.CrashReportDialog @@ -55,14 +50,6 @@ class MainActivity : ComponentActivity() { @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 // over the app on the next launch (the single-crash path). A startup // 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. if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) - // 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. - // - // ⚠️ 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. + // Each hook on its own, so one waiting on the network holds up no other. if (savedInstanceState == null) { - lifecycleScope.launch { - 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) } } } + launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } } } setContent { val settingsViewModel: SettingsViewModel = hiltViewModel() @@ -180,12 +142,18 @@ class MainActivity : ComponentActivity() { const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID" const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START" 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_TODAY = "de.jeanlucmakiola.agendula.action.TODAY" 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_FILTER = "de.jeanlucmakiola.agendula.action.OPEN_FILTER" + private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID" 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_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID" private const val SHARED_TITLE_LIMIT = 500 private const val NO_OCCURRENCE = -1L @@ -206,9 +174,14 @@ class MainActivity : ComponentActivity() { .putExtra(EXTRA_OPEN_ACCOUNTS, true) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - /** The launcher shortcut's and Quick Settings tile's "New task". */ - fun newTaskIntent(context: Context): Intent = - Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java) + /** + * The launcher shortcut's, Quick Settings tile's and a widget's "New + * 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) fun todayIntent(context: Context): Intent = @@ -227,19 +200,31 @@ class MainActivity : ComponentActivity() { .putExtra(EXTRA_LIST_ID, listId) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + /** Opens one saved filter. */ + fun filterIntent(context: Context, filterId: Long): Intent = + Intent(ACTION_OPEN_FILTER, "agendula://filter/$filterId".toUri(), context, MainActivity::class.java) + .putExtra(EXTRA_FILTER_ID, filterId) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + internal fun navRequestOf(intent: Intent?): NavRequest? { if (intent == null) return null - intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L } - ?.let { return NavRequest.OpenAccount(it) } - if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts + if (BuildConfig.SYNC_ENABLED) { + intent.getLongExtra(EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L } + ?.let { return NavRequest.OpenAccount(it) } + if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts + } 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_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST) ?.let { name -> SmartList.entries.firstOrNull { it.name == name } } ?.let { return NavRequest.OpenSmart(it) } ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L } ?.let { return NavRequest.OpenList(it) } + ACTION_OPEN_FILTER -> intent.getLongExtra(EXTRA_FILTER_ID, -1L).takeIf { it > 0L } + ?.let { return NavRequest.OpenFilter(it) } } importIntentUri(intent)?.let { return NavRequest.Import(it) } if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/ChannelRefresher.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/ChannelRefresher.kt new file mode 100644 index 0000000..e4125cc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/ChannelRefresher.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt index 38a327c..65a744b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt @@ -12,13 +12,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext 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.AndroidTasksDataSource 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.TasksRepository 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.TasksDatabase 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 * 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 by preferencesDataStore( name = "agendula_prefs", corruptionHandler = replaceCorrupted(), ) -/** See [CredentialsDataStore] for why this is a separate file. */ -private val Context.credentialsDataStore: DataStore by preferencesDataStore( - name = CREDENTIALS_DATASTORE, - corruptionHandler = replaceCorrupted(), -) - -/** See [SyncStateDataStore] for why this is a separate file. */ -private val Context.syncStateDataStore: DataStore 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 @InstallIn(SingletonComponent::class) abstract class DataBindModule { @@ -94,22 +62,6 @@ abstract class DataBindModule { @Singleton 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 // store itself, for the one caller that has to read it while another store is // the active one. @@ -128,18 +80,6 @@ object DataProvideModule { fun provideDataStore(@ApplicationContext context: Context): DataStore = context.agendulaDataStore - @Provides - @Singleton - @CredentialsDataStore - fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore = - context.credentialsDataStore - - @Provides - @Singleton - @SyncStateDataStore - fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore = - context.syncStateDataStore - @Provides @Singleton fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase = @@ -149,6 +89,7 @@ object DataProvideModule { // backed-up `.db` does not. The backup rules carry all three files and // the app checkpoints on ON_STOP. .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) + .addMigrations(TasksDatabase.MIGRATION_1_2) .build() /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/LaunchHook.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/LaunchHook.kt index 2fc4e52..c35bead 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/LaunchHook.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/LaunchHook.kt @@ -7,9 +7,8 @@ import dagger.hilt.components.SingletonComponent import dagger.multibindings.Multibinds /** - * Something a build variant wants to run when the app is launched with [Intent]. - * Release builds contribute none; the debug source set adds the demo seeder, so - * that class never ships. + * Something a build variant runs on a genuine app open (not on a configuration + * change): sync in the `full` flavor, the demo seeder in debug builds. */ fun interface LaunchHook { suspend fun onLaunch(intent: Intent) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt index 2ee0ee1..aa51f70 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt @@ -17,37 +17,6 @@ annotation class IoDispatcher @Retention(AnnotationRetention.BINARY) 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 * .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SavedFilterCodec.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SavedFilterCodec.kt new file mode 100644 index 0000000..74d16c5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SavedFilterCodec.kt @@ -0,0 +1,83 @@ +package de.jeanlucmakiola.agendula.data.prefs + +import de.jeanlucmakiola.agendula.domain.DueWindow +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.SavedFilter +import de.jeanlucmakiola.agendula.domain.TagMatch +import java.net.URLDecoder +import java.net.URLEncoder + +/** + * Stored form of the saved filters: one line per filter, + * `id|name|due|lists|tags|match|priority`, with name and tags URL-encoded and + * list ids qualified by the store they belong to (`OWN:3`), since each store + * numbers its lists on its own. Fixed once released — don't change without a + * data migration. + */ +internal object SavedFilterCodec { + + /** The filters in [raw], with only the list ids of [mode]'s store. */ + fun decode(raw: String?, mode: String): List = + lines(raw).mapNotNull { runCatching { decodeLine(it, mode) }.getOrNull() } + + /** [raw] with [filter] replacing the line of the same id, or appended. */ + fun upsert(raw: String?, filter: SavedFilter, mode: String): String { + val existing = lines(raw) + val index = existing.indexOfFirst { idOf(it) == filter.id } + // Keep the lists another store contributed, so switching stores and back + // doesn't widen a filter that was edited in between, unless "any list" + // was chosen here, which drops them on purpose. + val foreign = if (filter.anyList) { + emptyList() + } else { + existing.getOrNull(index)?.let(::fields)?.getOrNull(3) + ?.let(::splitList)?.filterNot { it.startsWith("$mode:") }.orEmpty() + } + val line = encodeLine(filter, mode, foreign) + val updated = if (index >= 0) existing.toMutableList().also { it[index] = line } else existing + line + return updated.joinToString("\n") + } + + fun remove(raw: String?, id: Long): String = lines(raw).filterNot { idOf(it) == id }.joinToString("\n") + + private fun lines(raw: String?): List = raw.orEmpty().split('\n').filter { it.isNotBlank() } + + private fun fields(line: String): List = line.split('|') + + private fun idOf(line: String): Long? = fields(line).firstOrNull()?.toLongOrNull() + + private fun splitList(value: String): List = value.split(',').filter { it.isNotEmpty() } + + private fun encodeLine(filter: SavedFilter, mode: String, foreignLists: List): String = + listOf( + filter.id.toString(), + encode(filter.name), + filter.due.sorted().joinToString(",") { it.name }, + (filter.listIds.sorted().map { "$mode:$it" } + foreignLists).joinToString(","), + filter.tags.sorted().joinToString(",") { encode(it) }, + filter.tagMatch.name, + filter.minPriority.name, + ).joinToString("|") + + private fun decodeLine(line: String, mode: String): SavedFilter? { + val f = fields(line) + if (f.size < 7) return null + val id = f[0].toLongOrNull() ?: return null + val lists = splitList(f[3]) + val here = lists.filter { it.startsWith("$mode:") }.mapNotNull { it.removePrefix("$mode:").toLongOrNull() }.toSet() + return SavedFilter( + id = id, + name = decode(f[1]), + due = splitList(f[2]).mapNotNull { runCatching { DueWindow.valueOf(it) }.getOrNull() }.toSet(), + listIds = here, + listsElsewhere = lists.any { !it.startsWith("$mode:") }, + tags = splitList(f[4]).map(::decode).toSet(), + tagMatch = runCatching { TagMatch.valueOf(f[5]) }.getOrDefault(TagMatch.ANY), + minPriority = runCatching { Priority.valueOf(f[6]) }.getOrDefault(Priority.NONE), + ) + } + + private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) + + private fun decode(value: String): String = URLDecoder.decode(value, Charsets.UTF_8.name()) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index 733745d..02a0c6c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -10,6 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.domain.SavedFilter import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFormField @@ -20,6 +21,7 @@ import de.jeanlucmakiola.floret.reminders.applyReminderOverride import de.jeanlucmakiola.floret.reminders.normalizeReminders import de.jeanlucmakiola.floret.reminders.reminderLeadsFor import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import java.time.DayOfWeek import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -95,6 +97,8 @@ data class Settings( val pushEnabled: Boolean = true, /** Lists of the current store whose tasks the smart lists (and their counts) leave out. */ val hiddenFromSmartLists: Set = 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. */ fun visibleIn(filter: TaskFilter, tasks: List): List = @@ -153,6 +157,7 @@ class SettingsPrefs @Inject constructor( .mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() } .toSet() }, + defaultStartToday = p[DEFAULT_START_TODAY] ?: false, ) } @@ -166,6 +171,8 @@ class SettingsPrefs @Inject constructor( 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 setWeekStart(day: DayOfWeek?) = dataStore.edit { @@ -254,6 +261,34 @@ class SettingsPrefs @Inject constructor( p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry } + /** The saved filters, oldest first, with the list ids of the current store. */ + val savedFilters: Flow> = + dataStore.data.map { p -> p[SAVED_FILTERS] to p[STORAGE_MODE] } + .distinctUntilChanged() + .map { (raw, mode) -> SavedFilterCodec.decode(raw, (storedMode(mode) ?: resolver.autoMode()).name) } + .distinctUntilChanged() + + /** One saved filter; `null` while it doesn't exist. */ + fun savedFilter(id: Long): Flow = + savedFilters.map { all -> all.firstOrNull { it.id == id } }.distinctUntilChanged() + + /** Store [filter]; an id of 0 makes a new one. Returns the filter's id. */ + suspend fun saveFilter(filter: SavedFilter): Long { + var id = filter.id + dataStore.edit { p -> + if (id <= 0) { + id = p[SAVED_FILTER_NEXT_ID] ?: 1L + p[SAVED_FILTER_NEXT_ID] = id + 1 + } + p[SAVED_FILTERS] = SavedFilterCodec.upsert(p[SAVED_FILTERS], filter.copy(id = id), modeOf(p).name) + } + return id + } + + suspend fun deleteFilter(id: Long) = dataStore.edit { p -> + p[SAVED_FILTERS] = SavedFilterCodec.remove(p[SAVED_FILTERS], id) + } + suspend fun setDefaultEditFields(fields: Set) = dataStore.edit { it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name } } @@ -282,6 +317,9 @@ class SettingsPrefs @Inject constructor( val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes") val PUSH_ENABLED = booleanPreferencesKey("push_enabled") val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden") + val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today") + val SAVED_FILTERS = stringPreferencesKey("saved_filters") + val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id") } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteLists.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteLists.kt new file mode 100644 index 0000000..aeb76b2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteLists.kt @@ -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 + + /** 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt new file mode 100644 index 0000000..a95da71 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt @@ -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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt index c317238..b87069c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import android.content.ContentProviderOperation import android.content.ContentResolver import android.content.ContentUris import android.content.ContentValues @@ -19,6 +20,7 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.export.ExportTask +import de.jeanlucmakiola.agendula.domain.normalizeTags import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec @@ -90,11 +92,14 @@ class AndroidTasksDataSource @Inject constructor( // `is_recurring` / `distance_from_current`). The by-name mapper reads // whatever is present and nulls the rest; the repository re-sorts, so we // don't depend on a provider sort column either. - return resolver.query(uri, null, selection, args, null) + val tasks = resolver.query(uri, null, selection, args, null) ?.use { c -> val reader = CursorColumnReader(c) buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) } } ?: emptyList() + if (tasks.isEmpty()) return tasks + val tags = categories(tasks.map { it.taskId }) + return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task } } override fun exportTasks(listId: Long): List { @@ -102,7 +107,7 @@ class AndroidTasksDataSource @Inject constructor( // table's shape varies across provider versions, and the by-name mapper // reads what's there. val uri = TasksContract.tasksUri(authority()) - return resolver.query( + val tasks = resolver.query( uri, null, // _deleted marks a row awaiting a sync round-trip. It's gone as far as @@ -115,21 +120,39 @@ class AndroidTasksDataSource @Inject constructor( val reader = CursorColumnReader(c) buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) } } ?: emptyList() + if (tasks.isEmpty()) return tasks + val tags = categories(tasks.map { it.taskId }) + return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task } } // --- writes --------------------------------------------------------------- override fun insertTask(form: TaskForm): Long { val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) - val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues()) - ?: throw TaskWriteFailedException("insert task") - return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id") + // One batch, so a refused tag leaves no task behind for a retry to duplicate. + val authority = authority() + val ops = arrayListOf( + ContentProviderOperation.newInsert(TasksContract.tasksUri(authority)) + .withValues(values.toContentValues()) + .build(), + ) + normalizeTags(form.tags).forEach { tag -> + ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority)) + .withValues(TaskWriteMapper.categoryValues(0, tag).toContentValues().apply { remove(Properties.TASK_ID) }) + .withValueBackReference(Properties.TASK_ID, 0) + .build() + } + val results = runCatching { resolver.applyBatch(authority, ops) } + .getOrElse { throw TaskWriteFailedException("insert task", it) } + return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull() + ?: throw TaskWriteFailedException("insert task: no id") } override fun updateTask(taskId: Long, form: TaskForm) { val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("update task $taskId") + setCategories(taskId, form.tags) } override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) { @@ -151,6 +174,7 @@ class AndroidTasksDataSource @Inject constructor( (Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due)) val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("update series $seriesId") + setCategories(seriesId, form.tags) } /** @@ -243,8 +267,28 @@ class AndroidTasksDataSource @Inject constructor( ?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence") val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id) val uri = TasksContract.instanceUri(authority(), instanceId) - val rows = resolver.update(uri, values.toContentValues(), null, null) - if (rows == 0) throw TaskWriteFailedException("update instance $instanceId") + // The fork copies the series' properties onto the override, so the tags + // go onto the series first and are put back once the override exists. + // Unchanged tags are inherited as they are, and the series is left alone. + val seriesTags = categories(listOf(taskId))[taskId].orEmpty() + val retag = normalizeTags(form.tags) != seriesTags + if (retag) setCategories(taskId, form.tags) + var failure: Throwable? = null + try { + val rows = resolver.update(uri, values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("update instance $instanceId") + } catch (e: Throwable) { + failure = e + throw e + } finally { + if (retag) { + try { + setCategories(taskId, seriesTags) + } catch (e: Throwable) { + failure?.addSuppressed(e) ?: throw e + } + } + } } /** @@ -348,6 +392,58 @@ class AndroidTasksDataSource @Inject constructor( } ?: emptyList() } + /** Tags by task id for [taskIds], in stored order. */ + private fun categories(taskIds: Collection): Map> { + val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk -> + val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})" + val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray() + resolver.query( + TasksContract.propertiesUri(authority()), + arrayOf(Properties.TASK_ID, TasksContract.Category.NAME), + selection, + args, + Properties.PROPERTY_ID, + )?.use { c -> + val reader = CursorColumnReader(c) + buildList { + while (c.moveToNext()) { + val id = reader.getLong(Properties.TASK_ID) ?: continue + val name = reader.getString(TasksContract.Category.NAME) ?: continue + add(id to name) + } + } + } ?: emptyList() + } + return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) } + } + + /** + * Replaces [taskId]'s category rows in one batch, so a refused insert cannot + * leave half the tags, and leaves them alone when nothing changed so the task + * isn't dirtied. + */ + private fun setCategories(taskId: Long, tags: List) { + val wanted = normalizeTags(tags) + if (categories(listOf(taskId))[taskId].orEmpty() == wanted) return + val authority = authority() + val uri = TasksContract.propertiesUri(authority) + val ops = arrayListOf( + ContentProviderOperation.newDelete(uri) + .withSelection( + "${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?", + arrayOf(taskId.toString(), TasksContract.Category.MIMETYPE), + ) + .build(), + ) + wanted.forEach { + ops += ContentProviderOperation.newInsert(uri) + .withValues(TaskWriteMapper.categoryValues(taskId, it).toContentValues()) + .build() + } + runCatching { resolver.applyBatch(authority, ops) } + .getOrElse { throw TaskWriteFailedException("set categories for task $taskId", it) } + } + override fun setCancelled(taskId: Long, cancelled: Boolean) { val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("cancel task $taskId") @@ -460,3 +556,6 @@ class AndroidTasksDataSource @Inject constructor( return cv } } + +/** SQLite's default bound-argument limit on older Android releases. */ +private const val MAX_SQL_ARGS = 999 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/Failures.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/Failures.kt index d34dae4..608bdc7 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/Failures.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/Failures.kt @@ -5,8 +5,8 @@ class ProviderUnavailableException : IllegalStateException("No tasks provider installed") /** A ContentResolver write returned no URI or affected no rows. */ -class TaskWriteFailedException(operation: String) : - RuntimeException("Task write failed: $operation") +class TaskWriteFailedException(operation: String, cause: Throwable? = null) : + RuntimeException("Task write failed: $operation", cause) /** * The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt index e608c59..7413e12 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt @@ -113,6 +113,12 @@ object TaskWriteMapper { Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE, ) + fun categoryValues(taskId: Long, name: String): Map = mapOf( + Properties.TASK_ID to taskId, + Properties.MIMETYPE to TasksContract.Category.MIMETYPE, + TasksContract.Category.NAME to name, + ) + /** The user-owned columns of a list — what an edit is allowed to change. */ fun listValues(name: String, color: Int): Map = mapOf( Lists.NAME to name.trim(), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt index d3712b1..75f68ee 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt @@ -154,6 +154,18 @@ object TasksContract { const val TYPE_MESSAGE = 1 } + /** + * A category property row — one tag. DAVx5 maps these to and from + * `CATEGORIES`. Inserting with only [NAME] is enough: the provider looks the + * category up in the task's account, or creates it, and fills in `data0`. + */ + object Category { + const val MIMETYPE = "vnd.android.cursor.item/category" + + /** `data1` — the tag as spelled. */ + const val NAME = "data1" + } + fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri() // --- status values (TaskColumns.STATUS_*) -------------------------------- diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt index 5519e76..b607cf2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import de.jeanlucmakiola.agendula.domain.SavedFilter import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskFilter @@ -13,6 +14,9 @@ import kotlin.time.Instant /** Whether Agendula can use the tasks provider right now. Drives onboarding. */ enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER } +/** Tasks for a filter; [savedFilter] is a saved filter's definition, `null` once it's deleted. */ +data class FilteredTasks(val tasks: List, val savedFilter: SavedFilter? = null) + /** * The single entry point the UI layer uses. Flows re-emit automatically when the * provider changes (our writes *and* external sync like DAVx5), via the @@ -27,7 +31,10 @@ interface TasksRepository { */ fun writableTaskLists(): Flow> = taskLists().map { lists -> lists.filter { it.acceptsWrites } } - fun tasks(filter: TaskFilter): Flow> + fun tasks(filter: TaskFilter): Flow> = filteredTasks(filter).map { it.tasks } + + /** [tasks] together with the definition behind a [TaskFilter.Saved]. */ + fun filteredTasks(filter: TaskFilter): Flow /** * The direct children of [parentId], re-emitting live like the other flows. diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index 584a112..1a519ce 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -1,7 +1,9 @@ package de.jeanlucmakiola.agendula.data.tasks import de.jeanlucmakiola.agendula.data.di.IoDispatcher +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.floret.time.DayWindow +import de.jeanlucmakiola.agendula.domain.SavedFilter import de.jeanlucmakiola.agendula.domain.SeriesCollapse import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail @@ -17,7 +19,10 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -32,12 +37,26 @@ class TasksRepositoryImpl @Inject constructor( private val dataSource: TasksDataSource, private val providerResolver: ProviderResolver, private val startupGate: StartupGate, + private val settingsPrefs: SettingsPrefs, @IoDispatcher private val io: CoroutineDispatcher, ) : TasksRepository { override fun taskLists(): Flow> = observing { dataSource.taskLists() } - override fun tasks(filter: TaskFilter): Flow> = observing { loadTasks(filter) } + @OptIn(ExperimentalCoroutinesApi::class) + override fun filteredTasks(filter: TaskFilter): Flow = when (filter) { + // A deleted filter shows nothing rather than failing the screen or widget on it. + is TaskFilter.Saved -> settingsPrefs.savedFilter(filter.id) + .flatMapLatest { saved -> + if (saved == null) flowOf(FilteredTasks(emptyList())) + else observing { + // A deleted list's id would otherwise leave the filter quietly empty. + val current = saved.withListsIn(dataSource.taskLists().mapTo(HashSet()) { it.id }) + FilteredTasks(loadTasks(filter, current), current) + } + } + else -> observing { FilteredTasks(loadTasks(filter)) } + } override fun subtasks(parentId: Long): Flow> = observing { SeriesCollapse.visible(dataSource.subtasks(parentId)) @@ -55,7 +74,7 @@ class TasksRepositoryImpl @Inject constructor( } } - private fun loadTasks(filter: TaskFilter): List { + private fun loadTasks(filter: TaskFilter, saved: SavedFilter? = null): List { val query = when (filter) { is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true) // Read completed tasks too, then let [TaskFiltering.matches] enforce each @@ -63,6 +82,11 @@ class TasksRepositoryImpl @Inject constructor( // parent's subtask-progress reflects ALL its children — completed ones // included — even on smart lists that hide completed tasks themselves. is TaskFilter.Smart -> TaskQuery(includeCompleted = true) + // 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) + is TaskFilter.Tagged -> TaskQuery(includeCompleted = true) + is TaskFilter.Saved -> TaskQuery(includeCompleted = true) } val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val all = SeriesCollapse.visible(dataSource.tasks(query)) @@ -74,7 +98,7 @@ class TasksRepositoryImpl @Inject constructor( (if (t.isCompleted) done + 1 else done) to (total + 1) } return all - .filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) } + .filter { TaskFiltering.matches(it, filter, todayStart, todayEnd, saved) } .map { task -> progress[task.taskId]?.let { (done, total) -> task.copy(subtaskDone = done, subtaskTotal = total) @@ -109,6 +133,7 @@ class TasksRepositoryImpl @Inject constructor( parentId = parentId, location = task.location, url = task.url, + tags = task.tags, ), ) val reminders = dataSource.reminders()[task.taskId].orEmpty() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt index 7461de0..084d84b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt @@ -9,6 +9,7 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser import de.jeanlucmakiola.agendula.domain.ical.ICalProperty import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import de.jeanlucmakiola.agendula.domain.normalizeTags import java.time.ZoneId import kotlin.time.Clock import kotlin.time.Instant @@ -81,6 +82,8 @@ object VTodoMapper { // Parameterised copies stay in the residue verbatim; authoring the // column beside them would emit the same exclusions twice. "RDATE", "EXDATE", + // Several lines, or one the column would not re-serialise exactly. + "CATEGORIES", ) /** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */ @@ -200,6 +203,9 @@ object VTodoMapper { CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 }, ) + val (categories, categoriesProperty) = readCategories(vtodo) + claim(categoriesProperty) + val sequenceProperty = vtodo.property("SEQUENCE") val sequence = take( sequenceProperty, @@ -225,6 +231,7 @@ object VTodoMapper { title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()), description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()), location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()), + categories = categories, // URI, not TEXT — escaping it would corrupt a query string. url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()), status = status, @@ -284,8 +291,9 @@ object VTodoMapper { now: Instant = Clock.System.now(), ): ICalComponent { val residue = parseResidue(entity.unknownProperties) + val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues)) val keptResidue = - residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) } + residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) } val suppressed = keptResidue .map { it.name.uppercase() } .filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE } @@ -307,6 +315,7 @@ object VTodoMapper { add("DESCRIPTION", entity.description?.let(ICalValues::escapeText)) add("LOCATION", entity.location?.let(ICalValues::escapeText)) add("URL", entity.url) + add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList)) add("STATUS", entity.status.toICalName()) add("PERCENT-COMPLETE", entity.percentComplete?.toString()) @@ -386,6 +395,7 @@ object VTodoMapper { private fun contradictsResidue( property: ICalProperty, residue: ICalComponent, + residueCategories: List, entity: TaskEntity, parentUid: String?, ): Boolean { @@ -433,6 +443,7 @@ object VTodoMapper { "PRIORITY" -> entity.priority != PRIORITY_NONE "PERCENT-COMPLETE" -> entity.percentComplete != null "CLASS" -> entity.classification != null + "CATEGORIES" -> entity.categories != residueCategories // Not SEQUENCE: it is the organiser's counter and never ours to bump, // so the column stays at its fallback and the residue always wins. else -> false @@ -475,6 +486,23 @@ object VTodoMapper { // -------------------------------------------------------------- helpers + /** + * Every `CATEGORIES` copy's values, merged, and the one line to claim — only + * when it is the sole, parameterless copy and the column re-serialises it + * byte for byte. Anything else is read for display and stays in the residue. + */ + private fun readCategories(vtodo: ICalComponent): Pair, ICalProperty?> { + val copies = vtodo.properties("CATEGORIES") + val values = normalizeTags(copies.flatMap(::categoryValues)) + val claimable = copies.singleOrNull()?.takeIf { + it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value + } + return values to claimable + } + + private fun categoryValues(property: ICalProperty): List = + normalizeTags(ICalValues.parseTextList(property.value)) + private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL") private class TimeRead( diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt index 9cb0429..d21b9ee 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt @@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE +import de.jeanlucmakiola.agendula.domain.normalizeTags import de.jeanlucmakiola.agendula.domain.statusFromInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -221,12 +222,17 @@ class OneShotImport @Inject constructor( } val alarms = mutableListOf() - db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null) + val categories = mutableMapOf>() + db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties ORDER BY property_id", null) .use { cursor -> val r = CursorColumnReader(cursor) while (cursor.moveToNext()) { - if (r.getString("mimetype") != ALARM_MIMETYPE) continue val taskId = r.getLong("task_id") ?: continue + if (r.getString("mimetype") == CATEGORY_MIMETYPE) { + r.getString("data1")?.let { categories.getOrPut(taskId, ::mutableListOf) += it } + continue + } + if (r.getString("mimetype") != ALARM_MIMETYPE) continue val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue alarms += LegacyAlarm( taskId = taskId, @@ -241,7 +247,7 @@ class OneShotImport @Inject constructor( } } - return LegacySnapshot(lists, rows, alarms) + return LegacySnapshot(lists, rows, alarms, categories.mapValues { normalizeTags(it.value) }) } // --- writing into Room ---------------------------------------------------- @@ -276,7 +282,12 @@ class OneShotImport @Inject constructor( // the unique index; a fresh uid costs the row nothing it still has. val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) } ?: UUID.randomUUID().toString() - val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId) + val entity = row.entity.copy( + listId = listId, + uid = uid, + recurrenceId = recurrenceId, + categories = snapshot.categories[row.id].orEmpty(), + ) val newId = taskDao.insert(entity) taskIds[row.id] = newId inserted += row to entity.copy(id = newId) @@ -379,6 +390,7 @@ class OneShotImport @Inject constructor( const val ARCHIVE_NAME = "tasks.db.imported" private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm" + private const val CATEGORY_MIMETYPE = "vnd.android.cursor.item/category" private const val REFERENCE_START = "2" private val SIDECARS = listOf("-journal", "-wal", "-shm") private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done") @@ -391,6 +403,8 @@ private class LegacySnapshot( val lists: List, val tasks: List, val alarms: List, + /** Tags by legacy task `_id`. */ + val categories: Map>, ) private class LegacyList(val id: Long, val entity: TaskListEntity) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLift.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLift.kt new file mode 100644 index 0000000..a656cbf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLift.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import de.jeanlucmakiola.agendula.domain.ical.ICalComponent +import de.jeanlucmakiola.agendula.domain.ical.ICalParser +import de.jeanlucmakiola.agendula.domain.ical.ICalProperty +import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer +import de.jeanlucmakiola.agendula.domain.ical.ICalValues + +/** + * Lifts `CATEGORIES` out of a residue written before v2 claimed it. + * + * ⚠️ Frozen with [TasksDatabase.MIGRATION_1_2]. The claim rule is copied here + * rather than shared with the mapper, so a later change to what the mapper + * claims cannot change what this migration produced for an earlier upgrader. + */ +internal object CategoriesLift { + + /** + * Every copy's values merged, and the residue with the one claimed line + * removed — or `null` for the residue when nothing was claimed, so the caller + * leaves the stored one alone. + */ + data class Result(val categories: List, val residue: String?, val claimed: Boolean) + + fun lift(residue: String?): Result { + val component = parse(residue) ?: return Result(emptyList(), residue, claimed = false) + val copies = component.properties.filter { it.name.equals("CATEGORIES", ignoreCase = true) } + val values = copies.flatMap(::values) + val claim = copies.singleOrNull()?.takeIf { + it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value + } ?: return Result(values, residue, claimed = false) + val rest = ICalSerializer.serializeProperties(component.properties.filterNot { it === claim }) + + ICalSerializer.serializeAll(component.components) + return Result(values, rest.ifEmpty { null }, claimed = true) + } + + private fun values(property: ICalProperty): List = + ICalValues.parseTextList(property.value).map(String::trim).filter(String::isNotEmpty).distinct() + + private fun parse(text: String?): ICalComponent? { + if (text.isNullOrEmpty()) return null + return runCatching { ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n") }.getOrNull() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt index f4eae17..caf307d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt @@ -2,6 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room import androidx.room.TypeConverter import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import de.jeanlucmakiola.agendula.domain.normalizeTags import de.jeanlucmakiola.agendula.domain.statusFromInt import de.jeanlucmakiola.agendula.domain.toInt import kotlin.time.Instant @@ -36,3 +38,17 @@ object Converters { fun alarmReferenceFrom(value: String): AlarmReference = runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE) } + +/** + * [TaskEntity.categories] stored as a `CATEGORIES` value, so escaping has one + * home; empty when there are none. Scoped to that field rather than the + * database, so no other `List` picks up iCalendar escaping by accident. + */ +object CategoriesConverter { + @TypeConverter + fun toText(value: List): String = ICalValues.formatTextList(normalizeTags(value)) + + @TypeConverter + fun fromText(value: String): List = + if (value.isEmpty()) emptyList() else normalizeTags(ICalValues.parseTextList(value)) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt index 972018d..513efc1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt @@ -5,6 +5,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import androidx.room.TypeConverters import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE import de.jeanlucmakiola.agendula.domain.TaskStatus import kotlin.time.Instant @@ -71,7 +72,7 @@ data class TaskListEntity( * * ⚠️ It was set by a rename and read by nobody. A collection's name and * 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 * the server" while nothing ever pays it is worse than no flag at all. */ @@ -143,6 +144,10 @@ data class TaskEntity( @ColumnInfo(name = "url") val url: String? = null, /** ARGB override for the list colour. */ @ColumnInfo(name = "color") val color: Int? = null, + /** RFC 5545 `CATEGORIES` — the tags — in the spelling each task carries them. */ + @ColumnInfo(name = "categories", defaultValue = "") + @field:TypeConverters(CategoriesConverter::class) + val categories: List = emptyList(), // state @ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt index dffe701..5ef7233 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt @@ -49,6 +49,7 @@ object RoomTaskMapper { description = row.task.description, location = row.task.location, url = row.task.url, + tags = row.task.categories, priority = priorityFromICal(row.task.priority), status = row.task.status, percentComplete = row.task.percentComplete, @@ -79,6 +80,7 @@ object RoomTaskMapper { description = task.description, location = task.location, url = task.url, + tags = task.categories, priority = priorityFromICal(task.priority), status = task.status, percentComplete = task.percentComplete, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt index 205cfa6..b759ea2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt @@ -520,7 +520,7 @@ class RoomTasksDataSource @Inject constructor( /** * ⚠️ 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. * * This used to set `is_dirty` for an account list instead, on the theory diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt index d0729f9..18d4392 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.tasks.room import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import de.jeanlucmakiola.agendula.domain.normalizeTags import de.jeanlucmakiola.agendula.domain.toICal import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.seconds @@ -50,6 +51,7 @@ object TaskFormWriter { description = form.description?.trim()?.ifBlank { null }, location = form.location?.trim()?.ifBlank { null }, url = form.url?.trim()?.ifBlank { null }, + categories = normalizeTags(form.tags), priority = form.priority.toICal(), percentComplete = percent, status = statusFor(percent, current.status), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt index 7d6e597..a203d11 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt @@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase /** * Agendula's own task store. Four tables, designed from @@ -18,7 +20,7 @@ import androidx.room.TypeConverters TaskEntity::class, TaskAlarmEntity::class, ], - version = 1, + version = 2, exportSchema = true, ) @TypeConverters(Converters::class) @@ -30,5 +32,36 @@ abstract class TasksDatabase : RoomDatabase() { companion object { const val NAME = "agendula-tasks.db" + + /** + * v2 adds `categories`. Until then `CATEGORIES` lived in the residue, and + * an unchanged task is never downloaded again, so the values are lifted + * out here. Not marked dirty: nothing changed on the server's side. + */ + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE tasks ADD COLUMN categories TEXT NOT NULL DEFAULT ''") + val lifted = mutableListOf>() + db.query( + "SELECT id, unknown_properties FROM tasks WHERE unknown_properties LIKE '%CATEGORIES%'", + ).use { cursor -> + while (cursor.moveToNext()) { + val result = CategoriesLift.lift(cursor.getString(1)) + if (result.categories.isNotEmpty()) lifted += cursor.getLong(0) to result + } + } + lifted.forEach { (id, result) -> + val categories = CategoriesConverter.toText(result.categories) + if (result.claimed) { + db.execSQL( + "UPDATE tasks SET categories = ?, unknown_properties = ? WHERE id = ?", + arrayOf(categories, result.residue, id), + ) + } else { + db.execSQL("UPDATE tasks SET categories = ? WHERE id = ?", arrayOf(categories, id)) + } + } + } + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt index 8803571..1585bb0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt @@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.export.ExportTask +import de.jeanlucmakiola.agendula.domain.normalizeTags import de.jeanlucmakiola.agendula.domain.toICal import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -213,6 +214,7 @@ class ExternalImport @Inject constructor( description = description, location = location, url = url, + categories = normalizeTags(tags), status = status, percentComplete = percentComplete, completedAt = completedAt, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt index 05b70db..35de203 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt @@ -52,6 +52,8 @@ data class Task( val description: String?, val location: String?, val url: String?, + /** `CATEGORIES`, as the task spells them. */ + val tags: List = emptyList(), val priority: Priority, val status: TaskStatus, val percentComplete: Int?, @@ -161,3 +163,10 @@ fun TaskStatus.toInt(): Int = when (this) { TaskStatus.COMPLETED -> ICalStatus.COMPLETED TaskStatus.CANCELLED -> ICalStatus.CANCELLED } + +/** Tags as every writer stores them: trimmed, no blanks, each spelling once, order kept. */ +fun normalizeTags(tags: List): List = tags.map(String::trim).filter(String::isNotEmpty).distinct() + +/** Every tag on [tasks], each spelling once, alphabetically ignoring case. */ +fun tagsInUse(tasks: Iterable): List = + tasks.flatMap { it.tags }.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/SavedFilter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/SavedFilter.kt new file mode 100644 index 0000000..e9a7764 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/SavedFilter.kt @@ -0,0 +1,96 @@ +package de.jeanlucmakiola.agendula.domain + +import java.time.LocalDate +import java.time.ZoneId +import kotlin.time.Instant + +/** Where an open task's due date falls, relative to today. */ +enum class DueWindow { OVERDUE, TODAY, UPCOMING, NO_DATE } + +/** How a [SavedFilter] with several tags reads them. */ +enum class TagMatch { ANY, ALL } + +/** + * A user-made view over the open tasks. An empty criterion doesn't narrow: + * no [due] windows means any due date, no [listIds] any list, no [tags] any tag. + * [minPriority] [Priority.NONE] lets every priority through. + * + * [listsElsewhere] is set when the filter also has lists in another task store. + * With none of its lists here it then matches nothing rather than every list. + */ +data class SavedFilter( + val id: Long, + val name: String, + val due: Set = emptySet(), + val listIds: Set = emptySet(), + val tags: Set = emptySet(), + val tagMatch: TagMatch = TagMatch.ANY, + val minPriority: Priority = Priority.NONE, + val listsElsewhere: Boolean = false, +) { + /** Whether the filter narrows by list at all. */ + val anyList: Boolean get() = listIds.isEmpty() && !listsElsewhere + + /** This filter without the list ids missing from [existing], e.g. lists since deleted. */ + fun withListsIn(existing: Set): SavedFilter = copy(listIds = listIds intersect existing) + + fun matches(task: Task, todayStart: Instant, todayEnd: Instant): Boolean { + if (task.isClosed) return false + if (listsElsewhere && listIds.isEmpty()) return false + if (listIds.isNotEmpty() && task.listId !in listIds) return false + if (task.priority < minPriority) return false + if (due.isNotEmpty() && dueWindowOf(task, todayStart, todayEnd) !in due) return false + return when { + tags.isEmpty() -> true + tagMatch == TagMatch.ALL -> task.tags.containsAll(tags) + else -> task.tags.any { it in tags } + } + } +} + +/** + * Where [task]'s due falls against local today ([todayStart] to [todayEnd], from + * [DayWindow] in [zone]). An all-day due is stored as UTC midnight, so it is read + * as its calendar date first rather than compared as a raw instant. + */ +fun dueWindowOf( + task: Task, + todayStart: Instant, + todayEnd: Instant, + zone: ZoneId = ZoneId.systemDefault(), +): DueWindow { + val due = task.due?.let { if (task.isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it } + return when { + due == null -> DueWindow.NO_DATE + due < todayStart -> DueWindow.OVERDUE + due < todayEnd -> DueWindow.TODAY + else -> DueWindow.UPCOMING + } +} + +/** What a task created from a [SavedFilter] starts with, so it lands in that filter. */ +data class NewTaskPreset( + val listIds: Set = emptySet(), + val tags: List = emptyList(), + val dueDate: LocalDate? = null, + val priority: Priority = Priority.NONE, +) + +/** + * The preset for a new task in this filter. Due prefers today, then tomorrow; + * a filter that admits undated tasks, or only overdue ones, leaves it empty. + */ +fun SavedFilter.newTaskPreset(today: LocalDate): NewTaskPreset = NewTaskPreset( + listIds = listIds, + tags = when { + tagMatch == TagMatch.ALL -> tags.sorted() + else -> listOfNotNull(tags.minOrNull()) + }, + dueDate = when { + due.isEmpty() || DueWindow.NO_DATE in due -> null + DueWindow.TODAY in due -> today + DueWindow.UPCOMING in due -> today.plusDays(1) + else -> null + }, + priority = minPriority, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt index e0e8550..274fbb2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt @@ -5,10 +5,16 @@ import kotlin.time.Instant /** The built-in "smart" lists, computed from due dates rather than membership. */ 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, a smart list, or a tag. */ sealed interface 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) : TaskFilter data class Smart(val list: SmartList) : TaskFilter + /** Every task carrying [tag], open and completed, across all lists. */ + data class Tagged(val tag: String) : TaskFilter + /** A [SavedFilter] by id; the repository resolves it to its definition. */ + data class Saved(val id: Long) : TaskFilter } /** @@ -18,21 +24,32 @@ sealed interface TaskFilter { */ object TaskFiltering { - fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean = + /** [saved] is the definition behind a [TaskFilter.Saved]; without one it matches nothing. */ + fun matches( + task: Task, + filter: TaskFilter, + todayStart: Instant, + todayEnd: Instant, + saved: SavedFilter? = null, + ): Boolean = when (filter) { 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.Tagged -> filter.tag in task.tags + is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false } - private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean { - val due = task.due - return when (list) { + /** Whether [task] carries every one of [tags]; an empty set matches everything. */ + fun hasAllTags(task: Task, tags: Set): Boolean = task.tags.containsAll(tags) + + private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean = + when (list) { SmartList.COMPLETED -> task.isCompleted SmartList.ALL -> !task.isClosed - SmartList.NO_DATE -> !task.isClosed && due == null - SmartList.OVERDUE -> !task.isClosed && due != null && due < todayStart - SmartList.TODAY -> !task.isClosed && due != null && due >= todayStart && due < todayEnd - SmartList.UPCOMING -> !task.isClosed && due != null && due >= todayEnd + SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE + SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE + SmartList.TODAY -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.TODAY + SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING } - } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt index c0c1c69..9adec18 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt @@ -22,6 +22,7 @@ data class TaskForm( val reminders: List = emptyList(), val location: String? = null, val url: String? = null, + val tags: List = emptyList(), /** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */ val rrule: String? = null, ) { @@ -44,7 +45,7 @@ enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITH * a setting; the rest unfold on demand. Declaring these as an enum keeps the * disclosure generic, so adding a field later is one entry plus its card. */ -enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url } +enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url, Tags } /** The optional fields that already carry a value — auto-revealed when editing. */ fun TaskForm.populatedFields(): Set = buildSet { @@ -56,4 +57,5 @@ fun TaskForm.populatedFields(): Set = buildSet { if (rrule != null) add(TaskFormField.Recurrence) if (!location.isNullOrBlank()) add(TaskFormField.Location) if (!url.isNullOrBlank()) add(TaskFormField.Url) + if (tags.isNotEmpty()) add(TaskFormField.Tags) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskSections.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskSections.kt index f25a608..63bbdf7 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskSections.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskSections.kt @@ -29,11 +29,11 @@ object TaskSections { fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection { if (task.isCompleted) return TaskSection.COMPLETED - val due = task.due ?: return TaskSection.NO_DATE - return when { - due < todayStart -> TaskSection.OVERDUE - due < todayEnd -> TaskSection.TODAY - else -> TaskSection.UPCOMING + return when (dueWindowOf(task, todayStart, todayEnd)) { + DueWindow.OVERDUE -> TaskSection.OVERDUE + DueWindow.TODAY -> TaskSection.TODAY + DueWindow.UPCOMING -> TaskSection.UPCOMING + DueWindow.NO_DATE -> TaskSection.NO_DATE } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt index 153a34c..8bc65a7 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt @@ -42,6 +42,7 @@ data class ExportTask( val rdate: String?, /** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */ val parentId: Long?, + val tags: List = emptyList(), ) /** A task list and everything in it, ready to become one `.ics` document. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt index e2700a4..bdd16e2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt @@ -88,6 +88,7 @@ object ICalendarWriter { task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) } // URL is a URI, not TEXT: it must not be escaped like one. task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) } + task.tags.takeIf { it.isNotEmpty() }?.let { rawProperty("CATEGORIES", ICalValues.formatTextList(it)) } task.start?.let { dateProperty("DTSTART", it, task.isAllDay) } task.due?.let { dateProperty("DUE", it, task.isAllDay) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt index 037eb5e..32422f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt @@ -49,6 +49,37 @@ object ICalValues { return out.toString() } + /** + * A multi-valued TEXT property (`CATEGORIES`, §3.8.1.2): split on the + * commas that are not escaped, then unescape each value. + */ + fun parseTextList(value: String): List { + val values = mutableListOf() + val current = StringBuilder() + var i = 0 + while (i < value.length) { + val c = value[i] + when { + c == '\\' && i + 1 < value.length -> { + current.append(c).append(value[i + 1]) + i += 2 + continue + } + c == ',' -> { + values += unescapeText(current.toString()) + current.clear() + } + else -> current.append(c) + } + i++ + } + values += unescapeText(current.toString()) + return values + } + + /** The inverse of [parseTextList]. */ + fun formatTextList(values: List): String = values.joinToString(",", transform = ::escapeText) + /** How a DATE / DATE-TIME property was written, and whether we can reproduce it. */ sealed interface TimeValue { /** `VALUE=DATE` — date-only. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/FormFieldVisuals.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/FormFieldVisuals.kt index b376caf..5fb8649 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/FormFieldVisuals.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/FormFieldVisuals.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Percent import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.Sell import androidx.compose.ui.graphics.vector.ImageVector import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.domain.TaskFormField @@ -23,6 +24,7 @@ fun TaskFormField.labelRes(): Int = when (this) { TaskFormField.Reminder -> R.string.edit_reminder_label TaskFormField.Location -> R.string.edit_location_label TaskFormField.Url -> R.string.edit_url_label + TaskFormField.Tags -> R.string.edit_tags_label } fun TaskFormField.icon(): ImageVector = when (this) { @@ -34,4 +36,5 @@ fun TaskFormField.icon(): ImageVector = when (this) { TaskFormField.Reminder -> Icons.Rounded.Notifications TaskFormField.Location -> Icons.Rounded.LocationOn TaskFormField.Url -> Icons.Rounded.Link + TaskFormField.Tags -> Icons.Rounded.Sell } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/NameField.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/NameField.kt new file mode 100644 index 0000000..a607a46 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/NameField.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedSurface +import de.jeanlucmakiola.floret.components.InlineTextField +import de.jeanlucmakiola.floret.components.Position + +/** A lone grouped card holding a name, for the list and filter editors. */ +@Composable +internal fun NameField( + name: String, + placeholder: String, + autoFocus: Boolean, + onNameChange: (String) -> Unit, + onImeAction: () -> Unit, + leading: (@Composable () -> Unit)? = null, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() } + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = GroupedListInset)) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + leading?.invoke() + InlineTextField( + value = name, + onValueChange = onNameChange, + placeholder = placeholder, + imeAction = ImeAction.Done, + onImeAction = onImeAction, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TagChip.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TagChip.kt new file mode 100644 index 0000000..accde3a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TagChip.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * A pill naming one tag. Solid secondary by default so it reads against any card; + * [muted] is the quieter tonal look for something not yet applied, like a suggestion. + */ +@Composable +fun TagChip( + name: String, + modifier: Modifier = Modifier, + muted: Boolean = false, + icon: ImageVector? = null, + iconContentDescription: String? = null, + onClick: (() -> Unit)? = null, +) { + val container = if (muted) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.secondary + val content = if (muted) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSecondary + val shape = RoundedCornerShape(50) + val body: @Composable () -> Unit = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(start = 12.dp, end = if (icon != null) 8.dp else 12.dp, top = 6.dp, bottom = 6.dp), + ) { + Text( + text = name, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (icon != null) { + Icon(icon, contentDescription = iconContentDescription, modifier = Modifier.size(16.dp)) + } + } + } + if (onClick != null) { + Surface(onClick = onClick, color = container, contentColor = content, shape = shape, modifier = modifier) { body() } + } else { + Surface(color = container, contentColor = content, shape = shape, modifier = modifier) { body() } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt index 0bf1661..8a191a4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -42,6 +44,7 @@ import android.net.Uri import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Sell import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Repeat @@ -60,6 +63,7 @@ import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog +import de.jeanlucmakiola.agendula.ui.common.TagChip import de.jeanlucmakiola.agendula.ui.common.linkified import de.jeanlucmakiola.agendula.ui.common.recurrenceText import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel @@ -97,6 +101,7 @@ import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.domain.readableDescription import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail +import de.jeanlucmakiola.floret.components.revealOnFocus import de.jeanlucmakiola.floret.components.GroupedSurface import de.jeanlucmakiola.agendula.ui.common.PriorityChip import de.jeanlucmakiola.floret.time.formatDate @@ -240,6 +245,7 @@ fun TaskDetailScreen( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun DetailBody( detail: TaskDetail, @@ -453,6 +459,19 @@ private fun DetailBody( } } + if (task.tags.isNotEmpty()) { + add { + DetailCard(icon = Icons.Rounded.Sell, iconContentDescription = stringResource(R.string.edit_tags_label)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + task.tags.forEach { TagChip(it) } + } + } + } + } + // Description — selectable, with its links tappable. task.description?.readableDescription()?.takeIf { it.isNotBlank() }?.let { description -> add { @@ -606,7 +625,7 @@ private fun AddSubtaskField(onAdd: (String) -> Unit) { } val accentOn = MaterialTheme.colorScheme.onPrimaryContainer Row( - modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 8.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).revealOnFocus().padding(start = 8.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt index f8feec8..62d6856 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt @@ -1,9 +1,25 @@ package de.jeanlucmakiola.agendula.ui.edit +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ScrollState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.text.style.TextOverflow +import de.jeanlucmakiola.agendula.ui.common.TagChip import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import androidx.activity.compose.BackHandler import androidx.compose.material.icons.rounded.Link +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material.icons.rounded.Sell +import androidx.compose.ui.text.input.ImeAction import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.Repeat import androidx.compose.ui.text.AnnotatedString @@ -15,6 +31,7 @@ import de.jeanlucmakiola.agendula.ui.common.icon import de.jeanlucmakiola.agendula.ui.common.labelRes import de.jeanlucmakiola.agendula.ui.common.recurrenceText import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.floret.components.revealOnFocus import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedSectionHeader @@ -153,11 +170,25 @@ fun TaskEditScreen( val requestBack = { if (state.isDirty && !state.saved) showDiscard = true else onBack() } BackHandler(enabled = state.isDirty && !state.saved) { showDiscard = true } + // Once the title field scrolls away it rides in the bar, so a long form never + // loses track of which task is being edited. + val scroll = rememberScrollState() + var titleBottom by remember { mutableIntStateOf(Int.MAX_VALUE) } + val titleInBar by remember { derivedStateOf { scroll.value > titleBottom } } + Scaffold( modifier = modifier, topBar = { TopAppBar( - title = {}, + title = { + AnimatedVisibility( + visible = titleInBar && state.title.isNotBlank(), + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + slideOutVertically { it / 2 }, + ) { + Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + }, navigationIcon = { IconButton(onClick = requestBack) { Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close)) @@ -180,6 +211,8 @@ fun TaskEditScreen( EditContent( state = state, viewModel = viewModel, + scroll = scroll, + onTitleBottom = { titleBottom = it }, modifier = Modifier.padding(inner), ) } @@ -220,6 +253,8 @@ private enum class PickerTarget { Start, Due } private fun EditContent( state: TaskEditUiState, viewModel: TaskEditViewModel, + scroll: ScrollState, + onTitleBottom: (Int) -> Unit, modifier: Modifier = Modifier, ) { val dark = isSystemInDarkTheme() @@ -250,7 +285,7 @@ private fun EditContent( // Shrink the scroll viewport by the keyboard so the focused field // scrolls into view above the IME instead of being hidden. .imePadding() - .verticalScroll(rememberScrollState()) + .verticalScroll(scroll) .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp), ) { // Title: borderless headline + accent bar, mirroring the detail screen. @@ -259,7 +294,9 @@ private fun EditContent( onValueChange = viewModel::onTitleChange, placeholder = stringResource(R.string.edit_title_hint), textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.focusRequester(titleFocus), + modifier = Modifier + .focusRequester(titleFocus) + .onPlaced { onTitleBottom(it.positionInParent().y.toInt() + it.size.height) }, ) Spacer(Modifier.height(10.dp)) Box( @@ -572,6 +609,19 @@ private fun EditContent( } } + OptionalFormSection(visible = TaskFormField.Tags in state.visibleFields) { + Spacer(Modifier.height(gap)) + TagsCard( + tags = state.tags, + draft = state.tagDraft, + suggestions = state.tagSuggestions, + onDraftChange = viewModel::onTagDraftChange, + onCommitDraft = viewModel::commitTagDraft, + onAdd = viewModel::addTag, + onRemove = viewModel::removeTag, + ) + } + OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) { Spacer(Modifier.height(20.dp)) TextButton( @@ -727,9 +777,9 @@ private fun EditCard( } } if (onClick != null) { - Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() } + Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth().revealOnFocus()) { inner() } } else { - Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() } + Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth().revealOnFocus()) { inner() } } } @@ -1040,6 +1090,77 @@ private fun AddReminderCard(position: Position, label: String, onClick: () -> Un } } +/** The input sits beside the icon; the tags and any suggestions run below it. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TagsCard( + tags: List, + draft: String, + suggestions: List, + onDraftChange: (String) -> Unit, + onCommitDraft: () -> Unit, + onAdd: (String) -> Unit, + onRemove: (String) -> Unit, +) { + val query = draft.trim() + val matches = if (query.isEmpty()) { + emptyList() + } else { + suggestions.filter { it.contains(query, ignoreCase = true) && it !in tags }.take(MAX_TAG_SUGGESTIONS) + } + EditCard( + icon = Icons.Rounded.Sell, + iconContentDescription = stringResource(R.string.edit_tags_label), + header = { + InlineTextField( + value = draft, + onValueChange = onDraftChange, + placeholder = stringResource(R.string.edit_tags_hint), + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done, + onImeAction = onCommitDraft, + ) + }, + ) { + if (matches.isNotEmpty()) { + TagFlow(modifier = Modifier.padding(top = 8.dp)) { + matches.forEach { match -> + TagChip( + name = match, + muted = true, + icon = Icons.Rounded.Add, + onClick = { onAdd(match) }, + ) + } + } + } + if (tags.isNotEmpty()) { + TagFlow(modifier = Modifier.padding(top = 8.dp)) { + tags.forEach { tag -> + TagChip( + name = tag, + icon = Icons.Rounded.Close, + iconContentDescription = stringResource(R.string.edit_tag_remove, tag), + onClick = { onRemove(tag) }, + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TagFlow(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { content() } +} + +private const val MAX_TAG_SUGGESTIONS = 8 + /** Picks one hidden optional section to add to the form. */ @Composable private fun FieldPickerDialog( diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt index 2c75dec..6ad7485 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt @@ -8,15 +8,20 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskFormError import de.jeanlucmakiola.agendula.domain.TaskFormField +import de.jeanlucmakiola.agendula.domain.allDayInstantOf import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.populatedFields import de.jeanlucmakiola.agendula.domain.htmlToPlainText import de.jeanlucmakiola.agendula.domain.looksLikeHtml +import de.jeanlucmakiola.agendula.domain.newTaskPreset +import de.jeanlucmakiola.agendula.domain.normalizeTags +import de.jeanlucmakiola.agendula.domain.tagsInUse import de.jeanlucmakiola.agendula.domain.rebasedForAllDay import de.jeanlucmakiola.agendula.domain.readableDescription import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope @@ -25,7 +30,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import java.time.LocalDate import javax.inject.Inject +import kotlin.time.Clock import kotlin.time.Instant /** @@ -50,6 +57,11 @@ data class TaskEditUiState( val reminders: List = emptyList(), val location: String = "", val url: String = "", + val tags: List = emptyList(), + /** What is typed in the tag field and not added yet. Saving still takes it. */ + val tagDraft: String = "", + /** Every tag already in use, for completing what the user types. */ + val tagSuggestions: List = emptyList(), val rrule: String? = null, /** Editing one occurrence of a series: saving asks how far the edit reaches. */ val isOccurrence: Boolean = false, @@ -113,7 +125,13 @@ class TaskEditViewModel @Inject constructor( private var defaultFields: Set = emptySet() /** Start a fresh task, optionally pre-selecting a list / parent. */ - fun bindNew(presetListId: Long? = null, parentId: Long? = null, initialTitle: String? = null) { + fun bindNew( + presetListId: Long? = null, + parentId: Long? = null, + initialTitle: String? = null, + presetTag: String? = null, + presetFilterId: Long? = null, + ) { if (bound) return bound = true editingTaskId = null @@ -122,11 +140,16 @@ class TaskEditViewModel @Inject constructor( val settings = settingsPrefs.settings.first() defaultFields = settings.defaultEditFields val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } - // A read-only or vanished default would give the task a list it + val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() } + ?.newTaskPreset(LocalDate.now()) + // 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. val writable = lists.filter { it.acceptsWrites } - val defaultList = presetListId - ?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } } + val filterLists = preset?.listIds.orEmpty() + val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } } + ?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } && (filterLists.isEmpty() || id in filterLists) } + ?: writable.firstOrNull { it.id in filterLists }?.id ?: writable.firstOrNull { !it.isLocal }?.id ?: writable.firstOrNull()?.id _state.value = withFields( @@ -135,10 +158,22 @@ class TaskEditViewModel @Inject constructor( isNew = true, autofocusTitle = settings.autofocusTitle, listId = defaultList, + // A preset due is all-day, so a default start has to be too, or + // today's start would land after a due of today. + start = when { + !settings.defaultStartToday -> null + preset?.dueDate != null -> allDayInstantOf(LocalDate.now()) + else -> Clock.System.now() + }, + // The same all-day value picking the date in the date field gives. + due = preset?.dueDate?.let(::allDayInstantOf), + isAllDay = preset?.dueDate != null, + priority = preset?.priority ?: Priority.NONE, parentId = parentId, lists = lists, parentCandidates = loadParents(defaultList, selfId = null), title = initialTitle.orEmpty(), + tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(), ), ).also { baseline = it.toForm().copy(title = "") } } @@ -176,6 +211,7 @@ class TaskEditViewModel @Inject constructor( reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore }, location = task.location.orEmpty(), url = task.url.orEmpty(), + tags = task.tags, rrule = task.recurrenceRule, originalRule = task.recurrenceRule, isOccurrence = task.isOccurrence, @@ -199,6 +235,18 @@ class TaskEditViewModel @Inject constructor( return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId } } + private var suggestionsRequested = false + + /** Tags on open tasks, read once the user starts typing one rather than on every bind. */ + private fun loadTagSuggestions() { + if (suggestionsRequested) return + suggestionsRequested = true + viewModelScope.launch { + val tasks = runCatching { repository.tasks(TaskFilter.Smart(SmartList.ALL)).first() }.getOrElse { emptyList() } + _state.value = _state.value.copy(tagSuggestions = tagsInUse(tasks)) + } + } + /** Reveal an optional section (one tap from the "More fields" picker). */ fun revealField(field: TaskFormField) = update { val visible = it.visibleFields + field @@ -254,6 +302,18 @@ class TaskEditViewModel @Inject constructor( fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) } fun onLocationChange(value: String) = update { it.copy(location = value) } fun onUrlChange(value: String) = update { it.copy(url = value) } + fun addTag(name: String) = update { it.copy(tags = normalizeTags(it.tags + name), tagDraft = "") } + + /** A typed comma adds what came before it. */ + fun onTagDraftChange(value: String) { + loadTagSuggestions() + update { + if (',' in value) it.copy(tags = it.withDraft(value), tagDraft = "") else it.copy(tagDraft = value) + } + } + + fun commitTagDraft() = update { it.copy(tags = it.withDraft(it.tagDraft), tagDraft = "") } + fun removeTag(name: String) = update { it.copy(tags = it.tags - name) } fun onRecurrenceChange(rrule: String?) = update { it.copy(rrule = rrule, errors = emptySet()) } /** Dismiss the conflict prompt without saving (the user keeps editing). */ @@ -336,5 +396,8 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm( reminders = reminders, location = location.ifBlank { null }, url = url.ifBlank { null }, + tags = withDraft(tagDraft), rrule = rrule, ) + +private fun TaskEditUiState.withDraft(draft: String): List = normalizeTags(tags + draft.split(',')) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/filters/FilterEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/filters/FilterEditScreen.kt new file mode 100644 index 0000000..378398b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/filters/FilterEditScreen.kt @@ -0,0 +1,259 @@ +package de.jeanlucmakiola.agendula.ui.filters + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.DueWindow +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.SavedFilter +import de.jeanlucmakiola.agendula.domain.TagMatch +import de.jeanlucmakiola.agendula.ui.common.ListColorChip +import de.jeanlucmakiola.agendula.ui.common.NameField +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +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.positionOf + +/** + * Creates or edits a [SavedFilter]: a name, then due windows, priority, lists and + * tags. Each multi-choice section leads with an "any" row that is selected while + * nothing in it is, so an untouched section visibly doesn't narrow. + */ +@Composable +fun FilterEditScreen( + viewModel: FilterEditViewModel, + isNew: Boolean, + onSaved: (Long) -> Unit, + onDeleted: () -> Unit, + onBack: () -> Unit, +) { + val draft by viewModel.draft.collectAsStateWithLifecycle() + val lists by viewModel.lists.collectAsStateWithLifecycle() + val tagsInUse by viewModel.tags.collectAsStateWithLifecycle() + val filter = draft + var choosingPriority by rememberSaveable { mutableStateOf(false) } + var confirmDelete by rememberSaveable { mutableStateOf(false) } + val save = { viewModel.save(onSaved) } + + CollapsingScaffold( + title = stringResource(if (isNew) R.string.filter_new_title else R.string.filter_edit_title), + onBack = onBack, + largeTopBar = false, + actions = { + Button( + onClick = save, + enabled = filter?.name?.isNotBlank() == true, + modifier = Modifier.padding(end = 12.dp), + ) { Text(stringResource(R.string.save)) } + }, + ) { + if (filter == null) return@CollapsingScaffold + + NameField( + name = filter.name, + placeholder = stringResource(R.string.filter_name_hint), + autoFocus = isNew, + onNameChange = { name -> viewModel.update { it.copy(name = name) } }, + onImeAction = save, + ) + + GroupedSectionHeader(stringResource(R.string.filter_section_due)) + val windows = DueWindow.entries + ChoiceRow( + title = stringResource(R.string.filter_any_due), + position = positionOf(0, windows.size + 1), + selected = filter.due.isEmpty(), + onClick = { viewModel.update { it.copy(due = emptySet()) } }, + ) + windows.forEachIndexed { index, window -> + ChoiceRow( + title = dueWindowLabel(window), + position = positionOf(index + 1, windows.size + 1), + selected = window in filter.due, + onClick = { viewModel.update { it.copy(due = it.due.toggle(window)) } }, + ) + } + + GroupedSectionHeader(stringResource(R.string.filter_section_priority)) + GroupedRow( + title = minPriorityLabel(filter.minPriority), + position = Position.Alone, + trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) }, + onClick = { choosingPriority = true }, + ) + + if (lists.isNotEmpty()) { + GroupedSectionHeader(stringResource(R.string.filter_section_lists)) + val showAccount = lists.mapTo(HashSet()) { it.accountName }.size > 1 + ChoiceRow( + title = stringResource(R.string.filter_any_list), + position = positionOf(0, lists.size + 1), + selected = filter.anyList, + onClick = { viewModel.update { it.copy(listIds = emptySet(), listsElsewhere = false) } }, + ) + lists.forEachIndexed { index, list -> + ChoiceRow( + title = list.name, + summary = list.accountName.takeIf { showAccount }, + position = positionOf(index + 1, lists.size + 1), + selected = list.id in filter.listIds, + leading = { ListColorChip(list.color) }, + onClick = { viewModel.update { it.copy(listIds = it.listIds.toggle(list.id)) } }, + ) + } + } + + // A tag no open task carries any more stays listed while the filter uses + // it, so it can still be unticked. + val tags = remember(tagsInUse, filter.tags) { + (tagsInUse + filter.tags).distinct().sortedWith(String.CASE_INSENSITIVE_ORDER) + } + if (tags.isNotEmpty()) { + GroupedSectionHeader(stringResource(R.string.filter_section_tags)) + ChoiceRow( + title = stringResource(R.string.filter_any_tag), + position = positionOf(0, tags.size + 1), + selected = filter.tags.isEmpty(), + onClick = { viewModel.update { it.copy(tags = emptySet()) } }, + ) + tags.forEachIndexed { index, tag -> + ChoiceRow( + title = tag, + position = positionOf(index + 1, tags.size + 1), + selected = tag in filter.tags, + onClick = { viewModel.update { it.copy(tags = it.tags.toggle(tag)) } }, + ) + } + if (filter.tags.size > 1) { + Spacer(Modifier.height(8.dp)) + GroupedRow( + title = stringResource(R.string.filter_match_all_tags), + summary = stringResource( + if (filter.tagMatch == TagMatch.ALL) R.string.filter_match_all_on else R.string.filter_match_all_off, + ), + position = Position.Alone, + trailing = { + Switch( + checked = filter.tagMatch == TagMatch.ALL, + onCheckedChange = { all -> + viewModel.update { it.copy(tagMatch = if (all) TagMatch.ALL else TagMatch.ANY) } + }, + ) + }, + onClick = { + viewModel.update { it.copy(tagMatch = if (it.tagMatch == TagMatch.ALL) TagMatch.ANY else TagMatch.ALL) } + }, + ) + } + } + + if (filter.id > 0) { + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.filter_delete), + position = Position.Alone, + contentColor = MaterialTheme.colorScheme.error, + leading = { Icon(Icons.Rounded.Delete, contentDescription = null) }, + onClick = { confirmDelete = true }, + ) + } + Spacer(Modifier.height(24.dp)) + } + + if (choosingPriority && filter != null) { + OptionPicker( + title = stringResource(R.string.filter_section_priority), + options = Priority.entries, + selected = filter.minPriority, + label = { minPriorityLabel(it) }, + onSelect = { priority -> viewModel.update { it.copy(minPriority = priority) } }, + onDismiss = { choosingPriority = false }, + ) + } + + if (confirmDelete && filter != null) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text(stringResource(R.string.filter_delete_title, filter.name)) }, + text = { Text(stringResource(R.string.filter_delete_body)) }, + confirmButton = { + TextButton(onClick = { confirmDelete = false; viewModel.delete(onDeleted) }) { + Text(stringResource(R.string.filter_delete_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { confirmDelete = false }) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) + } +} + +@Composable +private fun ChoiceRow( + title: String, + position: Position, + selected: Boolean, + onClick: () -> Unit, + summary: String? = null, + leading: @Composable (() -> Unit)? = null, +) { + GroupedRow( + title = title, + summary = summary, + position = position, + selected = selected, + leading = leading, + trailing = if (selected) { + { SelectedCheck() } + } else { + null + }, + onClick = onClick, + ) +} + +private fun Set.toggle(item: T): Set = if (item in this) this - item else this + item + +@Composable +private fun dueWindowLabel(window: DueWindow): String = stringResource( + when (window) { + DueWindow.OVERDUE -> R.string.smart_overdue + DueWindow.TODAY -> R.string.smart_today + DueWindow.UPCOMING -> R.string.smart_upcoming + DueWindow.NO_DATE -> R.string.smart_no_date + }, +) + +@Composable +internal fun minPriorityLabel(priority: Priority): String = stringResource( + when (priority) { + Priority.NONE -> R.string.filter_priority_any + Priority.LOW -> R.string.filter_priority_low + Priority.MEDIUM -> R.string.filter_priority_medium + Priority.HIGH -> R.string.filter_priority_high + }, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/filters/FilterEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/filters/FilterEditViewModel.kt new file mode 100644 index 0000000..4840316 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/filters/FilterEditViewModel.kt @@ -0,0 +1,91 @@ +package de.jeanlucmakiola.agendula.ui.filters + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.domain.SavedFilter +import de.jeanlucmakiola.agendula.domain.SmartList +import de.jeanlucmakiola.agendula.domain.TaskFilter +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.tagsInUse +import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Holds the filter being created or edited until it's saved. Call [bind] first. */ +@HiltViewModel +class FilterEditViewModel @Inject constructor( + private val settingsPrefs: SettingsPrefs, + private val widgetUpdater: TaskWidgetUpdater, + private val repository: TasksRepository, +) : ViewModel() { + + private val _draft = MutableStateFlow(null) + + /** `null` until [bind] has loaded the filter. An id of 0 is a new one. */ + val draft: StateFlow = _draft.asStateFlow() + + val lists: StateFlow> = repository.taskLists() + .catch { emit(emptyList()) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** Every tag on an open task. */ + val tags: StateFlow> = repository.tasks(TaskFilter.Smart(SmartList.ALL)) + .map { tagsInUse(it) } + .catch { emit(emptyList()) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private var bound = false + private var saving = false + + fun bind(filterId: Long?) { + if (bound) return + bound = true + if (filterId == null) { + _draft.value = SavedFilter(id = 0, name = "") + return + } + viewModelScope.launch { + val saved = settingsPrefs.savedFilter(filterId).first() + // Show it as it matches: lists deleted since drop out, as they do in the repository. + val existing = runCatching { repository.taskLists().first().mapTo(HashSet()) { it.id } }.getOrNull() + _draft.value = saved?.let { if (existing == null) it else it.withListsIn(existing) } + ?: SavedFilter(id = 0, name = "") + } + } + + fun update(transform: (SavedFilter) -> SavedFilter) { + _draft.value = _draft.value?.let(transform) + } + + fun save(onSaved: (Long) -> Unit) { + val filter = _draft.value ?: return + if (filter.name.isBlank() || saving) return + saving = true + viewModelScope.launch { + val id = settingsPrefs.saveFilter(filter.copy(name = filter.name.trim())) + // A widget showing this filter doesn't watch the prefs. + widgetUpdater.requestRefresh() + onSaved(id) + } + } + + fun delete(onDeleted: () -> Unit) { + val id = _draft.value?.id?.takeIf { it > 0 } ?: return + viewModelScope.launch { + settingsPrefs.deleteFilter(id) + widgetUpdater.requestRefresh() + onDeleted() + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt index 5939e6f..4c03174 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt @@ -4,7 +4,7 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.StorageMode import de.jeanlucmakiola.agendula.data.tasks.TasksRepository @@ -76,7 +76,7 @@ class ImportViewModel @Inject constructor( repository: TasksRepository, private val resolver: ProviderResolver, private val importer: IcsImport, - private val syncTrigger: SyncTrigger, + private val syncRequests: SyncRequests, ) : ViewModel() { private val ownStore = MutableStateFlow(resolver.mode() == StorageMode.OWN) @@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor( val result = importer.import(ready.parsed, target) (chosen as? ImportChoice.Existing)?.list ?.takeIf { it.accountId != null && result.imported > 0 } - ?.let { runCatching { syncTrigger.enqueue(it.accountName) } } + ?.let { runCatching { syncRequests.syncNow(it.accountName) } } ImportStep.Done( listName = listName, imported = result.imported, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/OpenSourceLicences.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/OpenSourceLicences.kt index b699e81..efee7a1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/OpenSourceLicences.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/OpenSourceLicences.kt @@ -1,5 +1,7 @@ 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. * @@ -30,6 +32,8 @@ data class Attribution( val licence: Licence, /** Where the source can actually be obtained — MPL-2.0 §3.2(a)'s requirement. */ 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) { @@ -54,30 +58,35 @@ object OpenSourceLicences { copyright = "© bitfire web engineering (Ricki Hirner, Bernhard Stockmann)", licence = Licence.MPL_2, sourceUrl = "https://github.com/bitfireAT/dav4jvm", + syncOnly = true, ), Attribution( name = "dnsjava", copyright = "© Brian Wellington and the dnsjava contributors", licence = Licence.BSD_3, sourceUrl = "https://github.com/dnsjava/dnsjava", + syncOnly = true, ), Attribution( name = "UnifiedPush Android connector", copyright = "© UnifiedPush contributors", licence = Licence.APACHE_2, sourceUrl = "https://codeberg.org/UnifiedPush/android-connector", + syncOnly = true, ), Attribution( name = "Tink", copyright = "© Google LLC", licence = Licence.APACHE_2, sourceUrl = "https://github.com/tink-crypto/tink-java", + syncOnly = true, ), Attribution( name = "OkHttp", copyright = "© Square, Inc.", licence = Licence.APACHE_2, sourceUrl = "https://github.com/square/okhttp", + syncOnly = true, ), Attribution( name = "Okio", @@ -121,5 +130,5 @@ object OpenSourceLicences { licence = Licence.MIT, sourceUrl = "https://codeberg.org/jlmakiola/floret-kit", ), - ) + ).filter { BuildConfig.SYNC_ENABLED || !it.syncOnly } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt index d06c11f..4dac81d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -40,25 +39,22 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.ui.common.DefaultListColor import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.ListPalette +import de.jeanlucmakiola.agendula.ui.common.NameField import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedSurface -import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.pastelize @@ -153,7 +149,6 @@ fun ListEditorSheet( GroupedRow( title = destination.label, position = Position.Alone, - modifier = Modifier.padding(horizontal = 16.dp), leading = { Icon(destinationIcon(destination), contentDescription = null) }, trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) }, onClick = { choosingDestination = true }, @@ -176,7 +171,6 @@ fun ListEditorSheet( title = stringResource(R.string.list_in_smart_lists), summary = stringResource(R.string.list_in_smart_lists_hint), position = Position.Alone, - modifier = Modifier.padding(horizontal = 16.dp), trailing = { Switch( checked = !hiddenFromSmartLists, @@ -260,27 +254,14 @@ internal fun ListNameField( autoFocus: Boolean, onNameChange: (String) -> Unit, onImeAction: () -> Unit, -) { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() } - GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { - Row( - modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - ListColorChip(color) - InlineTextField( - value = name, - onValueChange = onNameChange, - placeholder = stringResource(R.string.list_name_hint), - imeAction = ImeAction.Done, - onImeAction = onImeAction, - modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), - ) - } - } -} +) = NameField( + name = name, + placeholder = stringResource(R.string.list_name_hint), + autoFocus = autoFocus, + onNameChange = onNameChange, + onImeAction = onImeAction, + leading = { ListColorChip(color) }, +) /** * The palette as two rows of round swatches; the chosen one carries a check. diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index 92088cb..f64cd57 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.lists import de.jeanlucmakiola.floret.components.ReorderableColumn import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.Sell import androidx.compose.material.icons.rounded.SwapVert import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility @@ -30,6 +32,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed @@ -86,6 +89,7 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.ui.common.ActionShapes import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.SnackChip import de.jeanlucmakiola.floret.components.SnackChipHeight import de.jeanlucmakiola.floret.components.SnackChipMargin @@ -114,6 +118,7 @@ fun ListsScreen( onNewTask: () -> Unit, onOpenSettings: () -> Unit, modifier: Modifier = Modifier, + onNewFilter: () -> Unit = {}, viewModel: ListsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -179,6 +184,7 @@ fun ListsScreen( onOpenFilter = onOpenFilter, onOpenTask = onOpenTask, onNewList = { newList = true }, + onNewFilter = onNewFilter, onReorder = viewModel::reorderLists, topPadding = 0.dp, bottomPadding = inner.calculateBottomPadding() + 96.dp, @@ -252,6 +258,7 @@ private fun ListsContent( onOpenFilter: (TaskFilter) -> Unit, onOpenTask: (Task) -> Unit, onNewList: () -> Unit, + onNewFilter: () -> Unit, onReorder: (List) -> Unit, topPadding: androidx.compose.ui.unit.Dp, bottomPadding: androidx.compose.ui.unit.Dp, @@ -370,6 +377,90 @@ private fun ListsContent( } } } + + if (state.tags.isNotEmpty()) { + item(key = "tags-header") { SectionHeader(stringResource(R.string.tags_header)) } + itemsIndexed(state.tags, key = { _, t -> "tag-${t.tag}" }) { index, overview -> + GroupedRow( + title = overview.tag, + position = positionOf(index, state.tags.size), + leading = { + Icon( + Icons.Rounded.Sell, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailing = { + Text( + overview.openCount.toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { onOpenFilter(TaskFilter.Tagged(overview.tag)) }, + ) + } + } + + if (state.groups.isNotEmpty()) { + filtersSection(state.filters, onOpenFilter, onNewFilter) + } + } +} + +/** The saved filters, or a single row offering the first one. */ +private fun LazyListScope.filtersSection( + filters: List, + onOpenFilter: (TaskFilter) -> Unit, + onNewFilter: () -> Unit, +) { + item(key = "filters-header") { + SectionHeader( + text = stringResource(R.string.filters_header), + action = if (filters.isEmpty()) { + null + } else { + { + IconButton(onClick = onNewFilter) { + Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.filter_new_title)) + } + } + }, + ) + } + if (filters.isEmpty()) { + item(key = "filters-empty") { + GroupedRow( + title = stringResource(R.string.filter_new_title), + summary = stringResource(R.string.filters_empty_hint), + position = Position.Alone, + leading = { Icon(Icons.Rounded.Add, contentDescription = null) }, + onClick = onNewFilter, + ) + } + return + } + itemsIndexed(filters, key = { _, f -> "filter-${f.filter.id}" }) { index, overview -> + GroupedRow( + title = overview.filter.name, + position = positionOf(index, filters.size), + leading = { + Icon( + Icons.Rounded.FilterList, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailing = { + Text( + overview.openCount.toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { onOpenFilter(TaskFilter.Saved(overview.filter.id)) }, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index 223f440..90f2c18 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -4,15 +4,17 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository +import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.floret.time.DayWindow +import de.jeanlucmakiola.agendula.domain.SavedFilter import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.tagsInUse import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -54,25 +56,27 @@ enum class ListWriteFailure { } /** 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) { - RemoteListRepository.Outcome.Done -> null - is RemoteListRepository.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED - RemoteListRepository.Outcome.Unreachable -> ListWriteFailure.OFFLINE - RemoteListRepository.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED - RemoteListRepository.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY + RemoteLists.Outcome.Done -> null + is RemoteLists.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED + RemoteLists.Outcome.Unreachable -> ListWriteFailure.OFFLINE + RemoteLists.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED + RemoteLists.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY // The account is gone, stopped or undecryptable, or the server said // something this call cannot place. Nothing specific to say beyond // "that did not save", which is what [local] is — and specifically not // "you are offline", which neither of them is a claim about. - RemoteListRepository.Outcome.NoAccount, - RemoteListRepository.Outcome.Unexpected, + RemoteLists.Outcome.NoAccount, + RemoteLists.Outcome.Unexpected, -> local } data class ListOverview(val list: TaskList, val openCount: Int) data class AccountGroup(val accountName: String, val lists: List) data class SmartCount(val smart: SmartList, val count: Int) +data class TagOverview(val tag: String, val openCount: Int) +data class FilterOverview(val filter: SavedFilter, val openCount: Int) sealed interface ListsUiState { data object Loading : ListsUiState @@ -87,6 +91,10 @@ sealed interface ListsUiState { val upcoming: List, /** Every task (open and completed) — the corpus the home search filters. */ val allTasks: List, + /** Each tag on an open task, alphabetically. */ + val tags: List = emptyList(), + /** The saved filters, oldest first. */ + val filters: List = emptyList(), ) : ListsUiState } @@ -96,7 +104,7 @@ private const val UPCOMING_PREVIEW = 3 @HiltViewModel class ListsViewModel @Inject constructor( private val repository: TasksRepository, - private val remoteLists: RemoteListRepository, + private val remoteLists: RemoteLists, settingsPrefs: SettingsPrefs, ) : ViewModel() { @@ -125,8 +133,9 @@ class ListsViewModel @Inject constructor( // ones already ticked off to show "x of y done", so read them too. repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)), settingsPrefs.settings.map { it.hiddenFromSmartLists }.distinctUntilChanged(), - ) { lists, openTasks, completedTasks, hidden -> - buildContent(lists, openTasks, completedTasks, hidden) as ListsUiState + settingsPrefs.savedFilters, + ) { lists, openTasks, completedTasks, hidden, filters -> + buildContent(lists, openTasks, completedTasks, hidden, filters) as ListsUiState }.recoveringFromProviderFailure { ListsUiState.Failure } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading) @@ -135,6 +144,7 @@ class ListsViewModel @Inject constructor( openTasks: List, completedTasks: List, hidden: Set, + savedFilters: List, ): ListsUiState.Content { val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) // Count only top-level tasks: a subtask is represented by its parent (and @@ -177,13 +187,29 @@ class ListsViewModel @Inject constructor( ) } .sortedBy { it.accountName.lowercase() } + // Counts the open rows the tag's own screen shows: a subtask folds under a + // parent carrying the same tag, and stands on its own otherwise. + val allTasks = openTasks + completedTasks + val tags = tagsInUse(openTasks).map { tag -> + val tagged = allTasks.filter { tag in it.tags } + val ids = tagged.mapTo(HashSet()) { it.taskId } + TagOverview(tag, tagged.count { !it.isClosed && (!it.isSubtask || it.parentId !in ids) }) + } + // Same folding as the tags: a subtask counts only when its parent isn't in the filter too. + val filters = savedFilters.map { filter -> + val matching = openTasks.filter { filter.matches(it, todayStart, todayEnd) } + val ids = matching.mapTo(HashSet()) { it.taskId } + FilterOverview(filter, matching.count { !it.isSubtask || it.parentId !in ids }) + } return ListsUiState.Content( smartCounts = smartCounts, + filters = filters, groups = groups, todayDone = completedDueToday, todayTotal = todayTotal, upcoming = upcoming, - allTasks = openTasks + completedTasks, + allTasks = allTasks, + tags = tags, ) } @@ -215,7 +241,7 @@ class ListsViewModel @Inject constructor( // ⚠️ 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. val outcome = runCatching { remoteLists.create(accountId, name.trim(), color) } - .getOrElse { RemoteListRepository.Outcome.Unreachable } + .getOrElse { RemoteLists.Outcome.Unreachable } _writeFailure.value = outcome.asFailure(ListWriteFailure.SAVE) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt index b9f56e3..4bde49d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt @@ -20,6 +20,8 @@ import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel +import de.jeanlucmakiola.agendula.ui.filters.FilterEditScreen +import de.jeanlucmakiola.agendula.ui.filters.FilterEditViewModel import de.jeanlucmakiola.agendula.ui.imports.ImportScreen import de.jeanlucmakiola.agendula.ui.lists.ListsScreen import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen @@ -52,13 +54,17 @@ fun AgendulaNavHost( nav.navigate(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)) { launchSingleTop = true } is NavRequest.OpenAccount -> 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))) { launchSingleTop = true } is NavRequest.OpenList -> nav.navigate(Dest.TaskList.build(TaskFilter.OfList(navRequest.listId))) { launchSingleTop = true } + is NavRequest.OpenFilter -> nav.navigate(Dest.TaskList.build(TaskFilter.Saved(navRequest.filterId))) { + launchSingleTop = true + } is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri)) } onNavRequestConsumed() @@ -84,6 +90,7 @@ fun AgendulaNavHost( onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) }, onOpenTask = { task -> nav.navigate(task.detailRoute()) }, onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) }, + onNewFilter = { nav.navigate(Dest.FilterEdit.build()) }, onOpenSettings = { nav.navigate(Dest.SETTINGS) }, ) } @@ -113,6 +120,8 @@ fun AgendulaNavHost( val filter = Dest.TaskList.filterOf( smart = args?.getString(Dest.TaskList.ARG_SMART), listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID, + tag = args?.getString(Dest.TaskList.ARG_TAG), + filterId = args?.getLong(Dest.TaskList.ARG_FILTER_ID) ?: Dest.NO_ID, ) val vm: TaskListViewModel = hiltViewModel() LaunchedEffect(filter) { vm.bind(filter) } @@ -123,9 +132,15 @@ fun AgendulaNavHost( viewModel = vm, onOpenTask = onOpenTask, onNewTask = { - val presetListId = (filter as? TaskFilter.OfList)?.listId - nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId)) + nav.navigate( + Dest.TaskEdit.buildNew( + presetListId = (filter as? TaskFilter.OfList)?.listId, + tag = (filter as? TaskFilter.Tagged)?.tag, + filterId = (filter as? TaskFilter.Saved)?.id, + ), + ) }, + onEditFilter = { id -> nav.navigate(Dest.FilterEdit.build(id)) }, onBack = { nav.popBackStack() }, ) } @@ -138,6 +153,32 @@ fun AgendulaNavHost( } } + composable(Dest.FilterEdit.route, arguments = Dest.FilterEdit.arguments) { entry -> + val filterId = entry.arguments?.getLong(Dest.FilterEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID } + val vm: FilterEditViewModel = hiltViewModel() + LaunchedEffect(filterId) { vm.bind(filterId) } + + ReadableWidth { + FilterEditScreen( + viewModel = vm, + isNew = filterId == null, + onSaved = { id -> + // A different id means the edited filter was gone and a new one was made. + if (id != filterId) { + // A new filter opens straight away, with the editor off the stack. + nav.navigate(Dest.TaskList.build(TaskFilter.Saved(id))) { + popUpTo(Dest.FilterEdit.route) { inclusive = true } + } + } else { + nav.popBackStack() + } + }, + onDeleted = { nav.popBackStack(Dest.LISTS, inclusive = false) }, + onBack = { nav.popBackStack() }, + ) + } + } + composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry -> val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID val occurrence = entry.arguments?.getLong(Dest.TaskDetail.ARG_OCC)?.takeIf { it != Dest.NO_ID } @@ -165,6 +206,8 @@ fun AgendulaNavHost( val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID val occurrence = args?.getLong(Dest.TaskEdit.ARG_OCCURRENCE)?.takeIf { it != Dest.NO_ID } val title = args?.getString(Dest.TaskEdit.ARG_TITLE) + val tag = args?.getString(Dest.TaskEdit.ARG_TAG) + val filterId = args?.getLong(Dest.TaskEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID } val vm: TaskEditViewModel = hiltViewModel() LaunchedEffect(taskId, presetListId, parentId) { if (taskId != Dest.NO_ID) { @@ -174,6 +217,8 @@ fun AgendulaNavHost( presetListId = presetListId.takeIf { it != Dest.NO_ID }, parentId = parentId.takeIf { it != Dest.NO_ID }, initialTitle = title, + presetTag = tag, + presetFilterId = filterId, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt index a89ca47..d3b6168 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt @@ -53,28 +53,51 @@ object Dest { } /** - * One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or* - * `smart` (a [SmartList] name); whichever is set decides the filter. + * One [TaskFilter]'s tasks. Carries one of `listId` (a real list), `smart` + * (a [SmartList] name), `tag` or `filterId` (a saved filter); whichever is + * set decides the filter. */ object TaskList { const val ARG_LIST_ID = "listId" const val ARG_SMART = "smart" - const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}" + const val ARG_TAG = "tag" + const val ARG_FILTER_ID = "filterId" + const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}&$ARG_TAG={$ARG_TAG}" + + "&$ARG_FILTER_ID={$ARG_FILTER_ID}" val arguments = listOf( navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID }, navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null }, + navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null }, + navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID }, ) fun build(filter: TaskFilter): String = when (filter) { is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}" is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}" + is TaskFilter.Tagged -> "taskList?$ARG_TAG=${android.net.Uri.encode(filter.tag)}" + is TaskFilter.Saved -> "taskList?$ARG_FILTER_ID=${filter.id}" + // 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. */ - fun filterOf(smart: String?, listId: Long): TaskFilter = - if (smart != null) TaskFilter.Smart(SmartList.valueOf(smart)) - else TaskFilter.OfList(listId) + fun filterOf(smart: String?, listId: Long, tag: String? = null, filterId: Long = NO_ID): TaskFilter = when { + smart != null -> TaskFilter.Smart(SmartList.valueOf(smart)) + tag != null -> TaskFilter.Tagged(tag) + filterId != NO_ID -> TaskFilter.Saved(filterId) + else -> TaskFilter.OfList(listId) + } + } + + /** Creates a saved filter, or edits the one `filterId` names. */ + object FilterEdit { + const val ARG_FILTER_ID = "filterId" + const val route = "filterEdit?$ARG_FILTER_ID={$ARG_FILTER_ID}" + val arguments = listOf(navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID }) + + fun build(filterId: Long? = null): String = + if (filterId == null) "filterEdit" else "filterEdit?$ARG_FILTER_ID=$filterId" } /** A single task's detail; `occ` (epoch millis) picks one occurrence of a series. */ @@ -95,7 +118,8 @@ object Dest { /** * The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new - * task, `presetListId` / `parentId` optionally seed the form. + * task, `presetListId` / `parentId` optionally seed the form, and `filterId` + * fills it so the task lands in that saved filter. */ object TaskEdit { const val ARG_TASK_ID = "taskId" @@ -103,12 +127,16 @@ object Dest { const val ARG_PARENT_ID = "parentId" const val ARG_OCCURRENCE = "occ" const val ARG_TITLE = "title" + const val ARG_TAG = "tag" + const val ARG_FILTER_ID = "filterId" const val route = "taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" + "&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" + "&$ARG_PARENT_ID={$ARG_PARENT_ID}" + "&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" + - "&$ARG_TITLE={$ARG_TITLE}" + "&$ARG_TITLE={$ARG_TITLE}" + + "&$ARG_TAG={$ARG_TAG}" + + "&$ARG_FILTER_ID={$ARG_FILTER_ID}" val arguments = listOf( navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID }, @@ -116,17 +144,27 @@ object Dest { navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID }, navArgument(ARG_OCCURRENCE) { type = NavType.LongType; defaultValue = NO_ID }, navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null }, + navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null }, + navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID }, ) fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String = if (occurrenceStart == null) "taskEdit?$ARG_TASK_ID=$taskId" else "taskEdit?$ARG_TASK_ID=$taskId&$ARG_OCCURRENCE=$occurrenceStart" - fun buildNew(presetListId: Long? = null, parentId: Long? = null, title: String? = null): String { + fun buildNew( + presetListId: Long? = null, + parentId: Long? = null, + title: String? = null, + tag: String? = null, + filterId: Long? = null, + ): String { val params = buildList { if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId") if (parentId != null) add("$ARG_PARENT_ID=$parentId") if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}") + if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}") + if (filterId != null) add("$ARG_FILTER_ID=$filterId") } return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}" } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt index d4c94d9..3743cb3 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt @@ -18,8 +18,11 @@ sealed interface NavRequest { /** One account's detail — where "sign in again" lives. */ 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. */ data class OpenSmart(val list: SmartList) : NavRequest @@ -27,6 +30,9 @@ sealed interface NavRequest { /** One real list — a widget header. */ data class OpenList(val listId: Long) : NavRequest + /** One saved filter — a widget header. */ + data class OpenFilter(val filterId: Long) : NavRequest + /** An `.ics` another app handed over. */ data class Import(val uri: Uri) : NavRequest } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt index 6afa7fc..1e100ea 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt @@ -13,7 +13,6 @@ import androidx.compose.material3.Text import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier 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.lifecycle.compose.collectAsStateWithLifecycle 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.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingSpace @@ -47,23 +44,7 @@ fun OnboardingFlow( val state by viewModel.state.collectAsStateWithLifecycle() if (state.step == OnboardingStep.Account) { - // 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, - ) + AccountStep(state, viewModel) return } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt index 91f7a33..7757d30 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt @@ -5,10 +5,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.BuildConfig import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.tasks.TasksRepository 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 kotlinx.coroutines.flow.MutableStateFlow 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 * 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) { accountSlots.value = count @@ -145,11 +145,11 @@ class OnboardingViewModel @Inject constructor( viewModelScope.launch { prefs.setRemindersEnabled(enabled) 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) { connectsAccount.value = connect @@ -192,11 +192,9 @@ class OnboardingViewModel @Inject constructor( step.value = when (step.value) { OnboardingStep.Reminders -> OnboardingStep.Welcome OnboardingStep.ExactAlarms -> OnboardingStep.Reminders - // Once granted there is nothing left to ask, so back skips the step. - OnboardingStep.SyncOffer -> - if (needsExactAlarms.value && !context.canScheduleExactAlarms()) OnboardingStep.ExactAlarms - else OnboardingStep.Reminders - OnboardingStep.FirstList -> OnboardingStep.SyncOffer + OnboardingStep.SyncOffer -> beforeSyncOffer() + OnboardingStep.FirstList -> + if (BuildConfig.SYNC_ENABLED) OnboardingStep.SyncOffer else beforeSyncOffer() 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() { step.value = stepAfterFirstList() } @@ -241,7 +248,7 @@ class OnboardingViewModel @Inject constructor( add(OnboardingStep.Welcome) add(OnboardingStep.Reminders) if (needsExact) add(OnboardingStep.ExactAlarms) - add(OnboardingStep.SyncOffer) + if (BuildConfig.SYNC_ENABLED) add(OnboardingStep.SyncOffer) if (sync) add(OnboardingStep.Account) if (needsList) add(OnboardingStep.FirstList) if (!sync) add(OnboardingStep.Backup) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt index 4c74bc7..3caf328 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons 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.NotificationsActive import androidx.compose.material3.Button @@ -14,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.BuildConfig import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.floret.components.BenefitRow 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), ) Spacer(Modifier.height(OnboardingSpace.sm)) - BenefitRow( - icon = Icons.Rounded.CloudSync, - title = stringResource(R.string.onboarding_welcome_sync_title), - body = stringResource(R.string.onboarding_welcome_sync_body), - ) + if (BuildConfig.SYNC_ENABLED) { + BenefitRow( + icon = Icons.Rounded.CloudSync, + 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), + ) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt index 1bbdf46..601b115 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt @@ -40,13 +40,8 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.BuildConfig 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.imports.ImportScreen import de.jeanlucmakiola.agendula.ui.licences.LicencesScreen @@ -64,7 +59,7 @@ import de.jeanlucmakiola.floret.crash.submitCrashReport import de.jeanlucmakiola.floret.locale.AppLanguage /** The settings sub-screens reached from the hub's category rows. */ -private enum class SettingsSection { +internal enum class SettingsSection { Appearance, TaskForm, Reminders, @@ -111,16 +106,6 @@ fun SettingsScreen( 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 // rather than popping the whole Settings destination to the lists overview. @@ -159,58 +144,19 @@ fun SettingsScreen( SlideInSection(visible = section == SettingsSection.Import) { 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) { LicencesScreen(onBack = { section = null }) } - SlideInSection(visible = section == SettingsSection.Account) { - openAccount?.let { id -> - AccountDetailScreen( - accountId = id, - 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, - ) - } + AccountSections( + section = section, + initialAccountId = initialAccountId, + onSection = { section = it }, + ) } } @Composable -private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) { +internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) { AnimatedVisibility( visible = visible, enter = slideInHorizontally { it } + fadeIn(), @@ -267,17 +213,19 @@ private fun SettingsHub( Spacer(Modifier.height(8.dp)) GroupedSectionHeader(stringResource(R.string.settings_group_data)) - GroupedRow( - title = stringResource(R.string.settings_section_accounts), - summary = stringResource(R.string.settings_accounts_subtitle), - position = Position.Top, - leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) }, - onClick = { onOpenSection(SettingsSection.Accounts) }, - ) + if (BuildConfig.SYNC_ENABLED) { + GroupedRow( + title = stringResource(R.string.settings_section_accounts), + summary = stringResource(R.string.settings_accounts_subtitle), + position = Position.Top, + leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) }, + onClick = { onOpenSection(SettingsSection.Accounts) }, + ) + } GroupedRow( title = stringResource(R.string.settings_section_storage), 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) }, onClick = { onOpenSection(SettingsSection.Storage) }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index e74a509..7171ae1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -241,6 +241,7 @@ class SettingsViewModel @Inject constructor( fun setSnoozeMinutes(minutes: Int) = viewModelScope.launch { prefs.setSnoozeMinutes(minutes) } fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) } 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. */ fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt index d8d7268..cd1c891 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt @@ -99,6 +99,18 @@ internal fun TaskFormScreen( }, 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( title = stringResource(R.string.settings_bottom_add_bar), summary = stringResource(R.string.settings_bottom_add_bar_hint), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index 118c10e..337cd6f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -48,22 +49,30 @@ import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.Flag import androidx.compose.material3.Checkbox +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material.icons.automirrored.rounded.Sort import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.Sell +import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.AlertDialog import androidx.compose.material3.SwipeToDismissBox import androidx.compose.ui.res.pluralStringResource import de.jeanlucmakiola.agendula.domain.TaskSortOrder +import de.jeanlucmakiola.floret.components.revealOnFocus import de.jeanlucmakiola.floret.components.InlineTextField -import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.SelectedCheck import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -131,6 +140,7 @@ fun TaskListScreen( onNewTask: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, + onEditFilter: (Long) -> Unit = {}, viewModel: TaskListViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -139,19 +149,31 @@ fun TaskListScreen( val list = content?.list val listName = list?.name val listId = (filter as? TaskFilter.OfList)?.listId - // Editing is offered for a device-only list. A collection that belongs to an - // account is the server's to rename or remove, not ours. + // Editing is offered for any writable list, device or synced — renaming and + // 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 readOnlyHint by rememberSaveable { mutableStateOf(false) } val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle() val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle() // The list this screen is about is gone; there is nothing left to show. LaunchedEffect(listDeleted) { if (listDeleted) onBack() } + val savedFilter = content?.savedFilter + val filterGone = filter is TaskFilter.Saved && content?.filter == filter && savedFilter == null + LaunchedEffect(filterGone) { if (filterGone) onBack() } LaunchedEffect(listWriteFailure) { if (listWriteFailure != null) { delay(4_000) 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 // bottom quick-add bar; everything else (incl. smart lists, which have no single // target list) gets the floating "New task" button. @@ -168,7 +190,7 @@ fun TaskListScreen( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { MediumTopAppBar( - title = { Text(titleFor(filter, listName)) }, + title = { Text(titleFor(filter, listName ?: savedFilter?.name)) }, navigationIcon = { IconButton(onClick = onBack) { Icon( @@ -179,13 +201,29 @@ fun TaskListScreen( }, actions = { IconButton(onClick = { showSort = true }, enabled = content != null) { - Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title)) + BadgedBox(badge = { if (content?.selectedTags?.isNotEmpty() == true) Badge() }) { + Icon(Icons.Rounded.Tune, contentDescription = stringResource(R.string.view_options_title)) + } } - if (list != null && list.isLocal) { - IconButton(onClick = { editingList = true }) { + if (savedFilter != null) { + IconButton(onClick = { onEditFilter(savedFilter.id) }) { + Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.filter_edit_title)) + } + } + if (list != null) { + IconButton( + onClick = { + if (list.isReadOnly) readOnlyHint = true else editingList = true + }, + ) { Icon( Icons.Rounded.Edit, contentDescription = stringResource(R.string.list_edit_title), + tint = if (list.isReadOnly) { + LocalContentColor.current.copy(alpha = 0.38f) + } else { + LocalContentColor.current + }, ) } } @@ -230,35 +268,41 @@ fun TaskListScreen( .height(SnackChipHeight), contentAlignment = Alignment.CenterStart, ) { - // One chip, one anchor: the undo receipt takes precedence, and a - // refused list write reports itself once the undo window is clear. + // One chip, one anchor, in priority order: the undo receipt first, + // 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 - if (undo != null || failure == null) { - SnackChip( - visible = undo != null, + when { + undo != null -> SnackChip( + visible = true, message = stringResource( if (undo is UndoReceipt.Completed) R.string.task_completed else R.string.task_deleted, ), actionLabel = stringResource(R.string.undo), onAction = viewModel::undo, ) - } else { - SnackChip( + failure != null -> SnackChip( visible = true, message = stringResource(listWriteFailureMessage(failure)), ) + else -> SnackChip( + visible = readOnlyHint, + message = stringResource(R.string.list_read_only), + ) } } } } if (showSort && content != null) { - OptionPicker( - title = stringResource(R.string.sort_title), - options = TaskSortOrder.entries, - selected = content.sortOrder, - label = { stringResource(sortLabel(it)) }, - onSelect = { viewModel.setSortOrder(it); showSort = false }, + ViewOptionsPicker( + sortOrder = content.sortOrder, + tags = content.availableTags, + selectedTags = content.selectedTags, + onSortOrder = viewModel::setSortOrder, + onToggleTag = viewModel::toggleTagFilter, + onClearTags = viewModel::clearTagFilter, onDismiss = { showSort = false }, ) } @@ -300,7 +344,7 @@ fun TaskListScreen( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable private fun TaskListBody( state: TaskListUiState.Content, @@ -325,7 +369,8 @@ private fun TaskListBody( val listId = (filter as? TaskFilter.OfList)?.listId // In a mixed view (a smart list) the list colour bar + name disambiguate // rows; inside a single real list both are redundant. - val showListName = filter is TaskFilter.Smart + val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged || + (filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1) // Completed tasks collapse away by default to keep the active list in focus. var completedExpanded by rememberSaveable { mutableStateOf(false) } // Parents the user expanded to reveal their subtasks inline. Children are @@ -355,8 +400,16 @@ private fun TaskListBody( // When the bottom quick-add bar is shown it already lives in the scaffold's // bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB. val bottomBarShown = state.bottomAddBar && listId != null + // While typing, the viewport ends at the keyboard (or at the quick-add bar + // riding on it), so a focused add-subtask row can scroll fully above it. + val imeVisible = WindowInsets.isImeVisible + val viewport = when { + !imeVisible -> Modifier + bottomBarShown -> Modifier.padding(bottom = inner.calculateBottomPadding()) + else -> Modifier.imePadding() + } LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().then(viewport), contentPadding = PaddingValues( top = inner.calculateTopPadding() + 8.dp, bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp, @@ -635,7 +688,7 @@ internal fun TaskRowContent( val listName = task.listName?.takeIf { showListName && it.isNotBlank() } // Everything secondary collapses into one quiet supporting line below the title. val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 || - listName != null || task.isOccurrence + listName != null || task.isOccurrence || task.tags.isNotEmpty() Surface( onClick = onClick, @@ -690,6 +743,7 @@ internal fun TaskRowContent( subtaskTotal = subtaskTotal, listName = listName, listColor = task.effectiveColor, + tags = task.tags, dark = dark, ) } @@ -721,6 +775,7 @@ private fun TaskMetaLine( subtaskTotal: Int, listName: String?, listColor: Int, + tags: List, dark: Boolean, ) { val muted = MaterialTheme.colorScheme.onSurfaceVariant @@ -776,6 +831,28 @@ private fun TaskMetaLine( } } } + if (tags.isNotEmpty()) { + add { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + Icon( + Icons.Rounded.Sell, + contentDescription = stringResource(R.string.edit_tags_label), + tint = muted, + modifier = Modifier.size(13.dp), + ) + Text( + tags.joinToString(", "), + style = style, + color = muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } if (listName != null) { add { Text( @@ -828,7 +905,7 @@ private fun AddSubtaskRow( shape = cardShape(position, 22.dp, 6.dp), // Same tone as the subtask rows it closes, so the group reads as one unit. color = MaterialTheme.colorScheme.surfaceContainer, - modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap).revealOnFocus(), ) { Row( modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp), @@ -1145,9 +1222,65 @@ private fun CenteredMessage(text: String, inner: PaddingValues) { } } +/** + * Sort and tag filter in one place. Stays open while choosing, since several + * tags can be ticked; back leaves. A task must carry every ticked tag. + */ @Composable -private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) { - is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title) +private fun ViewOptionsPicker( + sortOrder: TaskSortOrder, + tags: List, + selectedTags: Set, + onSortOrder: (TaskSortOrder) -> Unit, + onToggleTag: (String) -> Unit, + onClearTags: () -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker( + title = stringResource(R.string.view_options_title), + onDismiss = onDismiss, + actions = { + if (selectedTags.isNotEmpty()) { + TextButton(onClick = onClearTags) { Text(stringResource(R.string.view_filter_clear)) } + } + }, + ) { + GroupedSectionHeader(stringResource(R.string.sort_title)) + TaskSortOrder.entries.forEachIndexed { index, order -> + val isSelected = order == sortOrder + GroupedRow( + title = stringResource(sortLabel(order)), + position = positionOf(index, TaskSortOrder.entries.size), + selected = isSelected, + trailing = if (isSelected) ({ SelectedCheck() }) else null, + onClick = { onSortOrder(order) }, + ) + } + if (tags.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + GroupedSectionHeader(stringResource(R.string.view_filter_tags)) + tags.forEachIndexed { index, tag -> + val isSelected = tag in selectedTags + GroupedRow( + title = tag, + position = positionOf(index, tags.size), + selected = isSelected, + leading = { Icon(Icons.Rounded.Sell, contentDescription = null) }, + trailing = if (isSelected) ({ SelectedCheck() }) else null, + onClick = { onToggleTag(tag) }, + ) + } + } + } +} + +@Composable +private fun titleFor(filter: TaskFilter, name: String?): String = when (filter) { + is TaskFilter.OfList -> name ?: stringResource(R.string.tasks_title) + is TaskFilter.Saved -> name.orEmpty() + // 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.Tagged -> filter.tag is TaskFilter.Smart -> stringResource( when (filter.list) { SmartList.TODAY -> R.string.smart_today diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index 71f991c..fdf75d7 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -4,11 +4,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository +import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure +import de.jeanlucmakiola.agendula.domain.SavedFilter import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter +import de.jeanlucmakiola.agendula.domain.TaskFiltering +import de.jeanlucmakiola.agendula.domain.tagsInUse import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskSortOrder @@ -55,6 +58,14 @@ sealed interface TaskListUiState { val sortOrder: TaskSortOrder = TaskSortOrder.DUE, /** This real list is kept out of the smart lists. */ val hiddenFromSmartLists: Boolean = false, + /** Tags the unnarrowed view carries, offered as filter chips. */ + val availableTags: List = emptyList(), + /** The tags [tasks] is narrowed to; a task must carry all of them. */ + val selectedTags: Set = emptySet(), + /** The definition behind a [TaskFilter.Saved]; `null` once it's deleted. */ + val savedFilter: SavedFilter? = null, + /** The filter this content was built for; a reused screen briefly holds the previous one's. */ + val filter: TaskFilter? = null, ) : TaskListUiState } @@ -77,12 +88,14 @@ private const val UNDO_WINDOW_MS = 4_000L class TaskListViewModel @Inject constructor( private val repository: TasksRepository, private val settingsPrefs: SettingsPrefs, - private val remoteLists: RemoteListRepository, + private val remoteLists: RemoteLists, @ApplicationScope private val appScope: CoroutineScope, ) : ViewModel() { private val filter = MutableStateFlow(null) + private val tagFilter = MutableStateFlow>(emptySet()) + /** Occurrences swiped to delete but not yet committed — hidden until the undo lapses. */ private val pendingDeletes = MutableStateFlow>(emptyMap()) @@ -101,18 +114,33 @@ class TaskListViewModel @Inject constructor( combine(tasks, repository.taskLists()) { rows, lists -> TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId }) } - is TaskFilter.Smart -> + is TaskFilter.Smart, is TaskFilter.Tagged -> tasks.map { TaskListUiState.Content(it) } + is TaskFilter.Saved -> + repository.filteredTasks(f).map { + TaskListUiState.Content(it.tasks, savedFilter = it.savedFilter) + } + // 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 // while the actual provider delete waits for the snackbar to commit, // and fold in the live UI settings (add-subtask row, bottom add bar). - combine(content, pendingDeletes, settingsPrefs.settings) { st, pending, settings -> + combine(content, pendingDeletes, settingsPrefs.settings, tagFilter) { st, pending, settings, tags -> if (st is TaskListUiState.Content) { + val visible = settings.visibleIn(f, st.tasks).filter { it.occurrenceKey !in pending } + val available = tagsInUse(visible) - setOfNotNull((f as? TaskFilter.Tagged)?.tag) + // A tag that vanished can no longer be unticked, so it is dropped + // for good rather than narrowing again when it comes back. + val selected = tags.filterTo(LinkedHashSet()) { it in available } + if (selected.size != tags.size) tagFilter.value = selected st.copy( - tasks = settings.visibleIn(f, st.tasks) - .filter { it.occurrenceKey !in pending } + filter = f, + tasks = visible + .filter { TaskFiltering.hasAllTags(it, selected) } .sortedWith(settings.sortOrder.comparator()), + availableTags = available, + selectedTags = selected, showAddSubtaskRow = settings.showAddSubtaskRow, bottomAddBar = settings.bottomAddBar, sortOrder = settings.sortOrder, @@ -161,6 +189,12 @@ class TaskListViewModel @Inject constructor( fun bind(taskFilter: TaskFilter) { filter.value = taskFilter } + fun toggleTagFilter(tag: String) { + tagFilter.value = if (tag in tagFilter.value) tagFilter.value - tag else tagFilter.value + tag + } + + fun clearTagFilter() { tagFilter.value = emptySet() } + fun toggleComplete(task: Task) { if (task.isCompleted) { viewModelScope.launch { @@ -298,7 +332,7 @@ class TaskListViewModel @Inject constructor( return@launch } val outcome = runCatching { remoteLists.rename(listId, name.trim(), color) } - .getOrElse { RemoteListRepository.Outcome.Unreachable } + .getOrElse { RemoteLists.Outcome.Unreachable } _listWriteFailure.value = outcome.asFailure(ListWriteFailure.SAVE) } @@ -320,7 +354,7 @@ class TaskListViewModel @Inject constructor( // existing for every other client, and could only be got back by // re-adding the whole account. val outcome = runCatching { remoteLists.delete(listId) } - .getOrElse { RemoteListRepository.Outcome.Unreachable } + .getOrElse { RemoteLists.Outcome.Unreachable } val failure = outcome.asFailure(ListWriteFailure.DELETE) if (failure == null) _listDeleted.value = true else _listWriteFailure.value = failure } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidget.kt new file mode 100644 index 0000000..5708ae1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidget.kt @@ -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?) { + 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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidgetReceiver.kt new file mode 100644 index 0000000..d88ac77 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidgetReceiver.kt @@ -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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt index 4da5e2a..de873dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt @@ -1,17 +1,18 @@ package de.jeanlucmakiola.agendula.widget import android.content.Context +import android.content.Intent 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.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.glance.ColorFilter import androidx.glance.GlanceId import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme -import androidx.glance.Image import androidx.glance.ImageProvider import androidx.glance.LocalContext 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.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity -import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.lazy.LazyColumn import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.state.getAppWidgetState -import androidx.glance.background import androidx.glance.color.ColorProvider import androidx.glance.currentState import androidx.glance.layout.Alignment @@ -44,20 +43,40 @@ import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.text.FontWeight import androidx.glance.text.Text +import androidx.glance.text.TextDecoration import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider as GlanceColorProvider import de.jeanlucmakiola.agendula.MainActivity 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.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.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.formatTime +import de.jeanlucmakiola.floret.glance.R as GlanceR +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlin.time.Instant /** - * "Tasks" home-screen widget: one smart list (Today by default) or one real - * list per instance, with a checkbox to complete each task in place. + * "Tasks" home-screen widget: one smart list (Today by default) or one or more + * 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 `provideGlance` preamble: an `update` on a running session only @@ -68,200 +87,303 @@ class TaskWidget : GlanceAppWidget() { 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) { - val stored = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[WIDGET_FILTER_KEY] - val initialFilter = parseWidgetFilter(stored) - val initial = runCatching { context.taskWidgetData(initialFilter).first() } + val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id) + val initialFilter = parseWidgetFilter(state[WIDGET_FILTER_KEY]) + val initialGroupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false + val initial = runCatching { context.taskWidgetData(initialFilter, initialGroupByList).first() } .getOrDefault(TaskWidgetData.Unavailable) provideContent { val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY)) - val data by remember(filter) { context.taskWidgetData(filter) } - .collectAsState(initial = if (filter == initialFilter) initial else null) + val groupByList = currentState(WIDGET_GROUP_BY_LIST_KEY) ?: false + 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 - AgendulaGlanceTheme( + FloretGlanceTheme( + lightScheme = AgendulaLightFallback, + darkScheme = AgendulaDarkFallback, 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. */ class CompleteTaskAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { val taskId = parameters[TaskIdKey] ?: return - val occurrence = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE } - ?.let { Instant.fromEpochMilliseconds(it) } + val occurrenceMillis = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE } + 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 { - 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 { val TaskIdKey = ActionParameters.Key("taskId") val OccurrenceKey = ActionParameters.Key("occurrenceStart") const val NO_OCCURRENCE = Long.MIN_VALUE + private const val TICK_FRAME_MILLIS = 600L + private const val TICK_RELEASE_MILLIS = 1500L } } @Composable -private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) { +private fun TaskWidgetBody( + filter: TaskFilter, + data: TaskWidgetData?, + background: WidgetBackground, + rowColor: WidgetRowColor, +) { val context = LocalContext.current + val ready = data as? TaskWidgetData.Ready val title = when (filter) { is TaskFilter.Smart -> context.getString(smartListLabel(filter.list)) - is TaskFilter.OfList -> (data as? TaskWidgetData.Ready)?.listName + is TaskFilter.Tagged -> filter.tag + is TaskFilter.Saved -> if (ready == null) { + context.getString(R.string.widget_tasks_label) + } else { + ready.filterName ?: context.getString(R.string.filter_missing) + } + is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName ?: context.getString(R.string.widget_tasks_label) } - Column( - 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( - 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), - ) - } + WidgetSurface(background = background) { Column( - modifier = GlanceModifier.defaultWeight().padding(end = 8.dp, top = 4.dp, bottom = 4.dp) - .clickable(open), + modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp), ) { - Text( - text = title, - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp), - ) - dueText(context, row, use24Hour)?.let { due -> - Text( - text = due, - maxLines = 1, - style = TextStyle( - color = if (row.isOverdue) GlanceTheme.colors.error - else GlanceTheme.colors.onSurfaceVariant, - fontSize = 12.sp, - ), - ) + TaskWidgetHeader(title, filter, ready?.singleListId) + Spacer(GlanceModifier.height(2.dp)) + when (data) { + null -> Unit + TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable)) + is TaskWidgetData.Ready -> + if (data.rows.isEmpty()) { + WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint)) + } else { + val allSections = widgetSections( + data.rows, + data.groupByList, + 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 -private fun Message(text: String) { - Box( - modifier = GlanceModifier.fillMaxSize().padding(16.dp), - contentAlignment = Alignment.Center, +private fun SectionHeader(name: String) { + Text( + text = name, + 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) + is TaskFilter.Tagged -> Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + is TaskFilter.Saved -> MainActivity.filterIntent(context, filter.id) + } + WidgetHeader( + title = title, + titleAction = actionStartActivity(open), ) { - Text( - text = text, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp), + WidgetHeaderAction( + icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), + 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( + 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 relative = when (row.dueKind) { DueKind.TODAY -> context.getString(R.string.home_due_today) DueKind.TOMORROW -> context.getString(R.string.home_due_tomorrow) else -> null } ?: 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" } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt index f248321..7287add 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt @@ -1,18 +1,24 @@ package de.jeanlucmakiola.agendula.widget import android.appwidget.AppWidgetManager +import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Circle import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect @@ -22,6 +28,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.state.getAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState @@ -33,6 +41,7 @@ import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.domain.SavedFilter import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskList @@ -40,17 +49,50 @@ import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow 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.pastelize 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.emptyFlow import kotlinx.coroutines.launch 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 - * the widget is placed (below API 31) and on "reconfigure" from the launcher. + * Picks what one widget instance shows. [WidgetKind.TASKS] picks a smart + * 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 class TaskWidgetConfigActivity : ComponentActivity() { @@ -68,6 +110,7 @@ class TaskWidgetConfigActivity : ComponentActivity() { finish() return } + val kind = widgetKindOf(this, appWidgetId) ?: WidgetKind.TASKS enableEdgeToEdge() val glanceId = GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) val listsFlow = runCatching { repository.taskLists() }.getOrDefault(emptyFlow()) @@ -76,11 +119,21 @@ class TaskWidgetConfigActivity : ComponentActivity() { setContent { val settings by settingsPrefs.settings.collectAsState(initial = Settings()) val lists by remember { listsFlow }.collectAsState(initial = emptyList()) + val savedFilters by settingsPrefs.savedFilters.collectAsState(initial = emptyList()) var current by remember { mutableStateOf(null) } + var groupByList by remember { mutableStateOf(false) } + var background by remember { mutableStateOf(WidgetBackground.SOLID) } + var themeOverride by remember { mutableStateOf(null) } + var rowColor by remember { mutableStateOf(WidgetRowColor.NEUTRAL) } + var selectedListIds by remember { mutableStateOf?>(null) } LaunchedEffect(glanceId) { - current = parseWidgetFilter( - getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)[WIDGET_FILTER_KEY], - ) + val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId) + 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) { ThemeMode.SYSTEM -> isSystemInDarkTheme() @@ -90,15 +143,41 @@ class TaskWidgetConfigActivity : ComponentActivity() { AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} WidgetFilterPicker( + kind = kind, lists = lists.filter { it.isVisible }, + savedFilters = savedFilters, dark = dark, 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 { 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)) finish() } @@ -115,40 +194,224 @@ class TaskWidgetConfigActivity : ComponentActivity() { @Composable private fun WidgetFilterPicker( + kind: WidgetKind, lists: List, + savedFilters: List, selected: TaskFilter, 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?, + onSelectedListIdsChange: (Set?) -> Unit, + onSave: (TaskFilter) -> Unit, onDismiss: () -> Unit, ) { - FullScreenPicker(title = stringResource(R.string.widget_config_title), onDismiss = onDismiss) { - GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists)) - WIDGET_SMART_LISTS.forEachIndexed { index, smart -> - val filter = TaskFilter.Smart(smart) - val isSelected = filter == selected - GroupedRow( - title = stringResource(smartListLabel(smart)), - position = positionOf(index, WIDGET_SMART_LISTS.size), - selected = isSelected, - trailing = if (isSelected) ({ SelectedCheck() }) else null, - onClick = { onSelect(filter) }, - ) - } - if (lists.isNotEmpty()) { + var showBackgroundPicker by remember { mutableStateOf(false) } + var showThemePicker by remember { mutableStateOf(false) } + var showRowColorPicker by remember { mutableStateOf(false) } + // Smart lists, saved filters and real lists are mutually exclusive; only real + // lists support picking more than one. `selectedSmart` or `selectedFilter` + // non-null is that pick; otherwise (or before the stored filter loads) it's + // a list pick. + var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) } + var selectedFilter by remember(selected) { mutableStateOf((selected as? TaskFilter.Saved)?.id) } + var selectedLists by remember(selected) { + mutableStateOf( + when (selected) { + is TaskFilter.OfList -> setOf(selected.listId) + is TaskFilter.OfLists -> selected.listIds + is TaskFilter.Smart, is TaskFilter.Tagged, is TaskFilter.Saved -> emptySet() + }, + ) + } + // A widget whose filter was deleted keeps the dead id until something else is picked. + val liveFilter = selectedFilter?.takeIf { id -> savedFilters.any { it.id == id } } + val canSave = kind != WidgetKind.TASKS || selectedSmart != null || liveFilter != 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) + when { + selectedSmart != null -> onSave(TaskFilter.Smart(selectedSmart!!)) + liveFilter != null -> onSave(TaskFilter.Saved(liveFilter)) + else -> 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; selectedFilter = null; selectedLists = emptySet() }, + ) + } + if (savedFilters.isNotEmpty()) { + GroupedSectionHeader(stringResource(R.string.filters_header)) + savedFilters.forEachIndexed { index, filter -> + val isSelected = filter.id == selectedFilter + GroupedRow( + title = filter.name, + position = positionOf(index, savedFilters.size), + selected = isSelected, + trailing = if (isSelected) ({ SelectedCheck() }) else null, + onClick = { selectedFilter = filter.id; selectedSmart = null; 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 + selectedFilter = 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)) lists.forEachIndexed { index, list -> - val filter = TaskFilter.OfList(list.id) - val isSelected = filter == selected + // `null` means "every list" (the default) until the user narrows it down. + val isSelected = selectedListIds?.let { list.id in it } ?: true 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 = { 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(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 +421,32 @@ internal fun smartListLabel(list: SmartList): Int = when (list) { SmartList.OVERDUE -> R.string.smart_overdue 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetReceiver.kt index e0a0263..112fa3b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetReceiver.kt @@ -4,6 +4,7 @@ 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 [TaskWidget]; also keeps the midnight rollover armed. */ class TaskWidgetReceiver : GlanceAppWidgetReceiver() { @@ -11,17 +12,17 @@ class TaskWidgetReceiver : GlanceAppWidgetReceiver() { override fun onEnabled(context: Context) { super.onEnabled(context) - WidgetRolloverScheduler.sync(context) + WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) } override fun onDisabled(context: 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. */ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { super.onUpdate(context, appWidgetManager, appWidgetIds) - WidgetRolloverScheduler.sync(context) + WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt index 7a3869f..5802a6c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt @@ -1,10 +1,10 @@ package de.jeanlucmakiola.agendula.widget import android.content.Context -import androidx.glance.appwidget.updateAll import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource +import de.jeanlucmakiola.floret.glance.WidgetRollover import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -36,7 +36,9 @@ class TaskWidgetUpdater @Inject constructor( scope.launch(Dispatchers.IO) { requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() } } - scope.launch(Dispatchers.IO) { runCatching { WidgetRolloverScheduler.sync(context) } } + scope.launch(Dispatchers.IO) { + runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) } + } // The process may have been started by a change it never heard about. requestRefresh() } @@ -48,7 +50,8 @@ class TaskWidgetUpdater @Inject constructor( private suspend fun redraw() { runCatching { - if (WidgetRolloverScheduler.hasPlacedWidgets(context)) TaskWidget().updateAll(context) + if (!WidgetRollover.hasPlacedWidgets(context, WIDGET_RECEIVER_CLASSES)) return@runCatching + updateAllWidgets(context) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt new file mode 100644 index 0000000..f5922e1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt @@ -0,0 +1,234 @@ +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.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.GlanceTheme +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.provideContent +import androidx.glance.currentState +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.TextAlign +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.glance.FloretGlanceTheme +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetHeader +import de.jeanlucmakiola.floret.glance.WidgetHeaderAction +import de.jeanlucmakiola.floret.glance.WidgetMessage +import de.jeanlucmakiola.floret.glance.WidgetProgressRing +import de.jeanlucmakiola.floret.glance.WidgetSurface +import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import de.jeanlucmakiola.floret.glance.R as GlanceR +import kotlinx.coroutines.flow.first + +/** + * "Today" home-screen widget: how far through today you are, as a ring — + * done ÷ (done + still open today/overdue) — with today's open tasks listed + * below (or beside, when wide) once the widget has room for them. + */ +class TodayWidget : GlanceAppWidget() { + + override val stateDefinition = PreferencesGlanceStateDefinition + + // Exact, so the ring can grow with the real placement rather than a breakpoint. + 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), WidgetKind.TODAY) + val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_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, + ) { + TodayWidgetBody(data, background) + } + } + } +} + +@Composable +private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground) { + val context = LocalContext.current + val ready = data as? WidgetStatsData.Ready + val size = LocalSize.current + val contentWidth = size.width - Padding * 2 + val contentHeight = size.height - Padding * 2 - HeaderHeight + WidgetSurface(background = background) { + Column(modifier = GlanceModifier.fillMaxSize().padding(Padding)) { + WidgetHeader( + title = context.getString(R.string.smart_today), + titleAction = actionStartActivity(MainActivity.todayIntent(context)), + ) { + WidgetHeaderAction( + icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), + contentDescription = context.getString(R.string.new_task), + action = actionStartActivity(MainActivity.newTaskIntent(context)), + ) + } + Spacer(GlanceModifier.height(HeaderGap)) + when { + data == null -> Unit + ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable)) + else -> { + val tasks = ready.snapshot.todayTasks + val openToday = actionStartActivity(MainActivity.todayIntent(context)) + when { + tasks.isNotEmpty() && contentWidth >= MinSideBySideWidth -> { + val ring = minOf(contentHeight, contentWidth * 0.4f, MaxRing) + Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = GlanceModifier.width(ring).fillMaxHeight().clickable(openToday), + contentAlignment = Alignment.Center, + ) { + TodayRing(ready, ring) + } + Spacer(GlanceModifier.width(12.dp)) + TodayTaskList(ready, GlanceModifier.defaultWeight().fillMaxHeight()) + } + } + tasks.isNotEmpty() && contentHeight >= MinRing + ListGap + RowHeight -> { + val ring = minOf(contentWidth, MaxRing, maxOf(MinRing, contentHeight - ListGap - RowHeight * 2)) + Box( + modifier = GlanceModifier.fillMaxWidth().height(ring).clickable(openToday), + contentAlignment = Alignment.Center, + ) { + TodayRing(ready, ring) + } + Spacer(GlanceModifier.height(ListGap)) + TodayTaskList(ready, GlanceModifier.fillMaxWidth().defaultWeight()) + } + else -> { + val ring = minOf(contentWidth, contentHeight, MaxRing) + Box( + modifier = GlanceModifier.fillMaxSize().clickable(openToday), + contentAlignment = Alignment.Center, + ) { + TodayRing(ready, ring) + } + } + } + } + } + } + } +} + +@Composable +private fun TodayTaskList(ready: WidgetStatsData.Ready, modifier: GlanceModifier) { + val tasks = ready.snapshot.todayTasks + LazyColumn(modifier = modifier) { + items(tasks.size) { index -> + // 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. + Column { + AgendaTaskRow( + row = tasks[index], + use24Hour = ready.snapshot.use24Hour, + color = GlanceTheme.colors.surfaceVariant, + colored = false, + suppressDayLabel = true, + ) + if (index < tasks.lastIndex) Spacer(GlanceModifier.height(6.dp)) + } + } + } +} + +@Composable +private fun TodayRing(ready: WidgetStatsData.Ready, ringSize: Dp) { + val context = LocalContext.current + val snapshot = ready.snapshot + val total = snapshot.todayDone + snapshot.todayRemaining + val allDone = total > 0 && snapshot.todayRemaining == 0 + val progress = if (total == 0) 0f else snapshot.todayDone.toFloat() / total + val indicator = if (allDone) GlanceTheme.colors.tertiary else GlanceTheme.colors.primary + // Label sizes scale with the ring so a large ring doesn't hold a tiny label. + val scale = (ringSize / MinRing).coerceIn(1f, 2f) + WidgetProgressRing( + progress = progress, + size = ringSize, + trackColor = GlanceTheme.colors.surfaceVariant, + indicatorColor = indicator, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = GlanceModifier.padding(horizontal = ringSize * 0.15f)) { + if (total == 0) { + Text( + text = context.getString(R.string.widget_today_nothing_due), + maxLines = 2, + style = TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = (11 * scale).sp, + textAlign = TextAlign.Center, + ), + ) + } else if (allDone) { + Text( + text = context.getString(R.string.widget_today_all_done), + maxLines = 2, + style = TextStyle( + color = GlanceTheme.colors.tertiary, + fontSize = (13 * scale).sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ), + ) + } else { + Text( + text = "${snapshot.todayDone}/$total", + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = (18 * scale).sp, + fontWeight = FontWeight.Medium, + ), + ) + } + } + } +} + +private val Padding = 8.dp +private val HeaderHeight = 36.dp +private val HeaderGap = 4.dp +private val ListGap = 8.dp +private val RowHeight = 48.dp +private val MinRing = 72.dp +private val MaxRing = 160.dp +private val MinSideBySideWidth = 230.dp diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidgetReceiver.kt new file mode 100644 index 0000000..e4ea468 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidgetReceiver.kt @@ -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 [TodayWidget]; also keeps the midnight rollover armed. */ +class TodayWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = TodayWidget() + + 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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt new file mode 100644 index 0000000..ad3098a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt @@ -0,0 +1,410 @@ +package de.jeanlucmakiola.agendula.widget + +import android.content.Context +import android.text.format.DateFormat +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.GlanceTheme +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.color.ColorProvider +import androidx.glance.currentState +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.size +import androidx.glance.layout.width +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextDecoration +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.WidgetHeaderAction +import de.jeanlucmakiola.floret.glance.WidgetMessage +import de.jeanlucmakiola.floret.glance.WidgetShape +import de.jeanlucmakiola.floret.glance.WidgetShapedIconButton +import de.jeanlucmakiola.floret.glance.WidgetSurface +import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import de.jeanlucmakiola.floret.time.formatTime +import de.jeanlucmakiola.floret.glance.R as GlanceR +import kotlinx.coroutines.flow.first +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.time.Clock +import kotlin.time.Instant +import kotlin.time.toJavaInstant + +/** + * "Up next" home-screen widget: the single open task with the nearest due + * moment, big, with a check to complete it in place — a countdown rather + * than a list, for someone who wants to see one thing at a time. Optionally + * narrowed to one list; when tall, the tasks after it are listed below. + */ +class UpNextWidget : GlanceAppWidget() { + + override val stateDefinition = PreferencesGlanceStateDefinition + + 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), WidgetKind.UP_NEXT) + val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY)) + val listId = parseWidgetListIds(currentState(WIDGET_LIST_IDS_KEY))?.singleOrNull() + 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, + ) { + UpNextWidgetBody(data, background, listId) + } + } + } +} + +@Composable +private fun UpNextWidgetBody(data: WidgetStatsData?, background: WidgetBackground, listId: Long?) { + val context = LocalContext.current + val ready = data as? WidgetStatsData.Ready + val size = LocalSize.current + val rows = ready?.snapshot?.upcoming?.let { all -> listId?.let { id -> all.filter { it.listId == id } } ?: all } + val list = listId?.let { id -> ready?.snapshot?.lists?.firstOrNull { it.id == id } } + WidgetSurface(background = background) { + when { + data == null -> Unit + ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp)) + rows.isNullOrEmpty() -> UpNextEmpty(listId) + size.height >= MinTallHeight -> UpNextTall(rows, ready.snapshot, list, size.height) + else -> UpNextCompact(rows.first(), ready.snapshot, size.width, size.height) + } + } +} + +@Composable +private fun UpNextEmpty(listId: Long?) { + val context = LocalContext.current + Row(modifier = GlanceModifier.fillMaxSize().padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + WidgetMessage(context.getString(R.string.widget_up_next_empty), GlanceModifier.defaultWeight()) + Spacer(GlanceModifier.width(8.dp)) + WidgetActionButton( + icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), + contentDescription = context.getString(R.string.new_task), + action = actionStartActivity(MainActivity.newTaskIntent(context, listId)), + style = WidgetActionStyle.FILLED, + size = 36.dp, + ) + } +} + +/** One row tall: a cookie-shaped check leading, then the time as the hero and the title under it. */ +@Composable +private fun UpNextCompact(row: WidgetTaskRow, snapshot: WidgetStatsSnapshot, width: Dp, height: Dp) { + val context = LocalContext.current + val title = row.title.ifBlank { context.getString(R.string.task_untitled) } + val now = Clock.System.now() + val accent = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.primary + val checkSize = (height * 0.45f).coerceIn(32.dp, 44.dp) + val heroSize = (height.value * 0.25f).coerceIn(16f, 22f) + Row( + modifier = GlanceModifier.fillMaxSize().padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CompleteButton(row, title, checkSize) + Spacer(GlanceModifier.width(12.dp)) + Column(modifier = GlanceModifier.defaultWeight().clickable(openTask(context, row))) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = upNextHero(context, row, snapshot.use24Hour), + maxLines = 1, + style = TextStyle(color = accent, fontSize = heroSize.sp, fontWeight = FontWeight.Bold), + ) + if (width >= MinRelativeInlineWidth) { + Spacer(GlanceModifier.width(8.dp)) + RowPill(row, upNextLabel(context, row, now)) + } + } + Text( + text = title, + maxLines = if (height >= 88.dp) 2 else 1, + style = titleStyle(row, 15f), + ) + } + } +} + +/** + * Tall: the list as a caption with the add pill, then a big cookie check + * beside a display-size time and the title, and — with room to spare — + * what comes after it. + */ +@Composable +private fun UpNextTall(rows: List, snapshot: WidgetStatsSnapshot, list: ListTile?, height: Dp) { + val context = LocalContext.current + val row = rows.first() + val title = row.title.ifBlank { context.getString(R.string.task_untitled) } + val now = Clock.System.now() + val accent = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.primary + val heroSize = (height.value * 0.15f).coerceIn(26f, 38f) + val checkSize = (height * 0.2f).coerceIn(44.dp, 56.dp) + val laterCount = ((height - MinTallHeight - 40.dp) / LaterRowHeight).toInt().coerceIn(0, minOf(rows.size - 1, MAX_LATER_ROWS)) + Column(modifier = GlanceModifier.fillMaxSize().padding(12.dp)) { + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + ListDot(row.color) + Spacer(GlanceModifier.width(6.dp)) + Text( + text = list?.name ?: context.getString(R.string.widget_up_next_label), + maxLines = 1, + modifier = GlanceModifier.defaultWeight(), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Bold), + ) + WidgetHeaderAction( + icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), + contentDescription = context.getString(R.string.new_task), + action = actionStartActivity(MainActivity.newTaskIntent(context, list?.id)), + ) + } + Row( + modifier = GlanceModifier.fillMaxWidth().defaultWeight(), + verticalAlignment = Alignment.CenterVertically, + ) { + CompleteButton(row, title, checkSize) + Spacer(GlanceModifier.width(12.dp)) + Column(modifier = GlanceModifier.defaultWeight().clickable(openTask(context, row))) { + Text( + text = upNextHero(context, row, snapshot.use24Hour), + maxLines = 1, + style = TextStyle(color = accent, fontSize = heroSize.sp, fontWeight = FontWeight.Bold), + ) + RowPill(row, upNextLabel(context, row, now)) + Spacer(GlanceModifier.height(6.dp)) + Text( + text = title, + maxLines = if (laterCount > 0) 2 else 3, + style = titleStyle(row, 16f), + ) + } + } + if (laterCount > 0) { + Text( + text = context.getString(R.string.widget_up_next_then), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp, fontWeight = FontWeight.Bold), + ) + Spacer(GlanceModifier.height(4.dp)) + Column { rows.drop(1).take(laterCount).forEach { later -> LaterRow(later, snapshot.use24Hour) } } + } + } +} + +/** The cookie check; while [WidgetCompletions] holds the row it bursts into a filled sunny shape. */ +@Composable +private fun CompleteButton(row: WidgetTaskRow, title: String, size: Dp) { + val context = LocalContext.current + val completing = WidgetCompletions.isCompleting(row) + WidgetShapedIconButton( + shape = if (completing) WidgetShape.SUNNY else WidgetShape.COOKIE, + icon = ImageProvider(GlanceR.drawable.floret_ic_widget_check), + contentDescription = context.getString(R.string.widget_complete_task, title), + action = completeTask(row), + color = when { + completing -> GlanceTheme.colors.tertiary + row.isOverdue -> GlanceTheme.colors.errorContainer + else -> GlanceTheme.colors.primaryContainer + }, + iconTint = when { + completing -> GlanceTheme.colors.onTertiary + row.isOverdue -> GlanceTheme.colors.onErrorContainer + else -> GlanceTheme.colors.onPrimaryContainer + }, + size = size, + ) +} + +@Composable +private fun RelativePill(text: String, overdue: Boolean, done: Boolean = false) { + val container = when { + done -> GlanceTheme.colors.tertiaryContainer + overdue -> GlanceTheme.colors.errorContainer + else -> GlanceTheme.colors.secondaryContainer + } + val content = when { + done -> GlanceTheme.colors.onTertiaryContainer + overdue -> GlanceTheme.colors.onErrorContainer + else -> GlanceTheme.colors.onSecondaryContainer + } + Box( + modifier = GlanceModifier.cornerRadius(10.dp) + .background(container) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + text = text, + maxLines = 1, + style = TextStyle(color = content, fontSize = 11.sp, fontWeight = FontWeight.Bold), + ) + } +} + +/** The relative pill's text, or "Done" while the row is being ticked off. */ +@Composable +private fun RowPill(row: WidgetTaskRow, relative: String) { + val context = LocalContext.current + if (WidgetCompletions.isCompleting(row)) { + RelativePill(context.getString(R.string.reminder_action_done), row.isOverdue, done = true) + } else if (relative.isNotEmpty()) { + RelativePill(relative, row.isOverdue) + } +} + +@Composable +private fun titleStyle(row: WidgetTaskRow, fontSize: Float): TextStyle { + val completing = WidgetCompletions.isCompleting(row) + return TextStyle( + color = if (completing) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface, + fontSize = fontSize.sp, + fontWeight = FontWeight.Medium, + textDecoration = if (completing) TextDecoration.LineThrough else TextDecoration.None, + ) +} + +@Composable +private fun LaterRow(row: WidgetTaskRow, use24Hour: Boolean) { + val context = LocalContext.current + Row( + modifier = GlanceModifier.fillMaxWidth().height(LaterRowHeight).clickable(openTask(context, row)), + verticalAlignment = Alignment.CenterVertically, + ) { + ListDot(row.color) + Spacer(GlanceModifier.width(8.dp)) + Text( + text = row.title.ifBlank { context.getString(R.string.task_untitled) }, + maxLines = 1, + modifier = GlanceModifier.defaultWeight(), + style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp), + ) + Text( + text = upNextHero(context, row, use24Hour), + maxLines = 1, + style = TextStyle( + color = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.onSurfaceVariant, + fontSize = 12.sp, + ), + ) + } +} + +@Composable +private fun ListDot(color: Int) { + Box( + modifier = GlanceModifier.size(8.dp).cornerRadius(4.dp) + .background(ColorProvider(day = pastelize(color, false), night = pastelize(color, true))), + ) {} +} + +private fun openTask(context: Context, row: WidgetTaskRow): Action = + actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds())) + +private fun completeTask(row: WidgetTaskRow): Action = + actionRunCallback( + actionParametersOf( + CompleteTaskAction.TaskIdKey to row.taskId, + CompleteTaskAction.OccurrenceKey to (row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE), + ), + ) + +/** + * The big line: a timed task's clock time (with its weekday once it's past + * tomorrow), or an all-day task's day. + */ +internal fun upNextHero(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String { + val due = row.due ?: return "" + val locale = context.resources.configuration.locales[0] + val day = DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, "EEEd"), locale) + .format(due.toJavaInstant().atZone(ZoneId.systemDefault())) + if (row.isAllDay) { + return when (row.dueKind) { + DueKind.TODAY -> context.getString(R.string.smart_today) + DueKind.TOMORROW -> context.getString(R.string.widget_up_next_tomorrow) + else -> day + } + } + val time = due.formatTime(use24Hour) + return if (row.dueKind == DueKind.LATER && !row.isOverdue) "$day $time" else time +} + +/** + * The relative line beside [upNextHero]: "in 3 h", "Tomorrow", "2 d overdue". + * Coarse on purpose — this widget only redraws on data changes, at midnight, + * and on the half-hourly `updatePeriodMillis`, so a minute-level countdown + * would just look wrong between redraws. + */ +internal fun upNextLabel(context: Context, row: WidgetTaskRow, now: Instant): String { + val due = row.due ?: return "" + if (row.isAllDay && !row.isOverdue) return if (row.dueKind == DueKind.LATER) daysUntil(context, due, now) else "" + if (row.isOverdue) { + val hours = (now - due).inWholeHours + return when { + hours < 1 -> context.getString(R.string.widget_up_next_soon) + hours < HOURS_PER_DAY -> context.resources.getQuantityString(R.plurals.widget_up_next_overdue_hours, hours.toInt(), hours.toInt()) + else -> { + val days = hours / HOURS_PER_DAY + context.resources.getQuantityString(R.plurals.widget_up_next_overdue_days, days.toInt(), days.toInt()) + } + } + } + if (row.dueKind == DueKind.TOMORROW) return context.getString(R.string.widget_up_next_tomorrow) + val hours = (due - now).inWholeHours + return when { + hours < 1 -> context.getString(R.string.widget_up_next_soon) + hours < HOURS_PER_DAY -> context.resources.getQuantityString(R.plurals.widget_up_next_in_hours, hours.toInt(), hours.toInt()) + else -> daysUntil(context, due, now) + } +} + +private fun daysUntil(context: Context, due: Instant, now: Instant): String { + val days = ((due - now).inWholeHours / HOURS_PER_DAY).toInt().coerceAtLeast(1) + return context.resources.getQuantityString(R.plurals.widget_up_next_in_days, days, days) +} + +private const val HOURS_PER_DAY = 24L +private const val MAX_LATER_ROWS = 10 +private val MinTallHeight = 150.dp +private val MinRelativeInlineWidth = 200.dp +private val LaterRowHeight = 24.dp diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidgetReceiver.kt new file mode 100644 index 0000000..7a7fca5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidgetReceiver.kt @@ -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 [UpNextWidget]; also keeps the midnight rollover armed. */ +class UpNextWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = UpNextWidget() + + 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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidget.kt new file mode 100644 index 0000000..c1c3c7e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidget.kt @@ -0,0 +1,177 @@ +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.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +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.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.currentState +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.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import de.jeanlucmakiola.agendula.MainActivity +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.SmartList +import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback +import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback +import de.jeanlucmakiola.floret.glance.FloretGlanceTheme +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 java.time.LocalDate +import java.time.format.TextStyle as JavaTextStyle +import java.util.Locale + +/** + * "Week" home-screen widget: a seven-day strip, one tile per day, each with + * its open-task count — the one shape none of the app's other widgets or + * screens offer (they're all "one list" or "one task" shaped). + */ +class WeekWidget : GlanceAppWidget() { + + override val stateDefinition = PreferencesGlanceStateDefinition + + // Exact, so the day count can follow the real width. + 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 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, + ) { + WeekWidgetBody(data, background) + } + } + } +} + +@Composable +private fun WeekWidgetBody(data: WidgetStatsData?, background: WidgetBackground) { + val context = LocalContext.current + val ready = data as? WidgetStatsData.Ready + WidgetSurface(background = background) { + when { + data == null -> Unit + ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp)) + else -> { + // As many days as fit at a comfortable width, up to the week. + // Gaps come from each cell's padding, not Spacers: Glance caps a + // Row at 10 children. + val days = ((LocalSize.current.width - 8.dp) / MinDayWidth).toInt().coerceIn(1, ready.snapshot.week.size) + Row(modifier = GlanceModifier.fillMaxSize().padding(4.dp)) { + ready.snapshot.week.take(days).forEach { day -> + // Glance has no margin — padding on the tile itself lands inside its fill. + Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(horizontal = 2.dp)) { + WeekDayCell(day, GlanceModifier.fillMaxSize()) + } + } + } + } + } + } +} + +@Composable +private fun WeekDayCell(day: WeekDay, modifier: GlanceModifier) { + val context = LocalContext.current + val today = isToday(day.date) + val background = if (today) GlanceTheme.colors.primaryContainer else GlanceTheme.colors.surfaceVariant + val onBackground = if (today) GlanceTheme.colors.onPrimaryContainer else GlanceTheme.colors.onSurfaceVariant + val open = if (today) { + actionStartActivity(MainActivity.todayIntent(context)) + } else { + actionStartActivity(MainActivity.smartListIntent(context, SmartList.UPCOMING)) + } + val badgeBackground = if (today) GlanceTheme.colors.primary else GlanceTheme.colors.surface + val onBadgeBackground = if (today) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurfaceVariant + WidgetTile(color = background, modifier = modifier, onClick = open) { + Column(modifier = GlanceModifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + val locale = context.resources.configuration.locales[0] ?: Locale.ROOT + Spacer(GlanceModifier.defaultWeight()) + Box( + modifier = GlanceModifier + .size(38.dp) + .cornerRadius(19.dp) + .background(badgeBackground), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = day.date.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale), + maxLines = 1, + style = TextStyle(color = onBadgeBackground, fontSize = 10.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center), + ) + Text( + text = "${day.date.dayOfMonth}", + style = TextStyle(color = onBadgeBackground, fontSize = 13.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), + ) + } + } + Spacer(GlanceModifier.height(3.dp)) + // Always rendered, even at 0 — an omitted count row made every + // other day's badge drift up or down relative to its neighbours. + // The checklist glyph (not a single checkmark) is what reads this + // as "N tasks" rather than an unlabelled, ambiguous number. + val countColor = if (day.overdueCount > 0) GlanceTheme.colors.error else onBackground + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + provider = ImageProvider(GlanceR.drawable.floret_ic_widget_checklist), + contentDescription = null, + colorFilter = ColorFilter.tint(countColor), + modifier = GlanceModifier.size(14.dp), + ) + Spacer(GlanceModifier.width(2.dp)) + Text( + text = "${day.count}", + style = TextStyle(color = countColor, fontSize = 15.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), + ) + } + Spacer(GlanceModifier.defaultWeight()) + } + } +} + +private fun isToday(date: LocalDate): Boolean = date == java.time.LocalDate.now() + +private val MinDayWidth = 64.dp diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidgetReceiver.kt new file mode 100644 index 0000000..4445458 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidgetReceiver.kt @@ -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 [WeekWidget]; also keeps the midnight rollover armed. */ +class WeekWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = WeekWidget() + + 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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetCompletions.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetCompletions.kt new file mode 100644 index 0000000..2b93695 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetCompletions.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.agendula.widget + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * Rows ticked in a widget but not yet completed in the store. Widgets can't + * animate, so [CompleteTaskAction] shows the row as done for a beat before + * completing it — every widget drawing that row reads this to render the + * ticked frame. + */ +internal object WidgetCompletions { + private val keys = MutableStateFlow>(emptySet()) + + fun key(taskId: Long, occurrenceStart: Long?): String = "$taskId@${occurrenceStart ?: ""}" + + /** Marks [key] as completing; `false` if it already was (a double tap). */ + fun start(key: String): Boolean { + var added = false + keys.update { current -> if (key in current) current else current + key.also { added = true } } + return added + } + + fun finish(key: String) = keys.update { it - key } + + @Composable + fun isCompleting(row: WidgetTaskRow): Boolean = + key(row.taskId, row.occurrenceStart?.toEpochMilliseconds()) in keys.collectAsState().value +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt index 5a36905..e1c0c34 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt @@ -4,12 +4,15 @@ import android.content.Context import android.text.format.DateFormat import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.is24Hour +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.calendarDate import de.jeanlucmakiola.agendula.domain.comparator +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetThemeMode import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine @@ -21,20 +24,84 @@ import kotlin.time.Instant /** Per-instance Glance state key holding the widget's [TaskFilter] ([widgetStorageValue]). */ internal val WIDGET_FILTER_KEY = stringPreferencesKey("task_filter") +/** Per-instance Glance state key: group a multi-list widget's rows under list headers. */ +internal val WIDGET_GROUP_BY_LIST_KEY = booleanPreferencesKey("group_by_list") + +/** Per-instance Glance state key holding the widget's [WidgetBackground] ([name]); unset = [WidgetBackground.SOLID]. */ +internal val WIDGET_BACKGROUND_KEY = stringPreferencesKey("widget_background") + +/** Per-instance Glance state key holding a [WidgetThemeMode] override; unset = follow the app's own theme setting. */ +internal val WIDGET_THEME_KEY = stringPreferencesKey("widget_theme") + +/** Per-instance Glance state key holding the widget's [WidgetRowColor] ([name]); unset = [WidgetRowColor.NEUTRAL]. */ +internal val WIDGET_ROW_COLOR_KEY = stringPreferencesKey("widget_row_color") + +/** Per-instance Glance state key holding the Lists widget's chosen list ids, comma-joined; unset = every visible list. */ +internal val WIDGET_LIST_IDS_KEY = stringPreferencesKey("widget_list_ids") + +/** `null` means "every visible list" — the Lists widget's default. */ +internal fun parseWidgetListIds(raw: String?): Set? = + raw?.split(",")?.mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }?.toSet()?.takeIf { it.isNotEmpty() } + +internal fun Set.widgetListIdsStorageValue(): String = joinToString(",") + internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY) +/** How a row's tonal container is tinted: the neutral surface role, or the task's own list colour. */ +internal enum class WidgetRowColor { NEUTRAL, BY_LIST } + +internal fun parseWidgetRowColor(raw: String?): WidgetRowColor = + raw?.let { runCatching { WidgetRowColor.valueOf(it) }.getOrNull() } ?: WidgetRowColor.NEUTRAL + +internal fun parseWidgetBackground(raw: String?): WidgetBackground = + raw?.let { runCatching { WidgetBackground.valueOf(it) }.getOrNull() } ?: WidgetBackground.SOLID + +/** + * [WidgetBackground]s a widget offers. Today and Up next draw their hero (ring + * label, time and title) straight on the surface rather than in containers, so + * on [WidgetBackground.TRANSPARENT] it would sit unreadable on the wallpaper. + */ +internal fun WidgetKind.backgrounds(): List = when (this) { + WidgetKind.TODAY, WidgetKind.UP_NEXT -> WidgetBackground.entries - WidgetBackground.TRANSPARENT + else -> WidgetBackground.entries +} + +/** [raw] parsed, falling back to SOLID for a stored value [kind] no longer offers. */ +internal fun parseWidgetBackground(raw: String?, kind: WidgetKind): WidgetBackground = + parseWidgetBackground(raw).takeIf { it in kind.backgrounds() } ?: WidgetBackground.SOLID + +/** `null` means "follow the app's own theme setting" — resolved by the caller against [TaskWidgetData.Ready.themeMode]. */ +internal fun parseWidgetThemeOverride(raw: String?): WidgetThemeMode? = + raw?.let { runCatching { WidgetThemeMode.valueOf(it) }.getOrNull() } + +internal fun TaskWidgetData.Ready.effectiveThemeMode(override: WidgetThemeMode?): WidgetThemeMode = + effectiveThemeMode(themeMode, override) + +/** [override], or [appThemeMode] translated to its Glance counterpart — shared by every widget's data type. */ +internal fun effectiveThemeMode(appThemeMode: ThemeMode, override: WidgetThemeMode?): WidgetThemeMode = + override ?: when (appThemeMode) { + ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM + ThemeMode.LIGHT -> WidgetThemeMode.LIGHT + ThemeMode.DARK -> WidgetThemeMode.DARK + } + /** The smart lists a widget can show, in picker order. */ internal val WIDGET_SMART_LISTS = listOf(SmartList.TODAY, SmartList.UPCOMING, SmartList.OVERDUE, SmartList.ALL) /** * Upper bound on rows, so the serialized RemoteViews stays well inside the - * binder transaction limit however long the list is. + * binder transaction limit however long the list is. Lower than the old flat + * list's 60: each row is now wrapped in its own tonal container, so the + * per-row view count is higher for the same visual density (calendula#214). */ -internal const val MAX_WIDGET_ROWS = 60 +internal const val MAX_WIDGET_ROWS = 40 internal fun TaskFilter.widgetStorageValue(): String = when (this) { is TaskFilter.OfList -> "list:$listId" + is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}" is TaskFilter.Smart -> "smart:${list.name}" + is TaskFilter.Tagged -> error("A tag has no widget") + is TaskFilter.Saved -> "filter:$id" } /** Anything unreadable, or a smart list the widget doesn't offer, falls back to Today. */ @@ -43,6 +110,11 @@ internal fun parseWidgetFilter(raw: String?): TaskFilter { return when { value.startsWith("list:") -> value.removePrefix("list:").toLongOrNull()?.takeIf { it > 0 }?.let { TaskFilter.OfList(it) } + value.startsWith("lists:") -> + value.removePrefix("lists:").split(",").mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } } + .toSet().takeIf { it.isNotEmpty() }?.let { TaskFilter.OfLists(it) } + value.startsWith("filter:") -> + value.removePrefix("filter:").toLongOrNull()?.takeIf { it > 0 }?.let { TaskFilter.Saved(it) } value.startsWith("smart:") -> WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) } else -> null @@ -69,6 +141,7 @@ internal fun isOverdue(due: Instant, allDay: Boolean, now: Instant, today: Local data class WidgetTaskRow( val taskId: Long, + val listId: Long, val occurrenceStart: Instant?, val title: String, val due: Instant?, @@ -84,66 +157,148 @@ sealed interface TaskWidgetData { data class Ready( val filter: TaskFilter, - /** The real list's name; `null` for a smart list or a list that no longer exists. */ - val listName: String?, + /** Names of the real lists [rows] can belong to, by list id. */ + val listNames: Map, val rows: List, + /** Group [rows] under a header per list (only meaningful with several lists). */ + val groupByList: Boolean, val dynamicColor: Boolean, val themeMode: ThemeMode, val use24Hour: Boolean, - ) : TaskWidgetData + /** The saved filter's name when [filter] is one; `null` once it's deleted. */ + val filterName: String? = null, + ) : TaskWidgetData { + /** The one list's id, when [filter] names exactly one real list — seeds a widget's add button. */ + val singleListId: Long? = when (filter) { + is TaskFilter.OfList -> filter.listId + is TaskFilter.OfLists -> filter.listIds.singleOrNull() + is TaskFilter.Smart, is TaskFilter.Tagged, is TaskFilter.Saved -> null + } + + /** [singleListId]'s name. */ + val singleListName: String? = singleListId?.let(listNames::get) + + val overdueCount: Int = rows.count { it.isOverdue } + } +} + +/** A run of rows in the widget's list: one task, or a run's header. */ +internal sealed interface WidgetSection { + data class Header(val key: String, val name: String) : WidgetSection + data class TaskItem(val row: WidgetTaskRow) : WidgetSection +} + +/** One headed run: its header's stable [key] and display [name], and the rows under it. */ +private data class WidgetRun(val key: String, val name: String, val rows: List) + +/** Each [WidgetRun] as its own [WidgetSection.Header] followed by its rows. */ +private fun headedRuns(runs: List): List = + runs.flatMap { run -> listOf(WidgetSection.Header(run.key, run.name)) + run.rows.map(WidgetSection::TaskItem) } + +/** + * [rows] split into headed runs: one per list when [groupByList] is on + * (already sorted that way by [widgetRows]), otherwise one per [DueKind] + * bucket — "Overdue" / "Today" / "Tomorrow" / "Later" / "No date" — in that + * fixed order, since [rows] isn't due-sorted. Each run becomes one header + * followed by its own standalone, independently coloured task pills. + */ +internal fun widgetSections( + rows: List, + groupByList: Boolean, + listNames: Map, + dueBucketLabel: (DueKind?) -> String, +): List { + if (groupByList) { + val runs = mutableListOf() + var current: MutableList? = null + for (row in rows) { + if (current == null || runs.last().key != "list:${row.listId}") { + current = mutableListOf() + runs += WidgetRun("list:${row.listId}", listNames[row.listId].orEmpty(), current) + } + current.add(row) + } + return headedRuns(runs) + } + val buckets = rows.groupBy { it.dueKind } + val runs = DueKind.entries.plus(null).mapNotNull { kind -> + buckets[kind]?.takeIf { it.isNotEmpty() } + ?.let { WidgetRun("due:${kind?.name.orEmpty()}", dueBucketLabel(kind), it) } + } + return headedRuns(runs) } /** - * Open rows for [tasks], sorted the way the app sorts them. A subtask whose - * parent is also shown is left out, as on the list screen. + * Open rows for [tasks], sorted the way the app sorts them, then grouped by list + * when [groupByList] is on (stable, so each list's own order survives). A + * subtask whose parent is also shown is left out, as on the list screen. */ internal fun widgetRows( tasks: List, comparator: Comparator, now: Instant, zone: ZoneId, + groupByList: Boolean, + listNames: Map, ): List { val open = tasks.filterNot { it.isClosed } val present = open.mapTo(HashSet()) { it.taskId } val today = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate() - return open + val sorted = open .filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present } .sortedWith(comparator) - .take(MAX_WIDGET_ROWS) - .map { task -> - WidgetTaskRow( - taskId = task.taskId, - occurrenceStart = task.occurrenceStart, - title = task.title, - due = task.due, - isAllDay = task.isAllDay, - dueKind = task.due?.let { dueKind(it, task.isAllDay, today, zone) }, - isOverdue = task.due?.let { isOverdue(it, task.isAllDay, now, today, zone) } ?: false, - color = task.effectiveColor, - ) + .let { + if (groupByList) { + it.sortedWith(compareBy({ task -> listNames[task.listId].orEmpty() }, { task -> task.listId })) + } else it } + return sorted.take(MAX_WIDGET_ROWS).map { it.toWidgetRow(now, today, zone) } } +/** This task as a [WidgetTaskRow], resolving its due bucket and overdue state against [today]. */ +internal fun Task.toWidgetRow(now: Instant, today: LocalDate, zone: ZoneId): WidgetTaskRow = WidgetTaskRow( + taskId = taskId, + listId = listId, + occurrenceStart = occurrenceStart, + title = title, + due = due, + isAllDay = isAllDay, + dueKind = due?.let { dueKind(it, isAllDay, today, zone) }, + isOverdue = due?.let { isOverdue(it, isAllDay, now, today, zone) } ?: false, + color = effectiveColor, +) + /** * Live widget data for [filter]. Re-emits on every store change, since the * repository's flows observe the store; a store that can't be read (permission * revoked, provider uninstalled) reads as [TaskWidgetData.Unavailable]. */ -internal fun Context.taskWidgetData(filter: TaskFilter): Flow { +internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): Flow { val ep = widgetEntryPoint() val repository = ep.tasksRepository() return combine( - repository.tasks(filter), + repository.filteredTasks(filter), repository.taskLists(), ep.settingsPrefs().settings, - ) { tasks, lists, settings -> + ) { filtered, lists, settings -> + val tasks = filtered.tasks + val listNames = lists.associate { it.id to it.name } val data: TaskWidgetData = TaskWidgetData.Ready( filter = filter, - listName = (filter as? TaskFilter.OfList)?.let { f -> lists.firstOrNull { it.id == f.listId }?.name }, - rows = widgetRows(settings.visibleIn(filter, tasks), settings.sortOrder.comparator(), Clock.System.now(), ZoneId.systemDefault()), + listNames = listNames, + rows = widgetRows( + settings.visibleIn(filter, tasks), + settings.sortOrder.comparator(), + Clock.System.now(), + ZoneId.systemDefault(), + groupByList, + listNames, + ), + groupByList = groupByList, dynamicColor = settings.dynamicColor, themeMode = settings.themeMode, use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)), + filterName = filtered.savedFilter?.name, ) data }.catch { emit(TaskWidgetData.Unavailable) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverScheduler.kt deleted file mode 100644 index 2c646c4..0000000 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverScheduler.kt +++ /dev/null @@ -1,83 +0,0 @@ -package de.jeanlucmakiola.agendula.widget - -import android.app.AlarmManager -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import androidx.core.content.getSystemService -import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.plus -import kotlinx.datetime.toLocalDateTime -import kotlin.time.Clock -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.seconds -import kotlin.time.Instant - -/** - * The app's own wake-up for the next local midnight, so "Today" on the widget - * flips over on the day boundary. `ACTION_DATE_CHANGED` isn't delivered to - * manifest receivers since Android 8, which is why this exists. - * - * One inexact alarm at a time, re-armed by every firing: `setAndAllowWhileIdle` - * needs no permission and survives doze, and a rollover a few minutes late is - * invisible on a sleeping screen. - */ -object WidgetRolloverScheduler { - - /** Fire just after midnight, so an early delivery doesn't still read the old date. */ - internal val ROLLOVER_SLACK = 5.seconds - - /** Arm the next rollover, or cancel it when no widget is placed. Idempotent. */ - fun sync(context: Context) { - val appContext = context.applicationContext - val alarmManager = appContext.getSystemService() ?: return - val pendingIntent = rolloverPendingIntent(appContext) - if (!hasPlacedWidgets(appContext)) { - alarmManager.cancel(pendingIntent) - return - } - val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault()) - alarmManager.setAndAllowWhileIdle( - AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent, - ) - } - - /** - * The instant just after the next local midnight following [now] in [zone]. - * Uses the actual start of day, so it holds where DST skips midnight or a - * date-line move skips a whole date. - */ - fun nextRolloverAt(now: Instant, zone: TimeZone): Instant { - val date = now.toLocalDateTime(zone).date - var days = 1 - while (days <= MAX_LOOKAHEAD_DAYS) { - val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK - if (candidate > now) return candidate - days++ - } - // Unreachable for any real zone; an hour rather than the slack so it can't loop. - return now + 1.hours - } - - fun hasPlacedWidgets(context: Context): Boolean { - val manager = AppWidgetManager.getInstance(context) ?: return false - return manager.getAppWidgetIds(ComponentName(context, TaskWidgetReceiver::class.java)).isNotEmpty() - } - - private fun rolloverPendingIntent(context: Context): PendingIntent = - PendingIntent.getBroadcast( - context, - ROLLOVER_REQUEST_CODE, - Intent(context, WidgetUpdateReceiver::class.java) - .setAction(WidgetUpdateReceiver.ACTION_ROLLOVER), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - - private const val ROLLOVER_REQUEST_CODE = 0x0DA1 - - private const val MAX_LOOKAHEAD_DAYS = 3 -} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt new file mode 100644 index 0000000..e048b2a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt @@ -0,0 +1,152 @@ +package de.jeanlucmakiola.agendula.widget + +import android.content.Context +import android.text.format.DateFormat +import de.jeanlucmakiola.agendula.data.prefs.ThemeMode +import de.jeanlucmakiola.agendula.data.prefs.is24Hour +import de.jeanlucmakiola.agendula.domain.SmartList +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskFilter +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.calendarDate +import de.jeanlucmakiola.agendula.domain.comparator +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import java.time.LocalDate +import java.time.ZoneId +import kotlin.time.Clock +import kotlin.time.Instant + +/** One day of the Week widget's strip. [first] is that day's earliest task, in sort order. */ +internal data class WeekDay(val date: LocalDate, val count: Int, val overdueCount: Int, val first: WidgetTaskRow?) + +/** One tile of the Lists widget. */ +internal data class ListTile(val id: Long, val name: String, val color: Int, val openCount: Int, val acceptsWrites: Boolean) + +internal data class WidgetStatsSnapshot( + val todayDone: Int, + val todayRemaining: Int, + val todayTasks: List, + /** Open dated tasks, earliest due first — Up next shows the head, optionally narrowed to one list. */ + val upcoming: List, + val week: List, + val lists: List, + val dynamicColor: Boolean, + val themeMode: ThemeMode, + val use24Hour: Boolean, +) + +internal sealed interface WidgetStatsData { + /** No readable store yet, mirroring [TaskWidgetData.Unavailable]. */ + data object Unavailable : WidgetStatsData + data class Ready(val snapshot: WidgetStatsSnapshot) : WidgetStatsData +} + +private const val MAX_TODAY_ROWS = 20 + +/** + * How far through today a set of [open] and [completed] tasks says you are: + * tasks done today (by [Task.completedAt]'s local date), tasks still open that + * are overdue or due today (in [comparator] order), and those tasks as rows. + */ +internal fun todayProgress( + open: List, + completed: List, + comparator: Comparator, + now: Instant, + today: LocalDate, + zone: ZoneId, +): Triple> { + val done = completed.count { task -> + val at = task.completedAt ?: return@count false + at.toLocalDate(zone) == today + } + val remaining = open.filter { task -> + val due = task.due ?: return@filter false + isOverdue(due, task.isAllDay, now, today, zone) || dueKind(due, task.isAllDay, today, zone) == DueKind.TODAY + }.sortedWith(comparator) + return Triple(done, remaining.size, remaining.take(MAX_TODAY_ROWS).map { it.toWidgetRow(now, today, zone) }) +} + +/** Open dated tasks by earliest due moment, overdue included; undated tasks are skipped. */ +internal fun upcoming(open: List, now: Instant, today: LocalDate, zone: ZoneId): List = + open.filter { it.due != null }.sortedBy { it.due!! }.take(MAX_UPCOMING_ROWS).map { it.toWidgetRow(now, today, zone) } + +private const val MAX_UPCOMING_ROWS = 200 + +/** + * Seven [WeekDay]s starting at [today]. Today's bucket folds in every overdue + * task regardless of its own due day, since that's where the app's own + * Overdue/Today smart lists put it; every other day matches its own due date. + */ +internal fun weekDays(open: List, comparator: Comparator, now: Instant, today: LocalDate, zone: ZoneId): List = + (0..6L).map { offset -> + val date = today.plusDays(offset) + val dayTasks = open.filter { task -> + val due = task.due ?: return@filter false + if (offset == 0L) { + isOverdue(due, task.isAllDay, now, today, zone) || due.calendarDate(task.isAllDay, zone) == date + } else { + due.calendarDate(task.isAllDay, zone) == date + } + } + val overdueCount = if (offset == 0L) { + dayTasks.count { isOverdue(it.due!!, it.isAllDay, now, today, zone) } + } else { + 0 + } + val first = dayTasks.sortedWith(comparator).firstOrNull()?.toWidgetRow(now, today, zone) + WeekDay(date, dayTasks.size, overdueCount, first) + } + +/** One tile per visible list: its open, non-subtask count — a subtask whose parent is also open is left out. */ +internal fun listTiles(open: List, lists: List): List { + val present = open.mapTo(HashSet()) { it.taskId } + val counted = open.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present } + val counts = counted.groupingBy { it.listId }.eachCount() + return lists.filter { it.isVisible }.map { list -> + ListTile(list.id, list.name, list.color, counts[list.id] ?: 0, list.acceptsWrites) + } +} + +private fun Instant.toLocalDate(zone: ZoneId): LocalDate = + java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate() + +/** + * Live stats for the Today, Up next, Lists and Week widgets — the ALL and + * COMPLETED smart lists, filtered the same way the app's own smart lists are + * ([de.jeanlucmakiola.agendula.data.prefs.Settings.visibleIn]). + */ +internal fun Context.widgetStats(): Flow { + val ep = widgetEntryPoint() + val repository = ep.tasksRepository() + return combine( + repository.tasks(TaskFilter.Smart(SmartList.ALL)), + repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)), + repository.taskLists(), + ep.settingsPrefs().settings, + ) { openRaw, completedRaw, lists, settings -> + val open = settings.visibleIn(TaskFilter.Smart(SmartList.ALL), openRaw) + val completed = settings.visibleIn(TaskFilter.Smart(SmartList.ALL), completedRaw) + val now = Clock.System.now() + val zone = ZoneId.systemDefault() + val today = now.toLocalDate(zone) + val comparator = settings.sortOrder.comparator() + val (done, remaining, todayTasks) = todayProgress(open, completed, comparator, now, today, zone) + val data: WidgetStatsData = WidgetStatsData.Ready( + WidgetStatsSnapshot( + todayDone = done, + todayRemaining = remaining, + todayTasks = todayTasks, + upcoming = upcoming(open, now, today, zone), + week = weekDays(open, comparator, now, today, zone), + lists = listTiles(open, lists), + dynamicColor = settings.dynamicColor, + themeMode = settings.themeMode, + use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)), + ), + ) + data + }.catch { emit(WidgetStatsData.Unavailable) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetTheme.kt deleted file mode 100644 index 5dcc3d7..0000000 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetTheme.kt +++ /dev/null @@ -1,39 +0,0 @@ -package de.jeanlucmakiola.agendula.widget - -import android.os.Build -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.runtime.Composable -import androidx.glance.GlanceTheme -import androidx.glance.LocalContext -import androidx.glance.material3.ColorProviders -import de.jeanlucmakiola.agendula.data.prefs.ThemeMode -import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback -import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback - -private val AgendulaGlanceColors = ColorProviders( - light = AgendulaLightFallback, - dark = AgendulaDarkFallback, -) - -/** - * Glance counterpart of `AgendulaTheme`: Material You on API 31+ when dynamic - * colour is on, the brand fallback schemes otherwise. On [ThemeMode.SYSTEM] it - * draws only from colour-role tokens, so light/dark follows the system without a - * redraw; a forced light or dark theme pins one scheme for both. - */ -@Composable -fun AgendulaGlanceTheme(dynamicColor: Boolean, themeMode: ThemeMode, content: @Composable () -> Unit) { - val dynamic = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - val context = LocalContext.current - val colors = when (themeMode) { - ThemeMode.SYSTEM -> if (dynamic) GlanceTheme.colors else AgendulaGlanceColors - ThemeMode.LIGHT -> ColorProviders( - if (dynamic) dynamicLightColorScheme(context) else AgendulaLightFallback, - ) - ThemeMode.DARK -> ColorProviders( - if (dynamic) dynamicDarkColorScheme(context) else AgendulaDarkFallback, - ) - } - GlanceTheme(colors = colors, content = content) -} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt index ceb1cab..c49ff28 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt @@ -1,56 +1,54 @@ package de.jeanlucmakiola.agendula.widget -import android.content.BroadcastReceiver +import android.app.PendingIntent import android.content.Context import android.content.Intent import androidx.glance.appwidget.updateAll -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch +import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver +import de.jeanlucmakiola.floret.glance.WidgetRollover + +/** Every home-screen widget's receiver class — the set [WidgetRollover] checks for placed instances. */ +internal val WIDGET_RECEIVER_CLASSES: List> = listOf( + TaskWidgetReceiver::class.java, + TodayWidgetReceiver::class.java, + UpNextWidgetReceiver::class.java, + ListsWidgetReceiver::class.java, + WeekWidgetReceiver::class.java, +) + +/** Redraws every placed home-screen widget. */ +internal suspend fun updateAllWidgets(context: Context) { + TaskWidget().updateAll(context) + TodayWidget().updateAll(context) + UpNextWidget().updateAll(context) + ListsWidget().updateAll(context) + WeekWidget().updateAll(context) +} + +internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.agendula.widget.ROLLOVER" + +private const val ROLLOVER_REQUEST_CODE = 0x0DA1 + +internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( + context, + ROLLOVER_REQUEST_CODE, + Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, +) /** - * Redraws the task widget when its day or its language goes stale, and keeps the - * midnight rollover armed. Task data changes don't come through here — see + * Redraws every widget when its day or language goes stale, and keeps the + * midnight rollover armed — the app-specific glue around + * [FloretWidgetUpdateReceiver]. Task data changes don't come through here — see * [TaskWidgetUpdater]. - * - * Exported for the system broadcasts; an extra redraw from another app is harmless. */ -class WidgetUpdateReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action !in HANDLED_ACTIONS) return - val appContext = context.applicationContext - val rearm = intent.action in REARM_ACTIONS - // The host redraws the widget itself after these two. - val redraw = intent.action !in REARM_ONLY_ACTIONS - val pending = goAsync() - CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { - try { - if (rearm) WidgetRolloverScheduler.sync(appContext) - if (redraw && WidgetRolloverScheduler.hasPlacedWidgets(appContext)) { - TaskWidget().updateAll(appContext) - } - } finally { - pending.finish() - } - } - } +class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() { + override val rolloverAction = ACTION_WIDGET_ROLLOVER + override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES - companion object { - const val ACTION_ROLLOVER = "de.jeanlucmakiola.agendula.widget.ROLLOVER" + override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context) - private val REARM_ONLY_ACTIONS = setOf( - Intent.ACTION_BOOT_COMPLETED, - Intent.ACTION_MY_PACKAGE_REPLACED, - ) - - internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf( - ACTION_ROLLOVER, - Intent.ACTION_TIME_CHANGED, - Intent.ACTION_TIMEZONE_CHANGED, - Intent.ACTION_DATE_CHANGED, - ) - - internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_LOCALE_CHANGED + override suspend fun redraw(context: Context) { + updateAllWidgets(context) } } diff --git a/app/src/main/res/drawable/ic_widget_add.xml b/app/src/main/res/drawable/ic_widget_add.xml deleted file mode 100644 index 4797a8b..0000000 --- a/app/src/main/res/drawable/ic_widget_add.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_widget_checkbox.xml b/app/src/main/res/drawable/ic_widget_checkbox.xml deleted file mode 100644 index 8ffa575..0000000 --- a/app/src/main/res/drawable/ic_widget_checkbox.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/preview_add_bg.xml b/app/src/main/res/drawable/preview_circle.xml similarity index 52% rename from app/src/main/res/drawable/preview_add_bg.xml rename to app/src/main/res/drawable/preview_circle.xml index 7bd7cac..da2ecc2 100644 --- a/app/src/main/res/drawable/preview_add_bg.xml +++ b/app/src/main/res/drawable/preview_circle.xml @@ -1,5 +1,6 @@ + - + diff --git a/app/src/main/res/drawable/preview_pill.xml b/app/src/main/res/drawable/preview_pill.xml new file mode 100644 index 0000000..8b5d6ed --- /dev/null +++ b/app/src/main/res/drawable/preview_pill.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/preview_ring.xml b/app/src/main/res/drawable/preview_ring.xml new file mode 100644 index 0000000..823026e --- /dev/null +++ b/app/src/main/res/drawable/preview_ring.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/drawable/preview_row.xml b/app/src/main/res/drawable/preview_row.xml new file mode 100644 index 0000000..85af09e --- /dev/null +++ b/app/src/main/res/drawable/preview_row.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/preview_stripe.xml b/app/src/main/res/drawable/preview_stripe.xml deleted file mode 100644 index 222a3f6..0000000 --- a/app/src/main/res/drawable/preview_stripe.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/preview_tile.xml b/app/src/main/res/drawable/preview_tile.xml new file mode 100644 index 0000000..6fd86b9 --- /dev/null +++ b/app/src/main/res/drawable/preview_tile.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/widget_preview_lists.xml b/app/src/main/res/layout/widget_preview_lists.xml new file mode 100644 index 0000000..6bfce84 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_lists.xml @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_tasks.xml b/app/src/main/res/layout/widget_preview_tasks.xml index d9d14bc..2eb60fc 100644 --- a/app/src/main/res/layout/widget_preview_tasks.xml +++ b/app/src/main/res/layout/widget_preview_tasks.xml @@ -7,69 +7,66 @@ android:layout_height="match_parent" android:orientation="vertical" android:background="@drawable/preview_widget_bg" - android:padding="12dp" + android:paddingHorizontal="8dp" + android:paddingVertical="4dp" tools:ignore="HardcodedText,UseCompoundDrawables,UseAppTint"> + android:textSize="16sp" /> + android:background="@drawable/preview_pill" + android:backgroundTint="@color/widget_preview_primary" + android:src="@drawable/floret_ic_widget_add" + android:tint="@color/widget_preview_on_primary" /> + android:gravity="center_vertical" + android:background="@drawable/preview_row" + android:backgroundTint="@color/widget_preview_surface_variant" + android:paddingHorizontal="8dp" + android:paddingVertical="6dp"> - - @@ -77,36 +74,38 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" + android:minHeight="40dp" android:orientation="horizontal" - android:gravity="center_vertical"> + android:gravity="center_vertical" + android:background="@drawable/preview_row" + android:backgroundTint="@color/widget_preview_surface_variant" + android:paddingHorizontal="8dp" + android:paddingVertical="6dp"> - @@ -116,36 +115,38 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" + android:minHeight="40dp" android:orientation="horizontal" - android:gravity="center_vertical"> + android:gravity="center_vertical" + android:background="@drawable/preview_row" + android:backgroundTint="@color/widget_preview_surface_variant" + android:paddingHorizontal="8dp" + android:paddingVertical="6dp"> - diff --git a/app/src/main/res/layout/widget_preview_today.xml b/app/src/main/res/layout/widget_preview_today.xml new file mode 100644 index 0000000..1cdc2e4 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_today.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_up_next.xml b/app/src/main/res/layout/widget_preview_up_next.xml new file mode 100644 index 0000000..44ccd8b --- /dev/null +++ b/app/src/main/res/layout/widget_preview_up_next.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_week.xml b/app/src/main/res/layout/widget_preview_week.xml new file mode 100644 index 0000000..fc83894 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_week.xml @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night-v31/widget_preview_colors.xml b/app/src/main/res/values-night-v31/widget_preview_colors.xml index f5f5e1f..9e74283 100644 --- a/app/src/main/res/values-night-v31/widget_preview_colors.xml +++ b/app/src/main/res/values-night-v31/widget_preview_colors.xml @@ -6,4 +6,6 @@ @android:color/system_accent1_200 @android:color/system_accent1_700 @android:color/system_accent1_100 + @android:color/system_neutral2_700 + @android:color/system_accent1_800 diff --git a/app/src/main/res/values-night/widget_preview_colors.xml b/app/src/main/res/values-night/widget_preview_colors.xml index eda3004..0c2a055 100644 --- a/app/src/main/res/values-night/widget_preview_colors.xml +++ b/app/src/main/res/values-night/widget_preview_colors.xml @@ -6,4 +6,6 @@ #E2BAC9 #583B49 #F6D9E5 + #49454F + #402533 diff --git a/app/src/main/res/values-v31/widget_preview_colors.xml b/app/src/main/res/values-v31/widget_preview_colors.xml index bba3eab..c6a8a21 100644 --- a/app/src/main/res/values-v31/widget_preview_colors.xml +++ b/app/src/main/res/values-v31/widget_preview_colors.xml @@ -6,4 +6,6 @@ @android:color/system_accent1_600 @android:color/system_accent1_100 @android:color/system_accent1_900 + @android:color/system_neutral2_100 + @android:color/system_accent1_0 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f89e585..06da004 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,6 +39,9 @@ Done reordering Drag to reorder Sort by + Sort and filter + Filter by tag + Clear filter Due date Priority Title @@ -185,6 +188,9 @@ Location Link https://… + Tags + Add a tag + Remove tag %1$s Discard changes? Your changes to this task haven’t been saved. Discard @@ -212,6 +218,31 @@ Lists + Tags + Filters + Combine due dates, lists, tags and priority into a view of your own + New filter + Edit filter + Filter name + Due + Priority + Lists + Tags + Any due date + Any list + Any tag, or none + Any priority + Low and higher + Medium and higher + High only + Match all tags + A task needs every tag you picked + A task needs one of the tags you picked + Delete filter + Delete “%1$s”? + Only the filter goes. Its tasks stay where they are. + Delete + Filter deleted New task Could not read your tasks. No task lists yet. @@ -305,6 +336,8 @@ A notification when something is due, on your schedule. Your server, or none at all Sync over CalDAV with a server you choose, or keep everything on this device. + No internet, by design + This version of Agendula has no network access, so it cannot sync or connect to anything itself. Sync with your own server? @@ -430,6 +463,8 @@ First available list Show \"add a subtask\" row An add field at the end of an expanded task\'s subtasks + Default start to today + Pre-fill a new task\'s start with today\'s date Bottom quick-add bar Add tasks from a bar pinned to the bottom of a list, instead of the floating button @@ -755,9 +790,68 @@ Tasks Today\'s tasks, or any list, with a tap to tick them off - Nothing to do here + All caught up + Nothing left to do, enjoy it Open Agendula to finish setting up Complete %1$s Show in widget Smart lists + Group by list + Show each list\'s tasks under its own heading + Overdue + Later + No date + Background + Solid + Fills the widget with the theme\'s surface colour + Tinted + A softer, tonal fill + Transparent + No fill — tasks stay readable in their own containers + Theme + Follow app + Light + Dark + Row colour + Neutral + Every row uses the same tonal fill + By list + Each row is tinted with its list\'s own colour + Today + A progress ring for today\'s tasks + All done + Nothing due today + + %1$d of %2$d done + %1$d of %2$d done + + Up next + Your single nearest due task + Nothing due + Soon + Tomorrow + Then + All lists + + in %1$d h + in %1$d h + + + in %1$d d + in %1$d d + + + %1$d h overdue + %1$d h overdue + + + %1$d d overdue + %1$d d overdue + + Lists + A shortcut tile per list, with its open count + No lists to show + Lists to show + Week + A seven-day strip of task counts diff --git a/app/src/main/res/values/widget_preview_colors.xml b/app/src/main/res/values/widget_preview_colors.xml index 8999c11..09fbe2c 100644 --- a/app/src/main/res/values/widget_preview_colors.xml +++ b/app/src/main/res/values/widget_preview_colors.xml @@ -8,4 +8,6 @@ #6A4E5B #F6D9E5 #2A101C + #E7E0EC + #FFFFFF diff --git a/app/src/main/res/xml/appwidget_info_lists.xml b/app/src/main/res/xml/appwidget_info_lists.xml new file mode 100644 index 0000000..acde34e --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_lists.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/main/res/xml/appwidget_info_today.xml b/app/src/main/res/xml/appwidget_info_today.xml new file mode 100644 index 0000000..db701a6 --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_today.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/main/res/xml/appwidget_info_up_next.xml b/app/src/main/res/xml/appwidget_info_up_next.xml new file mode 100644 index 0000000..101b93b --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_up_next.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/main/res/xml/appwidget_info_week.xml b/app/src/main/res/xml/appwidget_info_week.xml new file mode 100644 index 0000000..273960c --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_week.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/offline/AndroidManifest.xml b/app/src/offline/AndroidManifest.xml new file mode 100644 index 0000000..1382de1 --- /dev/null +++ b/app/src/offline/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt b/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt new file mode 100644 index 0000000..1a3d7fc --- /dev/null +++ b/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt @@ -0,0 +1,36 @@ +package de.jeanlucmakiola.agendula.data.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.agendula.data.sync.RemoteLists +import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome +import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity +import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener +import de.jeanlucmakiola.agendula.data.sync.SyncRequests + +/** + * The `offline` flavor has no CalDAV, no accounts and no network permission, so + * every list is device-only and the seams `main` declares are inert. + */ +@Module +@InstallIn(SingletonComponent::class) +object SyncModule { + + @Provides + fun provideLocalWriteListener(): LocalWriteListener = LocalWriteListener.NONE + + @Provides + fun provideSyncRequests(): SyncRequests = SyncRequests { } + + @Provides + fun provideRemoteLists(): RemoteLists = NoRemoteLists +} + +private object NoRemoteLists : RemoteLists { + override suspend fun creatableAccounts(): List = emptyList() + override suspend fun create(accountId: Long, name: String, color: Int): Outcome = Outcome.NoAccount + override suspend fun rename(listId: Long, name: String, color: Int): Outcome = Outcome.NoAccount + override suspend fun delete(listId: Long): Outcome = Outcome.NoAccount +} diff --git a/app/src/offline/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt b/app/src/offline/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt new file mode 100644 index 0000000..5613966 --- /dev/null +++ b/app/src/offline/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt @@ -0,0 +1,10 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.compose.runtime.Composable + +internal const val ACCOUNT_STEP_SLOTS = 0 + +/** Never reached: without sync the flow has no [OnboardingStep.SyncOffer] to lead here. */ +@Suppress("UNUSED_PARAMETER") +@Composable +internal fun AccountStep(state: OnboardingUiState, viewModel: OnboardingViewModel) = Unit diff --git a/app/src/offline/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt b/app/src/offline/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt new file mode 100644 index 0000000..bf4acd8 --- /dev/null +++ b/app/src/offline/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt @@ -0,0 +1,12 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import androidx.compose.runtime.Composable + +/** No accounts in the `offline` flavor; the hub hides the row that leads here. */ +@Suppress("UNUSED_PARAMETER") +@Composable +internal fun AccountSections( + section: SettingsSection?, + initialAccountId: Long?, + onSection: (SettingsSection?) -> Unit, +) = Unit diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/prefs/SavedFilterCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/prefs/SavedFilterCodecTest.kt new file mode 100644 index 0000000..7f6b38c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/prefs/SavedFilterCodecTest.kt @@ -0,0 +1,92 @@ +package de.jeanlucmakiola.agendula.data.prefs + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.DueWindow +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.SavedFilter +import de.jeanlucmakiola.agendula.domain.TagMatch +import org.junit.jupiter.api.Test + +class SavedFilterCodecTest { + + private val filter = SavedFilter( + id = 3, + name = "Work | today, 100%", + due = setOf(DueWindow.TODAY, DueWindow.OVERDUE), + listIds = setOf(4L, 9L), + tags = setOf("a,b", "größe", "x|y"), + tagMatch = TagMatch.ALL, + minPriority = Priority.MEDIUM, + ) + + @Test + fun `a filter round-trips, separators and all`() { + val raw = SavedFilterCodec.upsert(null, filter, "OWN") + assertThat(SavedFilterCodec.decode(raw, "OWN")).containsExactly(filter) + } + + @Test + fun `upsert replaces by id and keeps the order`() { + val first = filter.copy(id = 1, name = "one") + var raw = SavedFilterCodec.upsert(null, first, "OWN") + raw = SavedFilterCodec.upsert(raw, filter, "OWN") + raw = SavedFilterCodec.upsert(raw, first.copy(name = "renamed"), "OWN") + assertThat(SavedFilterCodec.decode(raw, "OWN").map { it.name }).containsExactly("renamed", filter.name).inOrder() + } + + @Test + fun `list ids belong to their store`() { + val raw = SavedFilterCodec.upsert(null, filter, "OWN") + val elsewhere = SavedFilterCodec.decode(raw, "EXTERNAL").single() + assertThat(elsewhere.listIds).isEmpty() + assertThat(elsewhere.listsElsewhere).isTrue() + assertThat(SavedFilterCodec.decode(raw, "OWN").single().listsElsewhere).isFalse() + } + + @Test + fun `choosing any list in one store drops the other store's lists`() { + var raw = SavedFilterCodec.upsert(null, filter, "OWN") + raw = SavedFilterCodec.upsert(raw, filter.copy(listIds = emptySet(), listsElsewhere = false), "EXTERNAL") + assertThat(SavedFilterCodec.decode(raw, "OWN").single().anyList).isTrue() + } + + @Test + fun `unticking the last list here keeps the other store's lists`() { + var raw = SavedFilterCodec.upsert(null, filter, "OWN") + raw = SavedFilterCodec.upsert(raw, filter.copy(listIds = setOf(1L)), "EXTERNAL") + val external = SavedFilterCodec.decode(raw, "EXTERNAL").single() + assertThat(external.listsElsewhere).isTrue() + raw = SavedFilterCodec.upsert(raw, external.copy(listIds = emptySet()), "EXTERNAL") + assertThat(SavedFilterCodec.decode(raw, "OWN").single().listIds).containsExactly(4L, 9L) + assertThat(SavedFilterCodec.decode(raw, "EXTERNAL").single().anyList).isFalse() + } + + @Test + fun `saving untouched in the other store keeps the lists`() { + var raw = SavedFilterCodec.upsert(null, filter, "OWN") + val elsewhere = SavedFilterCodec.decode(raw, "EXTERNAL").single() + raw = SavedFilterCodec.upsert(raw, elsewhere.copy(name = "renamed"), "EXTERNAL") + assertThat(SavedFilterCodec.decode(raw, "OWN").single().listIds).containsExactly(4L, 9L) + } + + @Test + fun `editing in one store keeps the other store's lists`() { + var raw = SavedFilterCodec.upsert(null, filter, "OWN") + raw = SavedFilterCodec.upsert(raw, filter.copy(listIds = setOf(1L)), "EXTERNAL") + assertThat(SavedFilterCodec.decode(raw, "OWN").single().listIds).containsExactly(4L, 9L) + assertThat(SavedFilterCodec.decode(raw, "EXTERNAL").single().listIds).containsExactly(1L) + } + + @Test + fun `remove drops only that filter`() { + var raw = SavedFilterCodec.upsert(null, filter.copy(id = 1), "OWN") + raw = SavedFilterCodec.upsert(raw, filter, "OWN") + assertThat(SavedFilterCodec.decode(SavedFilterCodec.remove(raw, 1), "OWN").map { it.id }).containsExactly(3L) + } + + @Test + fun `broken lines are skipped`() { + val raw = "nonsense\n" + SavedFilterCodec.upsert(null, filter, "OWN") + "\n7|short\n8|bad%zz||||ANY|NONE" + assertThat(SavedFilterCodec.decode(raw, "OWN")).containsExactly(filter) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt index 4ec590e..2ede4d0 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt @@ -148,6 +148,17 @@ class TaskWriteMapperTest { assertThat(undone[Tasks.COMPLETED]).isNull() } + @Test + fun `category row carries only the name, so the provider resolves the category itself`() { + val values = TaskWriteMapper.categoryValues(taskId = 12L, name = "Work") + + assertThat(values).containsExactly( + TasksContract.Properties.TASK_ID, 12L, + TasksContract.Properties.MIMETYPE, "vnd.android.cursor.item/category", + TasksContract.Category.NAME, "Work", + ) + } + @Test fun `alarm carries every column the provider's validator demands`() { val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt index 9f08ba0..b2e1c5e 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt @@ -303,6 +303,65 @@ class VTodoMapperTest { } } + @Nested + inner class Categories { + + @Test + fun `a single plain line is claimed into the column`() { + val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work,Home\\, Garden")) + assertThat(mapped.entity.categories).containsExactly("Work", "Home, Garden").inOrder() + assertThat(mapped.entity.unknownProperties.orEmpty()).doesNotContain("CATEGORIES") + } + + @Test + fun `several lines are read but round-trip from the residue`() { + val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work", "CATEGORIES:Errands")) + assertThat(mapped.entity.categories).containsExactly("Work", "Errands").inOrder() + + val written = VTodoMapper.write(mapped.entity).properties("CATEGORIES").map { it.value } + assertThat(written).containsExactly("Work", "Errands") + } + + @Test + fun `a line the column would not reproduce exactly stays in the residue`() { + val mapped = VTodoMapper.read(vtodo("CATEGORIES;LANGUAGE=de:Arbeit", "SUMMARY:x")) + assertThat(mapped.entity.categories).containsExactly("Arbeit") + assertThat(VTodoMapper.read(vtodo("CATEGORIES:a, b")).entity.unknownProperties) + .contains("CATEGORIES:a, b") + + val written = VTodoMapper.write(mapped.entity).properties("CATEGORIES") + assertThat(written.single().param("LANGUAGE")).isEqualTo("de") + } + + @Test + fun `editing the tags evicts the residue copies and authors one line`() { + val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work", "CATEGORIES:Errands")) + val edited = mapped.entity.copy(categories = listOf("Work", "Home")) + + val written = VTodoMapper.write(edited).properties("CATEGORIES") + assertThat(written.single().value).isEqualTo("Work,Home") + assertThat(written.single().params).isEmpty() + } + + @Test + fun `no tags writes no line`() { + assertThat(VTodoMapper.write(entity()).properties("CATEGORIES")).isEmpty() + } + + @Test + fun `a duplicated or blank tag is dropped and the line stays in the residue`() { + val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work, ,Work")) + assertThat(mapped.entity.categories).containsExactly("Work") + assertThat(VTodoMapper.write(mapped.entity).properties("CATEGORIES").single().value).isEqualTo("Work, ,Work") + } + + @Test + fun `an unclean column is cleaned on the way out`() { + val written = VTodoMapper.write(entity().copy(categories = listOf(" a ", "", "a", "b"))) + assertThat(written.properties("CATEGORIES").single().value).isEqualTo("a,b") + } + } + @Nested inner class Validation { diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt index 48b0895..55e7c6b 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt @@ -39,6 +39,8 @@ class VTodoRoundTripTest { "mixed-value-types", "malformed-scalars", "two-timezones", + "categories-shapes", + "categories-split", ) @TestFactory diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLiftTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLiftTest.kt new file mode 100644 index 0000000..8fd047d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLiftTest.kt @@ -0,0 +1,35 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class CategoriesLiftTest { + + @Test + fun `claims a lone plain line and keeps the rest of the residue`() { + val result = CategoriesLift.lift("X-FOO:bar\r\nCATEGORIES:Work,Home\r\n") + assertThat(result.categories).containsExactly("Work", "Home").inOrder() + assertThat(result.claimed).isTrue() + assertThat(result.residue).contains("X-FOO:bar") + assertThat(result.residue).doesNotContain("CATEGORIES") + } + + @Test + fun `a residue that was only the line becomes null`() { + assertThat(CategoriesLift.lift("CATEGORIES:Work\r\n").residue).isNull() + } + + @Test + fun `several lines are read but not claimed`() { + val residue = "CATEGORIES:a\r\nCATEGORIES:b\r\n" + val result = CategoriesLift.lift(residue) + assertThat(result.categories).containsExactly("a", "b").inOrder() + assertThat(result.claimed).isFalse() + assertThat(result.residue).isEqualTo(residue) + } + + @Test + fun `nothing to lift from an empty residue`() { + assertThat(CategoriesLift.lift(null)).isEqualTo(CategoriesLift.Result(emptyList(), null, claimed = false)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt index 2ef8067..3c8272c 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt @@ -50,4 +50,12 @@ class ConvertersTest { } assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE) } + + @Test + fun `categories round-trip with escaped commas and come back cleaned`() { + val stored = CategoriesConverter.toText(listOf("a,b", " c ", "", "c")) + assertThat(stored).isEqualTo("a\\,b,c") + assertThat(CategoriesConverter.fromText(stored)).containsExactly("a,b", "c").inOrder() + assertThat(CategoriesConverter.fromText("")).isEmpty() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt index 7a1ed8b..b64fb59 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt @@ -29,6 +29,7 @@ private fun form( start: Instant? = null, due: Instant? = null, isAllDay: Boolean = false, + tags: List = emptyList(), ) = TaskForm( title = title, listId = 1, @@ -36,6 +37,7 @@ private fun form( start = start, due = due, isAllDay = isAllDay, + tags = tags, ) class TaskFormWriterTest { @@ -51,6 +53,12 @@ class TaskFormWriterTest { assertThat(entity.isDirty).isTrue() } + @Test + fun `tags are trimmed and blanks dropped`() { + val entity = TaskFormWriter.apply(task(), form(tags = listOf(" Work ", "", " ", "Home")), NOW, ZONE) + assertThat(entity.categories).containsExactly("Work", "Home").inOrder() + } + @Test fun `progress and status move together in both directions`() { assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/SavedFilterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/SavedFilterTest.kt new file mode 100644 index 0000000..05b54e1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/SavedFilterTest.kt @@ -0,0 +1,119 @@ +package de.jeanlucmakiola.agendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.ZoneId +import kotlin.time.Instant + +class SavedFilterTest { + + private val dayMs = 86_400_000L + private val todayStart = Instant.fromEpochMilliseconds(1000 * dayMs) + private val todayEnd = Instant.fromEpochMilliseconds(1001 * dayMs) + private val overdue = Instant.fromEpochMilliseconds(999 * dayMs) + private val today = Instant.fromEpochMilliseconds(1000 * dayMs + 1) + private val upcoming = Instant.fromEpochMilliseconds(1002 * dayMs) + + private fun SavedFilter.matches(task: Task) = matches(task, todayStart, todayEnd) + + private val empty = SavedFilter(id = 1, name = "f") + + @Test + fun `an empty filter is every open task`() { + assertThat(empty.matches(testTask(due = null))).isTrue() + assertThat(empty.matches(testTask(due = overdue))).isTrue() + assertThat(empty.matches(testTask(status = TaskStatus.COMPLETED))).isFalse() + assertThat(empty.matches(testTask(status = TaskStatus.CANCELLED))).isFalse() + } + + @Test + fun `today and overdue together`() { + val filter = empty.copy(due = setOf(DueWindow.TODAY, DueWindow.OVERDUE)) + assertThat(filter.matches(testTask(due = overdue))).isTrue() + assertThat(filter.matches(testTask(due = today))).isTrue() + assertThat(filter.matches(testTask(due = upcoming))).isFalse() + assertThat(filter.matches(testTask(due = null))).isFalse() + } + + @Test + fun `lists narrow by membership`() { + val filter = empty.copy(listIds = setOf(2L, 3L)) + assertThat(filter.matches(testTask(listId = 2))).isTrue() + assertThat(filter.matches(testTask(listId = 1))).isFalse() + } + + @Test + fun `lists from another store match nothing here`() { + val filter = empty.copy(listsElsewhere = true) + assertThat(filter.matches(testTask(listId = 1))).isFalse() + assertThat(filter.copy(listIds = setOf(1L)).matches(testTask(listId = 1))).isTrue() + } + + @Test + fun `priority is a floor`() { + val filter = empty.copy(minPriority = Priority.MEDIUM) + assertThat(filter.matches(testTask(priority = Priority.HIGH))).isTrue() + assertThat(filter.matches(testTask(priority = Priority.MEDIUM))).isTrue() + assertThat(filter.matches(testTask(priority = Priority.LOW))).isFalse() + assertThat(filter.matches(testTask(priority = Priority.NONE))).isFalse() + } + + @Test + fun `tags match any or all`() { + val any = empty.copy(tags = setOf("work", "home")) + val all = any.copy(tagMatch = TagMatch.ALL) + val one = testTask(tags = listOf("work")) + val both = testTask(tags = listOf("home", "work", "x")) + assertThat(any.matches(one)).isTrue() + assertThat(any.matches(testTask(tags = listOf("x")))).isFalse() + assertThat(all.matches(one)).isFalse() + assertThat(all.matches(both)).isTrue() + } + + @Test + fun `criteria combine with and`() { + val filter = empty.copy(due = setOf(DueWindow.TODAY), listIds = setOf(1L), tags = setOf("work")) + assertThat(filter.matches(testTask(listId = 1, due = today, tags = listOf("work")))).isTrue() + assertThat(filter.matches(testTask(listId = 2, due = today, tags = listOf("work")))).isFalse() + assertThat(filter.matches(testTask(listId = 1, due = today))).isFalse() + } + + @Test + fun `an all-day due is read as its date west of utc`() { + val zone = ZoneId.of("America/New_York") + val date = LocalDate.of(2026, 10, 10) + val start = Instant.fromEpochMilliseconds(date.atStartOfDay(zone).toInstant().toEpochMilli()) + val end = Instant.fromEpochMilliseconds(date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()) + fun window(d: LocalDate) = dueWindowOf(testTask(due = allDayInstantOf(d), isAllDay = true), start, end, zone) + assertThat(window(date)).isEqualTo(DueWindow.TODAY) + assertThat(window(date.plusDays(1))).isEqualTo(DueWindow.UPCOMING) + assertThat(window(date.minusDays(1))).isEqualTo(DueWindow.OVERDUE) + } + + @Test + fun `a new task preset lands in its filter`() { + val day = LocalDate.of(2026, 10, 10) + val filter = empty.copy( + due = setOf(DueWindow.UPCOMING), + tags = setOf("work", "home"), + tagMatch = TagMatch.ALL, + minPriority = Priority.HIGH, + ) + val preset = filter.newTaskPreset(day) + assertThat(preset.dueDate).isEqualTo(day.plusDays(1)) + assertThat(preset.tags).containsExactly("home", "work") + assertThat(preset.priority).isEqualTo(Priority.HIGH) + assertThat(empty.copy(due = setOf(DueWindow.TODAY, DueWindow.UPCOMING)).newTaskPreset(day).dueDate).isEqualTo(day) + assertThat(empty.copy(due = setOf(DueWindow.TODAY, DueWindow.NO_DATE)).newTaskPreset(day).dueDate).isNull() + assertThat(empty.copy(tags = setOf("b", "a")).newTaskPreset(day).tags).containsExactly("a") + } + + @Test + fun `lists since deleted drop out`() { + val filter = empty.copy(listIds = setOf(1L, 5L)) + assertThat(filter.withListsIn(setOf(1L, 2L)).listIds).containsExactly(1L) + assertThat(filter.withListsIn(setOf(2L)).anyList).isTrue() + assertThat(filter.copy(listsElsewhere = true).withListsIn(setOf(2L)).anyList).isFalse() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFilteringTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFilteringTest.kt index 9be0ed7..7749331 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFilteringTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFilteringTest.kt @@ -58,4 +58,32 @@ class TaskFilteringTest { assertThat(TaskFiltering.matches(task, TaskFilter.OfList(7), todayStart, todayEnd)).isTrue() assertThat(TaskFiltering.matches(task, TaskFilter.OfList(8), todayStart, todayEnd)).isFalse() } + + @Test + fun `Tagged matches open and completed tasks carrying the tag`() { + val filter = TaskFilter.Tagged("Work") + assertThat(TaskFiltering.matches(testTask(tags = listOf("Home", "Work")), filter, todayStart, todayEnd)).isTrue() + assertThat( + TaskFiltering.matches(testTask(tags = listOf("Work"), status = TaskStatus.COMPLETED), filter, todayStart, todayEnd), + ).isTrue() + assertThat(TaskFiltering.matches(testTask(tags = listOf("work")), filter, todayStart, todayEnd)).isFalse() + } + + @Test + fun `narrowing by tags needs every one of them`() { + val task = testTask(tags = listOf("Home", "Work")) + assertThat(TaskFiltering.hasAllTags(task, emptySet())).isTrue() + assertThat(TaskFiltering.hasAllTags(task, setOf("Work"))).isTrue() + assertThat(TaskFiltering.hasAllTags(task, setOf("Work", "Home"))).isTrue() + assertThat(TaskFiltering.hasAllTags(task, setOf("Work", "Errands"))).isFalse() + } + + @Test + fun `tags in use are listed once each, alphabetically ignoring case`() { + val tasks = listOf( + testTask(id = 1, tags = listOf("work", "Home")), + testTask(id = 2, tags = listOf("Home", "errands")), + ) + assertThat(tagsInUse(tasks)).containsExactly("errands", "Home", "work").inOrder() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt index 3b49e2d..fb42d1f 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt @@ -44,4 +44,16 @@ class TaskFormTest { assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields()) .containsExactly(TaskFormField.Progress, TaskFormField.Parent) } + + @Test + fun `tags auto-reveal when the task has any`() { + assertThat(TaskForm(title = "x", listId = 1, tags = listOf("Work")).populatedFields()) + .containsExactly(TaskFormField.Tags) + } + + @Test + fun `tags are normalised to trimmed, non-blank, unique spellings in order`() { + assertThat(normalizeTags(listOf(" Work", "", "Home ", "Work", "work"))) + .containsExactly("Work", "Home", "work").inOrder() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt index 6967c97..ad92ce0 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt @@ -10,6 +10,10 @@ fun testTask( status: TaskStatus = TaskStatus.NEEDS_ACTION, priority: Priority = Priority.NONE, due: Instant? = null, + isAllDay: Boolean = false, + completedAt: Instant? = null, + parentId: Long? = null, + tags: List = emptyList(), ): Task = Task( taskId = id, listId = listId, @@ -22,17 +26,18 @@ fun testTask( percentComplete = null, start = null, due = due, - isAllDay = false, + isAllDay = isAllDay, timeZone = null, - completedAt = null, + completedAt = completedAt, listColor = 0, taskColor = null, listName = null, accountName = null, - parentId = null, + parentId = parentId, isRecurring = false, occurrenceStart = null, distanceFromCurrent = 0, created = null, lastModified = null, + tags = tags, ) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt index 86c6b8c..43bf0ed 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt @@ -35,9 +35,10 @@ class ICalendarWriterTest { rrule: String? = null, rdate: String? = null, parentId: Long? = null, + tags: List = emptyList(), ) = ExportTask( taskId, uid, title, description, location, url, priority, status, percentComplete, - start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId, + start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId, tags, ) private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String = @@ -59,6 +60,13 @@ class ICalendarWriterTest { assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO") } + @Test + fun `writes the tags as one CATEGORIES line, escaped`() { + val lines = linesOf(write(task(tags = listOf("Work", "Home, Garden")))) + assertThat(lines).contains("CATEGORIES:Work,Home\\, Garden") + assertThat(linesOf(write(task())).none { it.startsWith("CATEGORIES") }).isTrue() + } + @Test fun `uses CRLF line endings`() { // RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/ui/navigation/DestinationsTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/ui/navigation/DestinationsTest.kt index 022915d..7a7bd4d 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/ui/navigation/DestinationsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/ui/navigation/DestinationsTest.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.agendula.ui.navigation import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.TaskFilter import org.junit.jupiter.api.Test class DestinationsTest { @@ -17,4 +18,13 @@ class DestinationsTest { assertThat(Dest.Settings.build()).isEqualTo(Dest.SETTINGS) assertThat(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)).isEqualTo("settings?section=Accounts") } + + @Test + fun `a saved filter routes by id and comes back from its arguments`() { + assertThat(Dest.TaskList.build(TaskFilter.Saved(5))).isEqualTo("taskList?filterId=5") + assertThat(Dest.TaskList.filterOf(smart = null, listId = Dest.NO_ID, filterId = 5)).isEqualTo(TaskFilter.Saved(5)) + assertThat(Dest.FilterEdit.build()).isEqualTo("filterEdit") + assertThat(Dest.FilterEdit.build(5)).isEqualTo("filterEdit?filterId=5") + assertThat(Dest.TaskEdit.buildNew(filterId = 5)).isEqualTo("taskEdit?filterId=5") + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt index cbc4a77..1951ca0 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt @@ -1,12 +1,15 @@ package de.jeanlucmakiola.agendula.widget import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskSorting import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.allDayInstantOf import de.jeanlucmakiola.agendula.domain.testTask +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetThemeMode import org.junit.jupiter.api.Test import java.time.LocalDate import java.time.LocalDateTime @@ -27,7 +30,10 @@ class WidgetDataTest { @Test fun `every offered filter round-trips through storage`() { - val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } + TaskFilter.OfList(42) + val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } + + TaskFilter.OfList(42) + + TaskFilter.OfLists(setOf(1L, 2L, 3L)) + + TaskFilter.Saved(7) filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) } } @@ -38,7 +44,10 @@ class WidgetDataTest { @Test fun `garbage and smart lists the widget does not offer fall back to Today`() { - listOf("", "list:", "list:-3", "list:abc", "smart:COMPLETED", "smart:nope", "whatever").forEach { + listOf( + "", "list:", "list:-3", "list:abc", "lists:", "lists:abc", "lists:-1,-2", + "smart:COMPLETED", "smart:nope", "filter:", "filter:0", "filter:x", "whatever", + ).forEach { assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER) } } @@ -80,6 +89,8 @@ class WidgetDataTest { TaskSorting.DEFAULT, now, newYork, + groupByList = false, + listNames = emptyMap(), ) assertThat(rows.map { it.title }).containsExactly("soon", "later").inOrder() } @@ -89,13 +100,135 @@ class WidgetDataTest { val parent = testTask(id = 1, title = "parent") val child = testTask(id = 2, title = "child").copy(parentId = 1) val orphan = testTask(id = 3, title = "orphan").copy(parentId = 99) - val rows = widgetRows(listOf(parent, child, orphan), TaskSorting.DEFAULT, now, newYork) + val rows = widgetRows( + listOf(parent, child, orphan), + TaskSorting.DEFAULT, + now, + newYork, + groupByList = false, + listNames = emptyMap(), + ) assertThat(rows.map { it.title }).containsExactly("parent", "orphan") } @Test fun `rows are capped`() { val many = (1L..(MAX_WIDGET_ROWS + 10L)).map { testTask(id = it, title = "t$it") } - assertThat(widgetRows(many, TaskSorting.DEFAULT, now, newYork)).hasSize(MAX_WIDGET_ROWS) + assertThat( + widgetRows(many, TaskSorting.DEFAULT, now, newYork, groupByList = false, listNames = emptyMap()), + ).hasSize(MAX_WIDGET_ROWS) + } + + @Test + fun `grouping sorts by list name, keeping each list's own order`() { + val tasks = listOf( + testTask(id = 1, listId = 2, title = "b-later", due = at("2026-07-22T09:00:00")), + testTask(id = 2, listId = 1, title = "a-soon", due = at("2026-07-20T15:00:00")), + testTask(id = 3, listId = 1, title = "a-later", due = at("2026-07-22T09:00:00")), + ) + val rows = widgetRows( + tasks, + TaskSorting.DEFAULT, + now, + newYork, + groupByList = true, + listNames = mapOf(1L to "Alpha", 2L to "Beta"), + ) + assertThat(rows.map { it.title }).containsExactly("a-soon", "a-later", "b-later").inOrder() + } + + // --- sections ---------------------------------------------------------- + + private fun row(id: Long, listId: Long = 1, dueKind: DueKind? = null, overdue: Boolean = false) = WidgetTaskRow( + taskId = id, + listId = listId, + occurrenceStart = null, + title = "t$id", + due = null, + isAllDay = false, + dueKind = dueKind, + isOverdue = overdue, + color = 0, + ) + + @Test + fun `due buckets appear in a fixed order and skip empty ones`() { + val rows = listOf( + row(1, dueKind = DueKind.LATER), + row(2, dueKind = DueKind.PAST, overdue = true), + row(3, dueKind = DueKind.TODAY), + ) + val sections = widgetSections(rows, groupByList = false, listNames = emptyMap()) { it?.name ?: "none" } + assertThat(sections.filterIsInstance().map { it.name }) + .containsExactly("PAST", "TODAY", "LATER").inOrder() + } + + @Test + fun `a bucket's rows keep their own order under its one header`() { + val rows = listOf(row(1, dueKind = DueKind.TODAY), row(2, dueKind = DueKind.TODAY), row(3, dueKind = DueKind.TODAY)) + val sections = widgetSections(rows, groupByList = false, listNames = emptyMap()) { "x" } + assertThat(sections.filterIsInstance()).hasSize(1) + assertThat(sections.filterIsInstance().map { it.row.taskId }).containsExactly(1L, 2L, 3L).inOrder() + } + + @Test + fun `grouping by list keeps the widgetRows order and headers each run once`() { + val rows = listOf(row(1, listId = 1), row(2, listId = 1), row(3, listId = 2)) + val sections = widgetSections(rows, groupByList = true, listNames = mapOf(1L to "Alpha", 2L to "Beta")) { "x" } + assertThat(sections.filterIsInstance().map { it.name }).containsExactly("Alpha", "Beta").inOrder() + assertThat(sections.filterIsInstance().map { it.row.taskId }).containsExactly(1L, 2L, 3L).inOrder() + } + + // --- per-widget background / theme -------------------------------------- + + @Test + fun `an unset background is Solid, and round-trips`() { + assertThat(parseWidgetBackground(null)).isEqualTo(WidgetBackground.SOLID) + WidgetBackground.entries.forEach { assertThat(parseWidgetBackground(it.name)).isEqualTo(it) } + assertThat(parseWidgetBackground("garbage")).isEqualTo(WidgetBackground.SOLID) + } + + @Test + fun `an unset theme override follows the app, Light and Dark pin regardless`() { + assertThat(parseWidgetThemeOverride(null)).isNull() + assertThat(parseWidgetThemeOverride("garbage")).isNull() + assertThat(parseWidgetThemeOverride("LIGHT")).isEqualTo(WidgetThemeMode.LIGHT) + assertThat(parseWidgetThemeOverride("DARK")).isEqualTo(WidgetThemeMode.DARK) + } + + private fun readyWithTheme(appTheme: ThemeMode) = TaskWidgetData.Ready( + filter = DEFAULT_WIDGET_FILTER, + listNames = emptyMap(), + rows = emptyList(), + groupByList = false, + dynamicColor = true, + themeMode = appTheme, + use24Hour = true, + ) + + @Test + fun `no override resolves to whatever the app's own theme means`() { + assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK) + assertThat(readyWithTheme(ThemeMode.SYSTEM).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.SYSTEM) + } + + @Test + fun `an override wins over the app's own theme`() { + assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(WidgetThemeMode.LIGHT)).isEqualTo(WidgetThemeMode.LIGHT) + } + + @Test + fun `an unset row colour is Neutral, and round-trips`() { + assertThat(parseWidgetRowColor(null)).isEqualTo(WidgetRowColor.NEUTRAL) + WidgetRowColor.entries.forEach { assertThat(parseWidgetRowColor(it.name)).isEqualTo(it) } + assertThat(parseWidgetRowColor("garbage")).isEqualTo(WidgetRowColor.NEUTRAL) + } + + @Test + fun `overdue count only counts overdue rows`() { + val ready = readyWithTheme(ThemeMode.SYSTEM).copy( + rows = listOf(row(1, overdue = true), row(2, overdue = false), row(3, overdue = true)), + ) + assertThat(ready.overdueCount).isEqualTo(2) } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverSchedulerTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverSchedulerTest.kt deleted file mode 100644 index 9769a0a..0000000 --- a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverSchedulerTest.kt +++ /dev/null @@ -1,179 +0,0 @@ -package de.jeanlucmakiola.agendula.widget - -import android.content.Intent -import com.google.common.truth.Truth.assertThat -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.junit.jupiter.api.Test -import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Instant - -/** The "when is the next local midnight" arithmetic, especially where midnight is not 00:00. */ -class WidgetRolloverSchedulerTest { - - private val berlin = TimeZone.of("Europe/Berlin") - - private fun at(local: String, zone: TimeZone): Instant = - LocalDateTime.parse(local).toInstant(zone) - - private fun nextRollover(local: String, zone: TimeZone = berlin): Instant = - WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone) - - // --- the ordinary day ---------------------------------------------------- - - @Test - fun `midday rolls over at the coming midnight`() { - val next = nextRollover("2026-08-27T12:00:00") - assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin)) - } - - @Test - fun `a second before midnight still targets tonight, not tomorrow night`() { - val next = nextRollover("2026-08-27T23:59:59") - assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin)) - } - - @Test - fun `at midnight exactly the target is the next day, never the current instant`() { - // Re-arming after a firing must move a whole day on, or the widget wakes - // itself in a tight loop. - val now = at("2026-08-28T00:00:00", berlin) - val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) - assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin)) - assertThat(next - now).isGreaterThan(Duration.ZERO) - } - - @Test - fun `re-arming from the slack instant itself moves a full day on`() { - // What actually happens in practice: the receiver runs at midnight + slack. - val now = at("2026-08-28T00:00:05", berlin) - assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin)) - .isEqualTo(at("2026-08-29T00:00:05", berlin)) - } - - @Test - fun `the result is always in the future for every minute of a day`() { - val zone = berlin - // Spans Berlin's 2024 spring-forward: the likeliest source of a target - // in the past, i.e. an alarm that fires immediately, forever. - var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone) - val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone) - while (probe < end) { - assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe) - probe += 1.minutes - } - } - - // --- daylight saving ----------------------------------------------------- - - @Test - fun `spring forward keeps the rollover one day away, not one hour short`() { - // Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long. - // A rollover computed as "now + 24h" would land at 01:00 on 1 April. - val now = at("2024-03-30T12:00:00", berlin) - val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) - assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin)) - assertThat(next - now).isLessThan(24.hours) - } - - @Test - fun `fall back does not overshoot into the repeated hour`() { - // Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so - // "now + 24h" would land at 23:00 on the 26th and never roll over. - val now = at("2024-10-26T12:00:00", berlin) - val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) - assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin)) - assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27")) - } - - @Test - fun `a zone that repeats midnight takes the first start of day`() { - // Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day - // began twice. Pinned as the accepted trade: the widget runs an hour - // ahead until the next redraw. No live zone does this since 2019. - val saoPaulo = TimeZone.of("America/Sao_Paulo") - val next = WidgetRolloverScheduler.nextRolloverAt( - at("2018-02-16T12:00:00", saoPaulo), saoPaulo, - ) - val local = next.toLocalDateTime(saoPaulo) - assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17")) - assertThat(local.hour).isEqualTo(0) - } - - @Test - fun `a zone where midnight does not exist rolls over at the real start of day`() { - // Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana. - // Targeting a literal 00:00 there would arm an instant on the wrong day. - val havana = TimeZone.of("America/Havana") - val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana) - val local = next.toLocalDateTime(havana) - assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11")) - assertThat(local.hour).isEqualTo(1) - assertThat(local.minute).isEqualTo(0) - // And it is genuinely the first instant of that date, not a guess. - assertThat(next).isEqualTo( - LocalDate.parse("2018-03-11").atStartOfDayIn(havana) + - WidgetRolloverScheduler.ROLLOVER_SLACK, - ) - } - - // --- timezone changes ---------------------------------------------------- - - @Test - fun `the same instant rolls over at different times in different zones`() { - // TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic - // follows the zone, not a cached offset. - val instant = at("2026-08-27T12:00:00", berlin) - val tokyo = TimeZone.of("Asia/Tokyo") - val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin) - val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo) - assertThat(tokyoNext).isNotEqualTo(berlinNext) - assertThat(tokyoNext).isLessThan(berlinNext) - assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0) - } - - @Test - fun `a half-hour offset zone still lands on its own midnight`() { - val kathmandu = TimeZone.of("Asia/Kathmandu") - val next = WidgetRolloverScheduler.nextRolloverAt( - at("2026-08-27T12:00:00", kathmandu), kathmandu, - ) - val local = next.toLocalDateTime(kathmandu) - assertThat(local.date.toString()).isEqualTo("2026-08-28") - assertThat(local.hour).isEqualTo(0) - } - - // --- the wiring ---------------------------------------------------------- - - @Test - fun `the receiver actually handles the action the alarm is sent with`() { - // The single point where the whole fix would die silently: the alarm - // fires, the receiver drops it on the action guard, nothing redraws. - assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS) - .contains(WidgetUpdateReceiver.ACTION_ROLLOVER) - } - - @Test - fun `a language change redraws without re-arming`() { - assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS).contains(Intent.ACTION_LOCALE_CHANGED) - assertThat(WidgetUpdateReceiver.REARM_ACTIONS).doesNotContain(Intent.ACTION_LOCALE_CHANGED) - } - - @Test - fun `everything that loses the alarm re-arms it`() { - assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast( - WidgetUpdateReceiver.ACTION_ROLLOVER, - Intent.ACTION_BOOT_COMPLETED, - Intent.ACTION_MY_PACKAGE_REPLACED, - Intent.ACTION_TIME_CHANGED, - Intent.ACTION_TIMEZONE_CHANGED, - Intent.ACTION_DATE_CHANGED, - ) - } -} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt new file mode 100644 index 0000000..db25657 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt @@ -0,0 +1,133 @@ +package de.jeanlucmakiola.agendula.widget + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.TaskSortOrder +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import de.jeanlucmakiola.agendula.domain.comparator +import de.jeanlucmakiola.agendula.domain.testTask +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import kotlin.time.Instant + +class WidgetStatsTest { + + private val newYork = ZoneId.of("America/New_York") + private val today = LocalDate.of(2026, 7, 20) + private val comparator = TaskSortOrder.DUE.comparator() + + private fun at(local: String, zone: ZoneId = newYork): Instant = + Instant.fromEpochMilliseconds(LocalDateTime.parse(local).atZone(zone).toInstant().toEpochMilli()) + + private val now = at("2026-07-20T12:00:00") + + // --- todayProgress --------------------------------------------------- + + @Test + fun `a task completed today counts as done, one completed yesterday does not`() { + val completed = listOf( + testTask(id = 1, status = TaskStatus.COMPLETED, completedAt = at("2026-07-20T09:00:00")), + testTask(id = 2, status = TaskStatus.COMPLETED, completedAt = at("2026-07-19T23:00:00")), + ) + val (done, _, _) = todayProgress(emptyList(), completed, comparator, now, today, newYork) + assertThat(done).isEqualTo(1) + } + + @Test + fun `an all-day task due today and an overdue timed task both count as remaining`() { + val open = listOf( + testTask(id = 1, due = allDayInstantOf(today), isAllDay = true), + testTask(id = 2, due = at("2026-07-20T08:00:00")), // already past `now` (12:00) + testTask(id = 3, due = at("2026-07-21T08:00:00")), // tomorrow, not remaining + ) + val (_, remaining, _) = todayProgress(open, emptyList(), comparator, now, today, newYork) + assertThat(remaining).isEqualTo(2) + } + + @Test + fun `an earlier-due remaining task sorts ahead of a later one`() { + val open = listOf( + testTask(id = 1, due = at("2026-07-20T11:00:00")), // overdue, closer to now + testTask(id = 2, due = at("2026-07-20T07:00:00")), // more overdue, earliest + ) + val (_, _, rows) = todayProgress(open, emptyList(), comparator, now, today, newYork) + assertThat(rows.map { it.taskId }).containsExactly(2L, 1L).inOrder() + } + + // --- upcoming ---------------------------------------------------------- + + @Test + fun `upcoming sorts by due, overdue first, and skips undated ones`() { + val open = listOf( + testTask(id = 1, due = null), + testTask(id = 2, due = at("2026-07-21T08:00:00")), + testTask(id = 3, due = at("2026-07-20T08:00:00")), // overdue, earliest + ) + assertThat(upcoming(open, now, today, newYork).map { it.taskId }).containsExactly(3L, 2L).inOrder() + } + + @Test + fun `upcoming is empty when nothing is dated`() { + assertThat(upcoming(listOf(testTask(due = null)), now, today, newYork)).isEmpty() + } + + // --- weekDays ------------------------------------------------------------ + + @Test + fun `today's bucket folds in overdue tasks from earlier days`() { + val open = listOf( + testTask(id = 1, due = at("2026-07-18T08:00:00")), // two days overdue + testTask(id = 2, due = at("2026-07-20T08:00:00")), // due today, already past now + ) + val week = weekDays(open, comparator, now, today, newYork) + assertThat(week.first().date).isEqualTo(today) + assertThat(week.first().count).isEqualTo(2) + assertThat(week.first().overdueCount).isEqualTo(2) + } + + @Test + fun `a future day only counts tasks due that exact day`() { + val open = listOf( + testTask(id = 1, due = at("2026-07-22T08:00:00")), + testTask(id = 2, due = at("2026-07-23T08:00:00")), + ) + val week = weekDays(open, comparator, now, today, newYork) + assertThat(week[2].count).isEqualTo(1) // today + 2 = 07-22 + assertThat(week[2].overdueCount).isEqualTo(0) + assertThat(week[3].count).isEqualTo(1) // today + 3 = 07-23 + } + + @Test + fun `a day at the far edge of daylight saving still buckets by local date`() { + // Spans the US spring-forward boundary; only exercised to confirm + // calendarDate (not a raw day-of-year add) drives the bucketing. + val dstToday = LocalDate.of(2026, 3, 7) + val dstNow = at("2026-03-07T12:00:00") + val open = listOf(testTask(id = 1, due = at("2026-03-08T08:00:00"))) + val week = weekDays(open, comparator, dstNow, dstToday, newYork) + assertThat(week[1].date).isEqualTo(LocalDate.of(2026, 3, 8)) + assertThat(week[1].count).isEqualTo(1) + } + + // --- listTiles ------------------------------------------------------- + + @Test + fun `listTiles counts open tasks per visible list and skips a subtask whose parent is also open`() { + val lists = listOf( + TaskList(1, "Home", 0, "a", "t", false, isVisible = true, owner = null), + TaskList(2, "Hidden", 0, "a", "t", false, isVisible = false, owner = null), + ) + val open = listOf( + testTask(id = 1, listId = 1), + testTask(id = 2, listId = 1, parentId = 1), // subtask of an open parent + testTask(id = 3, listId = 2), + ) + val tiles = listTiles(open, lists) + assertThat(tiles).hasSize(1) + assertThat(tiles.single().id).isEqualTo(1L) + assertThat(tiles.single().openCount).isEqualTo(1) + } +} diff --git a/app/src/test/resources/vtodo/README.md b/app/src/test/resources/vtodo/README.md index ca0516b..cf4ee53 100644 --- a/app/src/test/resources/vtodo/README.md +++ b/app/src/test/resources/vtodo/README.md @@ -30,3 +30,5 @@ VTIMEZONE bodies, fold positions, parameter quoting). | `malformed-scalars.ics` | `PRIORITY:11`, `PERCENT-COMPLETE:150`, `SEQUENCE:x`, `STATUS:X-DEFERRED`, `CLASS:X-INTERNAL` — values no spec allows. Clamping or defaulting any of them is a silent rewrite of somebody's data | | `two-timezones.ics` | `DTSTART` and `DUE` in different zones. One `timezone` column cannot author both | | `folded-utf8.ics` | A line folded mid-emoji-adjacent, to prove folding counts octets and splits on character boundaries | +| `categories-shapes.ics` | One `CATEGORIES` line with an escaped comma inside a value — the one shape the column claims, so it is re-authored and must come out identical | +| `categories-split.ics` | One line per tag, a `LANGUAGE` parameter and a space after a comma. Read for display, but none of it is ours to rewrite | diff --git a/app/src/test/resources/vtodo/categories-shapes.ics b/app/src/test/resources/vtodo/categories-shapes.ics new file mode 100644 index 0000000..43ab4f3 --- /dev/null +++ b/app/src/test/resources/vtodo/categories-shapes.ics @@ -0,0 +1,10 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Several Clients//EN +BEGIN:VTODO +UID:categories-001 +DTSTAMP:20260901T120000Z +SUMMARY:One line, an escaped comma +CATEGORIES:Work,Home\, Garden +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/categories-split.ics b/app/src/test/resources/vtodo/categories-split.ics new file mode 100644 index 0000000..ec52464 --- /dev/null +++ b/app/src/test/resources/vtodo/categories-split.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Several Clients//EN +BEGIN:VTODO +UID:categories-002 +DTSTAMP:20260901T120000Z +SUMMARY:One line per tag, one with a language +CATEGORIES:Errands +CATEGORIES;LANGUAGE=de:Einkauf +CATEGORIES:a, b +END:VTODO +END:VCALENDAR diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStoreTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStoreTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStoreTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStoreTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionChangeTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionChangeTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionChangeTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionChangeTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStoreTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStoreTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStoreTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStoreTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/IncrementalSyncTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/IncrementalSyncTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/IncrementalSyncTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/IncrementalSyncTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/QuarantineRetryTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/QuarantineRetryTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/QuarantineRetryTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/QuarantineRetryTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFailureTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncFailureTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFailureTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncFailureTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStoreTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStoreTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStoreTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStoreTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/push/PushStoreTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/push/PushStoreTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/push/PushStoreTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/push/PushStoreTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt diff --git a/dav/PROVENANCE.md b/dav/PROVENANCE.md index ac6224b..8de12d5 100644 --- a/dav/PROVENANCE.md +++ b/dav/PROVENANCE.md @@ -144,11 +144,11 @@ app answered every registration with a 500. `parseDate` already used ## Build integration -- `:dav`'s tests are a **plain JVM `test` task**. CI runs `testDebugUnitTest`, +- `:dav`'s tests are a **plain JVM `test` task**. CI runs `testFullDebugUnitTest`, which exists only on Android variants, so `.forgejo/workflows/ci.yaml` names `:dav:test` explicitly. Without that the vendored suite is compiled by nobody and run by nobody, and the safety argument above is void. -- `:app` declares `testImplementation(libs.xpp3)`. `compileOnly` is not +- `:app` declares `testFullImplementation(libs.xpp3)`. `compileOnly` is not transitive, `:app`'s unit tests run on a plain JVM with no framework, and `isReturnDefaultValues = true` makes android.jar's stub factory return `null` — so anything touching `XmlUtils` would fail with an unrelated-looking NPE. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 6518fe6..04f233f 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -11,6 +11,12 @@ carries no APK assets. Codeberg is the canonical forge; Gitea is build infrastructure. See [Two forges, one repo](#two-forges-one-repo) for how the two are wired. +The app has two product flavors. **`full`** is Agendula as described everywhere +in this document. **`offline`** (`de.jeanlucmakiola.agendula.offline`, issue +#39) has no CalDAV sync, no push and no network permission; every release and +beta builds and signs it too, but it goes **only to Codeberg**, as an extra +`agendula-offline_v.apk` asset. It is not on F-Droid or Play. + Before a stable release you can ship **betas** (`X.Y.Z-beta.N`) from the release branch. They go to **Codeberg only**, flagged as pre-releases; F-Droid and Play never see them. See [Cutting a beta](#cutting-a-beta). @@ -56,7 +62,9 @@ retries). 1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the feature/fix branches for this release into it. Everything below happens on - that branch, before it reaches `main`. + that branch, before it reaches `main`. To ship betas first, follow + [Cutting a beta](#cutting-a-beta) from here and come back to step 2 when + going stable. 2. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items under a new `## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading and the next `## [` becomes the Gitea and Codeberg release notes. The @@ -65,7 +73,8 @@ retries). `scripts/version_info.sh` prints) in `app/build.gradle.kts`. **This bump is what triggers the release** when the branch merges to `main`. Then write this version's **"What's New"** by hand: - `fastlane/metadata/android//changelogs/.txt`, one per + `fastlane/metadata/android//changelogs/.txt` (e.g. + `1010099.txt` for 1.1.0), one per shipped locale, each a short summary **under 500 characters** — F-Droid and Play both publish these files, and Play rejects a longer one. It is not a copy of the CHANGELOG.md section. `scripts/sync_changelog_to_fastlane.sh` @@ -74,7 +83,8 @@ retries). committed `en-US` changelog. 4. **Verify the release build on a real device** — the mandatory gate: ```sh - scripts/verify-release.sh + scripts/verify-release.sh # full + scripts/verify-release.sh offline # when the release touches the offline flavor ``` It builds the `releaseTest` variant (same R8 config as `release`, debug-signed with a `.releasetest` suffix so it installs alongside the real app) and resets @@ -108,7 +118,9 @@ and to the stable release. (`scripts/version_info.sh` prints it; `1.1.0-beta.1` → `1010001`). No What's New file — betas don't ship to the stores. Notes come from a `## [X.Y.Z-beta.N]` section of `CHANGELOG.md` if you write one, otherwise - from `## [Unreleased]`. + from `## [Unreleased]`. So keep the entries under `## [Unreleased]` while + betas are going out and only move them to `## [X.Y.Z]` when going stable: + once they're moved, a beta's notes come out empty. 2. **Optionally verify it on a device** with `scripts/verify-release.sh`, as for a stable release. 3. **Push the branch to Codeberg.** When it reaches Gitea, `beta.yaml` sees a @@ -190,7 +202,11 @@ Releases aren't git objects and don't sync with the push mirror in either direction, so the pipeline pushes the `vX.Y.Z` tag straight to Codeberg, creates the release over the Codeberg API, and attaches `agendula_v.apk` + its `.sha256`. It's the same APK the F-Droid repo serves (same **app key**), so it -adds no trust surface. The logic lives in `scripts/publish_codeberg_release.sh`, +adds no trust surface. The offline flavor rides along as +`agendula-offline_v.apk` + `.sha256`; the full APK keeps its old name +because Obtainium filters point at it. +Obtainium users who see both offered can pin one with *Filter APKs by regular +expression* (`^agendula_v` or `^agendula-offline_v`). The logic lives in `scripts/publish_codeberg_release.sh`, which `beta.yaml` uses too (with the pre-release flag set). It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset, but it is **not** `continue-on-error`: through 0.2.1–0.3.2 this step reported green while never once publishing, which is how a crash-fix release reached @@ -199,7 +215,7 @@ fails the release loudly. ### Google Play channel -**Artifact.** Play gets an **App Bundle** (`bundleRelease`), never the APK. It is +**Artifact.** Play gets an **App Bundle** (`bundleFullRelease`), never the APK. It is built from the same source and signing config at the very end of the `release` job, `continue-on-error`, and handed to the `play` job as a workflow artifact (via a commit-pinned fork of `upload-artifact`/`download-artifact`; the official diff --git a/docs/fdroid-official/README.md b/docs/fdroid-official/README.md index e3dc61b..2f308cb 100644 --- a/docs/fdroid-official/README.md +++ b/docs/fdroid-official/README.md @@ -48,5 +48,11 @@ off the official repo; keep that pattern if the recipe ever changes. Only a chan recipe itself (a new submodule, a build flag) needs an MR, pinned to a full commit hash, never a tag. +⚠️ **Product flavors (from 1.1.0).** The app now has `full` and `offline` +flavors, and `gradle: - yes` would build both. The first build entry for a +flavored version must say `gradle: - full`, and `AutoUpdateMode` copies the +last entry forward, so it is a one-time recipe MR before that tag lands. The +offline flavor is not submitted to F-Droid. + The recipe says `License: MIT`; `:dav` is vendored MPL-2.0 (`dav/PROVENANCE.md`). If the reviewer asks, change it to `MIT AND MPL-2.0`. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 644facd..59043d7 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -22,7 +22,7 @@ IMAGE_LOCALE = "en-US" platform :android do desc "Upload an already-built, already-signed AAB to Play" lane :deploy do |options| - aab = File.expand_path(options[:aab] || "app/build/outputs/bundle/release/app-release.aab", ROOT) + aab = File.expand_path(options[:aab] || "app/build/outputs/bundle/fullRelease/app-full-release.aab", ROOT) UI.user_error!("AAB not found at #{aab}") unless File.exist?(aab) supply( diff --git a/fastlane/metadata/android/ar/changelogs/1010099.txt b/fastlane/metadata/android/ar/changelogs/1010099.txt new file mode 100644 index 0000000..f4d25e9 --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/1010099.txt @@ -0,0 +1,11 @@ +### جديد +- الوسوم: أضفها إلى المهام، وتصفحها من الشاشة الرئيسية، وصفِّ أي قائمة بها. تُزامَن كـ CATEGORIES. +- عوامل التصفية: احفظ عرضك الخاص للمهام المفتوحة حسب الاستحقاق والقائمة والوسم والأولوية، وفي الأداة أيضًا. +- أدوات جديدة: Today وUp next وLists وWeek. يمكن للأداة عرض عدة قوائم. + +### تغييرات +- صفوف بنمط الأجندة في أداة المهام. +- يمكن الآن إعادة تسمية القوائم المتزامنة وحذفها. + +### إصلاحات +- لم تعد مهام اليوم الكامل تنزاح يومًا غرب UTC. diff --git a/fastlane/metadata/android/de-DE/changelogs/1010099.txt b/fastlane/metadata/android/de-DE/changelogs/1010099.txt new file mode 100644 index 0000000..8234bbd --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/1010099.txt @@ -0,0 +1,11 @@ +### Neu +- Tags: an Aufgaben vergeben, auf dem Startbildschirm durchsuchen, jede Liste danach filtern. +- Filter: eigene Ansicht offener Aufgaben nach Fälligkeit, Liste, Tag und Priorität, auch im Widget. +- Neue Widgets: Today, Up next, Lists und Week. Ein Widget kann mehrere Listen zeigen. + +### Geändert +- Agenda-Zeilen im Aufgaben-Widget. +- Synchronisierte Listen lassen sich umbenennen und löschen. + +### Behoben +- Ganztägige Aufgaben landen westlich von UTC nicht mehr einen Tag daneben. diff --git a/fastlane/metadata/android/en-GB/changelogs/1010099.txt b/fastlane/metadata/android/en-GB/changelogs/1010099.txt new file mode 100644 index 0000000..9a4cfdf --- /dev/null +++ b/fastlane/metadata/android/en-GB/changelogs/1010099.txt @@ -0,0 +1,11 @@ +### Added +- Tags: add them to tasks, browse them from the home screen and filter any list by them. They sync as CATEGORIES. +- Filters: save your own view of open tasks by due date, list, tag and priority, and show it in a widget. +- New widgets: Today, Up next, Lists and Week. A widget can show several lists. + +### Changed +- Agenda-style rows in the Tasks widget. +- Synced lists can be renamed and deleted. + +### Fixed +- All-day tasks no longer land a day off west of UTC. diff --git a/fastlane/metadata/android/en-US/changelogs/1010099.txt b/fastlane/metadata/android/en-US/changelogs/1010099.txt new file mode 100644 index 0000000..9a4cfdf --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1010099.txt @@ -0,0 +1,11 @@ +### Added +- Tags: add them to tasks, browse them from the home screen and filter any list by them. They sync as CATEGORIES. +- Filters: save your own view of open tasks by due date, list, tag and priority, and show it in a widget. +- New widgets: Today, Up next, Lists and Week. A widget can show several lists. + +### Changed +- Agenda-style rows in the Tasks widget. +- Synced lists can be renamed and deleted. + +### Fixed +- All-day tasks no longer land a day off west of UTC. diff --git a/fastlane/metadata/android/fr-FR/changelogs/1010099.txt b/fastlane/metadata/android/fr-FR/changelogs/1010099.txt new file mode 100644 index 0000000..b48c03d --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/1010099.txt @@ -0,0 +1,11 @@ +### Nouveautés +- Étiquettes : ajoutez-les aux tâches, parcourez-les depuis l'accueil, filtrez toute liste. +- Filtres : votre vue des tâches ouvertes par échéance, liste, étiquette et priorité, aussi en widget. +- Widgets Today, Up next, Lists et Week. Un widget peut montrer plusieurs listes. + +### Modifié +- Lignes façon agenda dans le widget Tâches. +- Listes synchronisées : renommer et supprimer. + +### Corrigé +- Les tâches sur la journée ne sont plus décalées d'un jour à l'ouest d'UTC. diff --git a/fastlane/metadata/android/pt-BR/changelogs/1010099.txt b/fastlane/metadata/android/pt-BR/changelogs/1010099.txt new file mode 100644 index 0000000..015dfda --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/1010099.txt @@ -0,0 +1,11 @@ +### Novo +- Tags: adicione às tarefas, navegue pela tela inicial e filtre qualquer lista por elas. +- Filtros: salve sua visão das tarefas abertas por data, lista, tag e prioridade, também em widget. +- Novos widgets: Today, Up next, Lists e Week. Um widget pode mostrar várias listas. + +### Alterado +- Linhas em estilo agenda no widget Tasks. +- Listas sincronizadas podem ser renomeadas e excluídas. + +### Corrigido +- Tarefas de dia inteiro não caem mais um dia errado a oeste do UTC. diff --git a/floret-kit b/floret-kit index 8b33635..0b44d35 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 8b33635a852d404cd72d2483dd5bdc4926f13276 +Subproject commit 0b44d353d2a617a0c0e0b9f8c71948e2e97e37e4 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e87a59b..57d8d63 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -177,6 +177,7 @@ floret-core-locale = { group = "de.jeanlucmakiola.floret", name = "core-locale" floret-core-crash = { group = "de.jeanlucmakiola.floret", name = "core-crash" } floret-identity = { group = "de.jeanlucmakiola.floret", name = "identity" } floret-components = { group = "de.jeanlucmakiola.floret", name = "components" } +floret-glance = { group = "de.jeanlucmakiola.floret", name = "glance" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/scripts/make_import_fixture.py b/scripts/make_import_fixture.py index 3c87a44..a2118b3 100644 --- a/scripts/make_import_fixture.py +++ b/scripts/make_import_fixture.py @@ -115,8 +115,8 @@ def seed(db: sqlite3.Connection) -> None: # data0 minutes before, data1 reference (1 = DUE), data3 alarm type. (1, 1, ALARM_MIMETYPE, "30", "1", None, "1"), (2, 9, ALARM_MIMETYPE, "1440", "1", "Ship it", "1"), - # A non-alarm property the import must skip. - (3, 1, "vnd.android.cursor.item/category", "Errands", None, None, None), + # A category: data0 the provider's category id, data1 the name. A tag, not an alarm. + (3, 1, "vnd.android.cursor.item/category", "1", "Errands", None, None), ], ) diff --git a/scripts/publish_codeberg_release.sh b/scripts/publish_codeberg_release.sh index 511492c..7d626ac 100755 --- a/scripts/publish_codeberg_release.sh +++ b/scripts/publish_codeberg_release.sh @@ -4,7 +4,7 @@ # a pre-release, where Codeberg is the only channel. # # Env: TOKEN, API (.../api/v1/repos//), TAG, SHA, PRERELEASE, -# NOTES_FILE, APK +# NOTES_FILE, APK, optional APK_OFFLINE (the offline flavor, #39) set -euo pipefail if [ -z "${TOKEN:-}" ]; then if [ "${PRERELEASE:-}" = true ]; then @@ -20,10 +20,19 @@ if [ ! -f "$APK" ]; then echo "No release APK at $APK." >&2; exit 1; fi WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT +# The full APK keeps its historical name: Obtainium filters point at it. ASSET_APK="agendula_${TAG}.apk" ASSET_SUM="${ASSET_APK}.sha256" cp "$APK" "$WORK/$ASSET_APK" ( cd "$WORK" && sha256sum "$ASSET_APK" > "$ASSET_SUM" ) +ASSETS=("$ASSET_APK" "$ASSET_SUM") +if [ -n "${APK_OFFLINE:-}" ]; then + if [ ! -f "$APK_OFFLINE" ]; then echo "No offline APK at $APK_OFFLINE." >&2; exit 1; fi + ASSET_OFFLINE="agendula-offline_${TAG}.apk" + cp "$APK_OFFLINE" "$WORK/$ASSET_OFFLINE" + ( cd "$WORK" && sha256sum "$ASSET_OFFLINE" > "$ASSET_OFFLINE.sha256" ) + ASSETS+=("$ASSET_OFFLINE" "$ASSET_OFFLINE.sha256") +fi # Push the tag first and create the release without target_commitish: a POST # naming a commit Codeberg hasn't received yet 500s. @@ -66,7 +75,8 @@ for attempt in 1 2 3 4 5 6; do done if [ -z "$ID" ]; then echo "Could not create or update the Codeberg release." >&2; exit 1; fi -for A in "$ASSET_APK" "$ASSET_SUM"; do +# Attach APKs + checksums, replacing any prior asset of the same name. +for A in "${ASSETS[@]}"; do OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \ | jq -r --arg n "$A" '.[]? | select(.name==$n) | .id' 2>/dev/null || true) for O in $OLD; do diff --git a/scripts/publish_gitea_release.sh b/scripts/publish_gitea_release.sh index c5e948f..eaee858 100755 --- a/scripts/publish_gitea_release.sh +++ b/scripts/publish_gitea_release.sh @@ -11,7 +11,8 @@ # TAG, SHA vX.Y.Z[-beta.N] and the commit it marks # PRERELEASE true | false # NOTES_FILE release notes (scripts/release_notes.sh) -# MAPPING optional path to R8's mapping.txt +# MAPPING optional path to R8's mapping.txt (full flavor) +# MAPPING_OFFLINE optional path to the offline flavor's mapping.txt set -euo pipefail : "${TOKEN:?}" "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}" @@ -57,8 +58,8 @@ echo "Created/updated Gitea release $TAG at $SHA" # so it fits the no-binaries rule for this release. Best-effort: never fail a # release over it. attach_mapping() { - local asset="mapping-${TAG#v}.txt.gz" old - gzip -c "$MAPPING" > "$WORK/$asset" + local mapping=$1 asset=$2 old + gzip -c "$mapping" > "$WORK/$asset" # Replace any prior asset of the same name (re-run safe). old=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \ | jq -r --arg n "$asset" '.[] | select(.name==$n) | .id') @@ -72,5 +73,10 @@ attach_mapping() { if [ -z "${MAPPING:-}" ] || [ ! -f "$MAPPING" ]; then echo "No mapping.txt (R8 off?) — skipping." else - attach_mapping || echo "warning: could not attach the R8 mapping to $TAG" >&2 + attach_mapping "$MAPPING" "mapping-${TAG#v}.txt.gz" \ + || echo "warning: could not attach the R8 mapping to $TAG" >&2 +fi +if [ -n "${MAPPING_OFFLINE:-}" ] && [ -f "$MAPPING_OFFLINE" ]; then + attach_mapping "$MAPPING_OFFLINE" "mapping-offline-${TAG#v}.txt.gz" \ + || echo "warning: could not attach the offline R8 mapping to $TAG" >&2 fi diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index 81b4894..a0d9413 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -11,16 +11,22 @@ # unminified debug build — or a device that already holds the permission — # silently hides. # -# Usage: scripts/verify-release.sh +# Usage: scripts/verify-release.sh [full|offline] (default: full) set -euo pipefail cd "$(dirname "$0")/.." -PKG="de.jeanlucmakiola.agendula.releasetest" -APK="app/build/outputs/apk/releaseTest/app-releaseTest.apk" +FLAVOR="${1:-full}" +case "$FLAVOR" in + full) PKG="de.jeanlucmakiola.agendula.releasetest" ;; + offline) PKG="de.jeanlucmakiola.agendula.offline.releasetest" ;; + *) echo "Unknown flavor '$FLAVOR' (full|offline)." >&2; exit 2 ;; +esac +TASK="assemble${FLAVOR^}ReleaseTest" +APK="app/build/outputs/apk/$FLAVOR/releaseTest/app-$FLAVOR-releaseTest.apk" -echo "==> Building release-candidate APK (releaseTest, R8 minified)…" -./gradlew :app:assembleReleaseTest +echo "==> Building release-candidate APK ($FLAVOR releaseTest, R8 minified)…" +./gradlew ":app:$TASK" echo "==> Installing $PKG …" adb install -r "$APK"