Compare commits

..
9 Commits
Author SHA1 Message Date
Jean-Luc Makiolaandmakiolaj dd08c035df Release 1.1.0 (#51)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Renovate / renovate (push) Successful in 54s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 21m44s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 55s
Agendula 1.1.0. Merging this triggers the release pipeline (F-Droid, Codeberg, Play) and mints the v1.1.0 tag.

- Tags, synced as CATEGORIES (#35)
- Saved filters, also usable in the Tasks widget (#47)
- Offline flavor without sync or network, published to Codeberg only (#39)
- New Today, Up next, Lists and Week widgets; widgets can show several lists
- Synced lists can be renamed and deleted
- All-day tasks no longer land a day off west of UTC in smart lists and filters

versionName 1.1.0 / versionCode 1010099. CHANGELOG has the 1.1.0 section, and What's New is written for en-US, en-GB, de-DE, pt-BR, plus new fr-FR and ar.

After the tag lands, fdroiddata!49998 needs a 1.1.0 build entry with gradle: full.

Closes #35, closes #39, closes #47

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/51
2026-10-10 15:06:07 +02:00
Jean-Luc Makiola b6f0391cb3 Translations update from Weblate (#34)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/34
2026-10-10 14:28:03 +02:00
Jean-Luc Makiolaandmakiolaj 22f3d8c2c5 ci: route jobs to the gradle and light runners (#48)
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m47s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 9s
Moves Gradle jobs to the dedicated `gradle` runner (capacity 1, 8g per job) and the cheap gate/lint jobs to `light`. Two runners used to share the `docker` label, so two Gradle builds could run at once and the VPS ran out of memory. Only `runs-on` lines change.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/48
2026-10-10 11:59:22 +02:00
Weblate 4c2a6dc87b Translated using Weblate (Arabic)
Currently translated at 19.4% (112 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-08 13:09:37 +00:00
Sup99999 33639c0a7c Translated using Weblate (Arabic)
Currently translated at 19.4% (112 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-08 13:09:37 +00:00
Robin Schanbacher a15e696059 Translated using Weblate (German)
Currently translated at 66.0% (380 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-07 11:37:09 +00:00
Robin Schanbacher b8e797f419 Translated using Weblate (German)
Currently translated at 64.1% (369 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-07 11:37:09 +00:00
Jean-Luc Makiolaandmakiolaj 72b2932197 Agendula: repin floret-kit to shared main, Gradle 9.8 toolchain (#44)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 52s
Moves the floret-kit submodule from v0.4.0 (db4be68) to floret-kit main (8b33635). The kit's per-app branches were folded back into main in jlmakiola/floret-kit#11, so agendula now tracks the same line as calendula, clockula and vouch. The kit adds core-prefs, core-di, CollapsingScaffold FAB/no-back slots, the glance module, LocaleManager on API 33+ and a dependency bump; nothing existing is removed.

floret-kit main requires Gradle >= 9.6 in the consuming build, so this also bumps Gradle 9.5.1 → 9.8.0, AGP 9.2.1 → 9.4.1 and Kotlin 2.3.21 → 2.4.20. Hilt 2.60.1 / KSP 2.3.11 were already new enough. No source changes.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/44
2026-10-07 10:16:36 +02:00
Jean-Luc Makiolaandmakiolaj c4a69d30f7 ci(release): exact tag checks and a retryable beta publish (#40)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m26s
### What this changes

- New `scripts/release_gate.sh`, used by the `detect` jobs of both `release.yaml` and `beta.yaml`, decides whether the committed version still needs publishing. Tags are read by exact name via `git ls-remote` instead of Codeberg's `git/refs/tags/<name>` API.
- A beta counts as done only once its Codeberg pre-release carries the APK, so a publish that failed part-way is redone by the next push of the branch.
- A beta is refused unless it is newer than the latest stable tag (previously only an exact `vX.Y.Z` tag was checked).
- `publish_codeberg_release.sh` fails a pre-release when `CODEBERG_RELEASE_TOKEN` is missing (stable still skips), fails on any asset upload that doesn't return 201, and no longer aborts when the asset listing returns an error.
- `version_info.sh` rejects leading zeros (`1.1.0-beta.01` would have shared `beta.1`'s versionCode under a different tag).
- `beta.yaml` header and `docs/RELEASING.md` updated to match.

### Why

Codeberg's `git/refs/tags/<name>` matches by prefix: asking for `v1.1.0` returns 200 because `v1.1.0-beta.1` exists. Merging 1.1.0 into `main` would have logged "tag already exists" and shipped nothing while the run stayed green, and `1.1.0-beta.2` would have been refused as "already shipped stable".

`release/v1.1.0` needs `main` merged in after this lands, since `beta.yaml` runs from the pushed branch's files.

### Also in here

`gradle/gradle-daemon-jvm.properties` now points at JetBrains' own download URLs for JBR 21.0.11 instead of foojay. foojay dropped JetBrains 21 from its index, so the pinned package ids return 400 and CI couldn't provision the daemon JVM (unrelated to the release change, but it blocks every PR until fixed).

### Checklist

- [x] No `values-*/strings.xml` touched
- [x] No `versionName` / `versionCode` bump
- [x] `CHANGELOG.md` not updated: release infrastructure, not a user-visible change

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/40
2026-10-06 18:32:42 +02:00
89 changed files with 2992 additions and 224 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
detect: detect:
# Gitea only; see the same guard in release.yaml. # Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj' if: github.repository_owner == 'makiolaj'
runs-on: docker runs-on: light
outputs: outputs:
is_beta: ${{ steps.v.outputs.is_beta }} is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }} version: ${{ steps.v.outputs.version }}
@@ -57,7 +57,7 @@ jobs:
beta: beta:
needs: detect needs: detect
if: needs.detect.outputs.is_beta == 'true' if: needs.detect.outputs.is_beta == 'true'
runs-on: docker runs-on: gradle
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
+2 -2
View File
@@ -48,7 +48,7 @@ jobs:
# secrets. repository_owner differs between the two forges regardless of # secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently. # URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj' if: github.repository_owner == 'makiolaj'
runs-on: docker runs-on: light
outputs: outputs:
is_release: ${{ steps.v.outputs.is_release }} is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }} version: ${{ steps.v.outputs.version }}
@@ -105,7 +105,7 @@ jobs:
release: release:
needs: detect needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch' if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: docker runs-on: gradle
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
+17
View File
@@ -7,6 +7,8 @@ All notable changes to this project are documented here. The format follows
## [Unreleased] ## [Unreleased]
## [1.1.0] - 2026-10-10
### Added ### Added
- Four new home-screen widgets: Today, Up next, Lists and Week. - Four new home-screen widgets: Today, Up next, Lists and Week.
- A widget can show tasks from several lists at once, optionally grouped by - A widget can show tasks from several lists at once, optionally grouped by
@@ -14,6 +16,13 @@ All notable changes to this project are documented here. The format follows
- A setting under Settings → Task form pre-fills a new task's start with today. - 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, - An offline version of Agendula with no sync and no network access at all,
published as a separate APK on Codeberg (#39). 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 ### Changed
- The Tasks widget has agenda-style rows, an optional row colour and an "All - The Tasks widget has agenda-style rows, an optional row colour and an "All
@@ -23,9 +32,17 @@ All notable changes to this project are documented here. The format follows
showed. showed.
- Synced lists can be renamed and deleted, not only device-only ones. A - 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. 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 ### Fixed
- The list editor's Where and smart-lists rows no longer have double padding. - 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 ## [1.0.0] - 2026-09-21
+10
View File
@@ -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 is kept verbatim and sent back unchanged, so passing your tasks through Agendula
does not quietly lose fields another client wrote. 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 your tasks live
| | Where | Sync | Needs | | | Where | Sync | Needs |
+2 -2
View File
@@ -33,8 +33,8 @@ android {
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001, // (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match. // 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md. // See docs/RELEASING.md.
versionCode = 1010002 versionCode = 1010099
versionName = "1.1.0-beta.2" versionName = "1.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -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')"
]
}
}
Binary file not shown.
@@ -212,6 +212,15 @@ class OneShotImportTest {
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands") 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 ----------------------------------------------------------- // --- running it -----------------------------------------------------------
@Test @Test
@@ -8,27 +8,7 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
/** /** Migrations, proven against the committed schemas in `app/schemas/`. */
* 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
* }
* ```
*/
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class TasksDatabaseMigrationTest { 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<Any?>(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<Any?>(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 { private companion object {
const val TEST_DB = "migration-test.db" const val TEST_DB = "migration-test.db"
} }
@@ -107,6 +107,17 @@ class ExternalImportTest {
assertThat(importer.hasRun.first()).isTrue() 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. */ /** Every list arrives device-only: the account belongs to the sync app. */
@Test @Test
fun importedListsAreDeviceOnly() = runBlocking { fun importedListsAreDeviceOnly() = runBlocking {
@@ -241,6 +252,7 @@ class ExternalImportTest {
title: String, title: String,
uid: String? = "uid-$id", uid: String? = "uid-$id",
parentId: Long? = null, parentId: Long? = null,
tags: List<String> = emptyList(),
) = ExportTask( ) = ExportTask(
taskId = id, taskId = id,
uid = uid, uid = uid,
@@ -260,6 +272,7 @@ class ExternalImportTest {
rrule = null, rrule = null,
rdate = null, rdate = null,
parentId = parentId, parentId = parentId,
tags = tags,
) )
private companion object { private companion object {
@@ -149,6 +149,8 @@ class MainActivity : ComponentActivity() {
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY" const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART" private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST" private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
private const val 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_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID" private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID" private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
@@ -198,6 +200,12 @@ class MainActivity : ComponentActivity() {
.putExtra(EXTRA_LIST_ID, listId) .putExtra(EXTRA_LIST_ID, listId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .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? { internal fun navRequestOf(intent: Intent?): NavRequest? {
if (intent == null) return null if (intent == null) return null
if (BuildConfig.SYNC_ENABLED) { if (BuildConfig.SYNC_ENABLED) {
@@ -215,6 +223,8 @@ class MainActivity : ComponentActivity() {
?.let { return NavRequest.OpenSmart(it) } ?.let { return NavRequest.OpenSmart(it) }
ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L } ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenList(it) } ?.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) } importIntentUri(intent)?.let { return NavRequest.Import(it) }
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) { if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
@@ -89,6 +89,7 @@ object DataProvideModule {
// backed-up `.db` does not. The backup rules carry all three files and // backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP. // the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(TasksDatabase.MIGRATION_1_2)
.build() .build()
/** /**
@@ -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<SavedFilter> =
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<String> = raw.orEmpty().split('\n').filter { it.isNotBlank() }
private fun fields(line: String): List<String> = line.split('|')
private fun idOf(line: String): Long? = fields(line).firstOrNull()?.toLongOrNull()
private fun splitList(value: String): List<String> = value.split(',').filter { it.isNotEmpty() }
private fun encodeLine(filter: SavedFilter, mode: String, foreignLists: List<String>): 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())
}
@@ -10,6 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField 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.normalizeReminders
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import java.time.DayOfWeek import java.time.DayOfWeek
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@@ -259,6 +261,34 @@ class SettingsPrefs @Inject constructor(
p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry 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<List<SavedFilter>> =
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<SavedFilter?> =
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<TaskFormField>) = dataStore.edit { suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name } it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
} }
@@ -288,6 +318,8 @@ class SettingsPrefs @Inject constructor(
val PUSH_ENABLED = booleanPreferencesKey("push_enabled") val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden") val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today") val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
} }
} }
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import android.content.ContentProviderOperation
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.ContentValues 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.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask 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.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec 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 // `is_recurring` / `distance_from_current`). The by-name mapper reads
// whatever is present and nulls the rest; the repository re-sorts, so we // whatever is present and nulls the rest; the repository re-sorts, so we
// don't depend on a provider sort column either. // 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 -> ?.use { c ->
val reader = CursorColumnReader(c) val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) } buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
} ?: emptyList() } ?: 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<ExportTask> { override fun exportTasks(listId: Long): List<ExportTask> {
@@ -102,7 +107,7 @@ class AndroidTasksDataSource @Inject constructor(
// table's shape varies across provider versions, and the by-name mapper // table's shape varies across provider versions, and the by-name mapper
// reads what's there. // reads what's there.
val uri = TasksContract.tasksUri(authority()) val uri = TasksContract.tasksUri(authority())
return resolver.query( val tasks = resolver.query(
uri, uri,
null, null,
// _deleted marks a row awaiting a sync round-trip. It's gone as far as // _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) val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) } buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
} ?: emptyList() } ?: 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 --------------------------------------------------------------- // --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long { override fun insertTask(form: TaskForm): Long {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues()) // One batch, so a refused tag leaves no task behind for a retry to duplicate.
?: throw TaskWriteFailedException("insert task") val authority = authority()
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id") 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) { override fun updateTask(taskId: Long, form: TaskForm) {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update task $taskId") if (rows == 0) throw TaskWriteFailedException("update task $taskId")
setCategories(taskId, form.tags)
} }
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) { 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)) (Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null) val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update series $seriesId") 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") ?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id) val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId) val uri = TasksContract.instanceUri(authority(), 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) val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId") 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() } ?: emptyList()
} }
/** Tags by task id for [taskIds], in stored order. */
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
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<String>) {
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) { override fun setCancelled(taskId: Long, cancelled: Boolean) {
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null) val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId") if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
@@ -460,3 +556,6 @@ class AndroidTasksDataSource @Inject constructor(
return cv return cv
} }
} }
/** SQLite's default bound-argument limit on older Android releases. */
private const val MAX_SQL_ARGS = 999
@@ -5,8 +5,8 @@ class ProviderUnavailableException :
IllegalStateException("No tasks provider installed") IllegalStateException("No tasks provider installed")
/** A ContentResolver write returned no URI or affected no rows. */ /** A ContentResolver write returned no URI or affected no rows. */
class TaskWriteFailedException(operation: String) : class TaskWriteFailedException(operation: String, cause: Throwable? = null) :
RuntimeException("Task write failed: $operation") RuntimeException("Task write failed: $operation", cause)
/** /**
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded * The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
@@ -113,6 +113,12 @@ object TaskWriteMapper {
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE, Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
) )
fun categoryValues(taskId: Long, name: String): Map<String, Any?> = 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. */ /** The user-owned columns of a list — what an edit is allowed to change. */
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf( fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
Lists.NAME to name.trim(), Lists.NAME to name.trim(),
@@ -154,6 +154,18 @@ object TasksContract {
const val TYPE_MESSAGE = 1 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() fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
// --- status values (TaskColumns.STATUS_*) -------------------------------- // --- status values (TaskColumns.STATUS_*) --------------------------------
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter 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. */ /** Whether Agendula can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER } 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<Task>, val savedFilter: SavedFilter? = null)
/** /**
* The single entry point the UI layer uses. Flows re-emit automatically when the * 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 * provider changes (our writes *and* external sync like DAVx5), via the
@@ -27,7 +31,10 @@ interface TasksRepository {
*/ */
fun writableTaskLists(): Flow<List<TaskList>> = fun writableTaskLists(): Flow<List<TaskList>> =
taskLists().map { lists -> lists.filter { it.acceptsWrites } } taskLists().map { lists -> lists.filter { it.acceptsWrites } }
fun tasks(filter: TaskFilter): Flow<List<Task>> fun tasks(filter: TaskFilter): Flow<List<Task>> = filteredTasks(filter).map { it.tasks }
/** [tasks] together with the definition behind a [TaskFilter.Saved]. */
fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks>
/** /**
* The direct children of [parentId], re-emitting live like the other flows. * The direct children of [parentId], re-emitting live like the other flows.
@@ -1,7 +1,9 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.time.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SeriesCollapse import de.jeanlucmakiola.agendula.domain.SeriesCollapse
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
@@ -17,7 +19,10 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -32,12 +37,26 @@ class TasksRepositoryImpl @Inject constructor(
private val dataSource: TasksDataSource, private val dataSource: TasksDataSource,
private val providerResolver: ProviderResolver, private val providerResolver: ProviderResolver,
private val startupGate: StartupGate, private val startupGate: StartupGate,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : TasksRepository { ) : TasksRepository {
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() } override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() }
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) } @OptIn(ExperimentalCoroutinesApi::class)
override fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks> = 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<List<Task>> = observing { override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
SeriesCollapse.visible(dataSource.subtasks(parentId)) SeriesCollapse.visible(dataSource.subtasks(parentId))
@@ -55,7 +74,7 @@ class TasksRepositoryImpl @Inject constructor(
} }
} }
private fun loadTasks(filter: TaskFilter): List<Task> { private fun loadTasks(filter: TaskFilter, saved: SavedFilter? = null): List<Task> {
val query = when (filter) { val query = when (filter) {
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true) is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
// Read completed tasks too, then let [TaskFiltering.matches] enforce each // Read completed tasks too, then let [TaskFiltering.matches] enforce each
@@ -66,6 +85,8 @@ class TasksRepositoryImpl @Inject constructor(
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so // 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. // several lists are read in full and membership is filtered in-memory.
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true) 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 (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = SeriesCollapse.visible(dataSource.tasks(query)) val all = SeriesCollapse.visible(dataSource.tasks(query))
@@ -77,7 +98,7 @@ class TasksRepositoryImpl @Inject constructor(
(if (t.isCompleted) done + 1 else done) to (total + 1) (if (t.isCompleted) done + 1 else done) to (total + 1)
} }
return all return all
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) } .filter { TaskFiltering.matches(it, filter, todayStart, todayEnd, saved) }
.map { task -> .map { task ->
progress[task.taskId]?.let { (done, total) -> progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total) task.copy(subtaskDone = done, subtaskTotal = total)
@@ -112,6 +133,7 @@ class TasksRepositoryImpl @Inject constructor(
parentId = parentId, parentId = parentId,
location = task.location, location = task.location,
url = task.url, url = task.url,
tags = task.tags,
), ),
) )
val reminders = dataSource.reminders()[task.taskId].orEmpty() val reminders = dataSource.reminders()[task.taskId].orEmpty()
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.ICalValues import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.normalizeTags
import java.time.ZoneId import java.time.ZoneId
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -81,6 +82,8 @@ object VTodoMapper {
// Parameterised copies stay in the residue verbatim; authoring the // Parameterised copies stay in the residue verbatim; authoring the
// column beside them would emit the same exclusions twice. // column beside them would emit the same exclusions twice.
"RDATE", "EXDATE", "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. */ /** 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 }, CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
) )
val (categories, categoriesProperty) = readCategories(vtodo)
claim(categoriesProperty)
val sequenceProperty = vtodo.property("SEQUENCE") val sequenceProperty = vtodo.property("SEQUENCE")
val sequence = take( val sequence = take(
sequenceProperty, sequenceProperty,
@@ -225,6 +231,7 @@ object VTodoMapper {
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()), title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()), description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()), location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
categories = categories,
// URI, not TEXT — escaping it would corrupt a query string. // URI, not TEXT — escaping it would corrupt a query string.
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()), url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
status = status, status = status,
@@ -284,8 +291,9 @@ object VTodoMapper {
now: Instant = Clock.System.now(), now: Instant = Clock.System.now(),
): ICalComponent { ): ICalComponent {
val residue = parseResidue(entity.unknownProperties) val residue = parseResidue(entity.unknownProperties)
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
val keptResidue = val keptResidue =
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) } residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) }
val suppressed = keptResidue val suppressed = keptResidue
.map { it.name.uppercase() } .map { it.name.uppercase() }
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE } .filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
@@ -307,6 +315,7 @@ object VTodoMapper {
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText)) add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
add("LOCATION", entity.location?.let(ICalValues::escapeText)) add("LOCATION", entity.location?.let(ICalValues::escapeText))
add("URL", entity.url) add("URL", entity.url)
add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList))
add("STATUS", entity.status.toICalName()) add("STATUS", entity.status.toICalName())
add("PERCENT-COMPLETE", entity.percentComplete?.toString()) add("PERCENT-COMPLETE", entity.percentComplete?.toString())
@@ -386,6 +395,7 @@ object VTodoMapper {
private fun contradictsResidue( private fun contradictsResidue(
property: ICalProperty, property: ICalProperty,
residue: ICalComponent, residue: ICalComponent,
residueCategories: List<String>,
entity: TaskEntity, entity: TaskEntity,
parentUid: String?, parentUid: String?,
): Boolean { ): Boolean {
@@ -433,6 +443,7 @@ object VTodoMapper {
"PRIORITY" -> entity.priority != PRIORITY_NONE "PRIORITY" -> entity.priority != PRIORITY_NONE
"PERCENT-COMPLETE" -> entity.percentComplete != null "PERCENT-COMPLETE" -> entity.percentComplete != null
"CLASS" -> entity.classification != null "CLASS" -> entity.classification != null
"CATEGORIES" -> entity.categories != residueCategories
// Not SEQUENCE: it is the organiser's counter and never ours to bump, // 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. // so the column stays at its fallback and the residue always wins.
else -> false else -> false
@@ -475,6 +486,23 @@ object VTodoMapper {
// -------------------------------------------------------------- helpers // -------------------------------------------------------------- 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<List<String>, 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<String> =
normalizeTags(ICalValues.parseTextList(property.value))
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL") private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
private class TimeRead( private class TimeRead(
@@ -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.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.statusFromInt import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -221,12 +222,17 @@ class OneShotImport @Inject constructor(
} }
val alarms = mutableListOf<LegacyAlarm>() val alarms = mutableListOf<LegacyAlarm>()
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null) val categories = mutableMapOf<Long, MutableList<String>>()
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties ORDER BY property_id", null)
.use { cursor -> .use { cursor ->
val r = CursorColumnReader(cursor) val r = CursorColumnReader(cursor)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
val taskId = r.getLong("task_id") ?: 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 val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
alarms += LegacyAlarm( alarms += LegacyAlarm(
taskId = taskId, 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 ---------------------------------------------------- // --- writing into Room ----------------------------------------------------
@@ -276,7 +282,12 @@ class OneShotImport @Inject constructor(
// the unique index; a fresh uid costs the row nothing it still has. // 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)) } val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
?: UUID.randomUUID().toString() ?: 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) val newId = taskDao.insert(entity)
taskIds[row.id] = newId taskIds[row.id] = newId
inserted += row to entity.copy(id = newId) inserted += row to entity.copy(id = newId)
@@ -379,6 +390,7 @@ class OneShotImport @Inject constructor(
const val ARCHIVE_NAME = "tasks.db.imported" const val ARCHIVE_NAME = "tasks.db.imported"
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm" 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 const val REFERENCE_START = "2"
private val SIDECARS = listOf("-journal", "-wal", "-shm") private val SIDECARS = listOf("-journal", "-wal", "-shm")
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done") private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
@@ -391,6 +403,8 @@ private class LegacySnapshot(
val lists: List<LegacyList>, val lists: List<LegacyList>,
val tasks: List<LegacyTaskRow>, val tasks: List<LegacyTaskRow>,
val alarms: List<LegacyAlarm>, val alarms: List<LegacyAlarm>,
/** Tags by legacy task `_id`. */
val categories: Map<Long, List<String>>,
) )
private class LegacyList(val id: Long, val entity: TaskListEntity) private class LegacyList(val id: Long, val entity: TaskListEntity)
@@ -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<String>, 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<String> =
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()
}
}
@@ -2,6 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.TypeConverter import androidx.room.TypeConverter
import de.jeanlucmakiola.agendula.domain.TaskStatus 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.statusFromInt
import de.jeanlucmakiola.agendula.domain.toInt import de.jeanlucmakiola.agendula.domain.toInt
import kotlin.time.Instant import kotlin.time.Instant
@@ -36,3 +38,17 @@ object Converters {
fun alarmReferenceFrom(value: String): AlarmReference = fun alarmReferenceFrom(value: String): AlarmReference =
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE) 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<String>` picks up iCalendar escaping by accident.
*/
object CategoriesConverter {
@TypeConverter
fun toText(value: List<String>): String = ICalValues.formatTextList(normalizeTags(value))
@TypeConverter
fun fromText(value: String): List<String> =
if (value.isEmpty()) emptyList() else normalizeTags(ICalValues.parseTextList(value))
}
@@ -5,6 +5,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant import kotlin.time.Instant
@@ -143,6 +144,10 @@ data class TaskEntity(
@ColumnInfo(name = "url") val url: String? = null, @ColumnInfo(name = "url") val url: String? = null,
/** ARGB override for the list colour. */ /** ARGB override for the list colour. */
@ColumnInfo(name = "color") val color: Int? = null, @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<String> = emptyList(),
// state // state
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION, @ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
@@ -49,6 +49,7 @@ object RoomTaskMapper {
description = row.task.description, description = row.task.description,
location = row.task.location, location = row.task.location,
url = row.task.url, url = row.task.url,
tags = row.task.categories,
priority = priorityFromICal(row.task.priority), priority = priorityFromICal(row.task.priority),
status = row.task.status, status = row.task.status,
percentComplete = row.task.percentComplete, percentComplete = row.task.percentComplete,
@@ -79,6 +80,7 @@ object RoomTaskMapper {
description = task.description, description = task.description,
location = task.location, location = task.location,
url = task.url, url = task.url,
tags = task.categories,
priority = priorityFromICal(task.priority), priority = priorityFromICal(task.priority),
status = task.status, status = task.status,
percentComplete = task.percentComplete, percentComplete = task.percentComplete,
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.ical.ICalValues import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.toICal import de.jeanlucmakiola.agendula.domain.toICal
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -50,6 +51,7 @@ object TaskFormWriter {
description = form.description?.trim()?.ifBlank { null }, description = form.description?.trim()?.ifBlank { null },
location = form.location?.trim()?.ifBlank { null }, location = form.location?.trim()?.ifBlank { null },
url = form.url?.trim()?.ifBlank { null }, url = form.url?.trim()?.ifBlank { null },
categories = normalizeTags(form.tags),
priority = form.priority.toICal(), priority = form.priority.toICal(),
percentComplete = percent, percentComplete = percent,
status = statusFor(percent, current.status), status = statusFor(percent, current.status),
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/** /**
* Agendula's own task store. Four tables, designed from * Agendula's own task store. Four tables, designed from
@@ -18,7 +20,7 @@ import androidx.room.TypeConverters
TaskEntity::class, TaskEntity::class,
TaskAlarmEntity::class, TaskAlarmEntity::class,
], ],
version = 1, version = 2,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@@ -30,5 +32,36 @@ abstract class TasksDatabase : RoomDatabase() {
companion object { companion object {
const val NAME = "agendula-tasks.db" 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<Pair<Long, CategoriesLift.Result>>()
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<Any?>(categories, result.residue, id),
)
} else {
db.execSQL("UPDATE tasks SET categories = ? WHERE id = ?", arrayOf<Any?>(categories, id))
}
}
}
}
} }
} }
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.toICal import de.jeanlucmakiola.agendula.domain.toICal
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -213,6 +214,7 @@ class ExternalImport @Inject constructor(
description = description, description = description,
location = location, location = location,
url = url, url = url,
categories = normalizeTags(tags),
status = status, status = status,
percentComplete = percentComplete, percentComplete = percentComplete,
completedAt = completedAt, completedAt = completedAt,
@@ -52,6 +52,8 @@ data class Task(
val description: String?, val description: String?,
val location: String?, val location: String?,
val url: String?, val url: String?,
/** `CATEGORIES`, as the task spells them. */
val tags: List<String> = emptyList(),
val priority: Priority, val priority: Priority,
val status: TaskStatus, val status: TaskStatus,
val percentComplete: Int?, val percentComplete: Int?,
@@ -161,3 +163,10 @@ fun TaskStatus.toInt(): Int = when (this) {
TaskStatus.COMPLETED -> ICalStatus.COMPLETED TaskStatus.COMPLETED -> ICalStatus.COMPLETED
TaskStatus.CANCELLED -> ICalStatus.CANCELLED TaskStatus.CANCELLED -> ICalStatus.CANCELLED
} }
/** Tags as every writer stores them: trimmed, no blanks, each spelling once, order kept. */
fun normalizeTags(tags: List<String>): List<String> = tags.map(String::trim).filter(String::isNotEmpty).distinct()
/** Every tag on [tasks], each spelling once, alphabetically ignoring case. */
fun tagsInUse(tasks: Iterable<Task>): List<String> =
tasks.flatMap { it.tags }.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER)
@@ -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<DueWindow> = emptySet(),
val listIds: Set<Long> = emptySet(),
val tags: Set<String> = 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<Long>): 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<Long> = emptySet(),
val tags: List<String> = 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,
)
@@ -5,12 +5,16 @@ import kotlin.time.Instant
/** The built-in "smart" lists, computed from due dates rather than membership. */ /** The built-in "smart" lists, computed from due dates rather than membership. */
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED } 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 { sealed interface TaskFilter {
data class OfList(val listId: Long) : TaskFilter data class OfList(val listId: Long) : TaskFilter
/** Several lists shown together, e.g. one widget covering more than one list. */ /** Several lists shown together, e.g. one widget covering more than one list. */
data class OfLists(val listIds: Set<Long>) : TaskFilter data class OfLists(val listIds: Set<Long>) : TaskFilter
data class Smart(val list: SmartList) : 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
} }
/** /**
@@ -20,22 +24,32 @@ sealed interface TaskFilter {
*/ */
object TaskFiltering { 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) { when (filter) {
is TaskFilter.OfList -> task.listId == filter.listId is TaskFilter.OfList -> task.listId == filter.listId
is TaskFilter.OfLists -> task.listId in filter.listIds is TaskFilter.OfLists -> task.listId in filter.listIds
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd) 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 { /** Whether [task] carries every one of [tags]; an empty set matches everything. */
val due = task.due fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags)
return when (list) {
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean =
when (list) {
SmartList.COMPLETED -> task.isCompleted SmartList.COMPLETED -> task.isCompleted
SmartList.ALL -> !task.isClosed SmartList.ALL -> !task.isClosed
SmartList.NO_DATE -> !task.isClosed && due == null SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE
SmartList.OVERDUE -> !task.isClosed && due != null && due < todayStart SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE
SmartList.TODAY -> !task.isClosed && due != null && due >= todayStart && due < todayEnd SmartList.TODAY -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.TODAY
SmartList.UPCOMING -> !task.isClosed && due != null && due >= todayEnd SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING
}
} }
} }
@@ -22,6 +22,7 @@ data class TaskForm(
val reminders: List<Int> = emptyList(), val reminders: List<Int> = emptyList(),
val location: String? = null, val location: String? = null,
val url: String? = null, val url: String? = null,
val tags: List<String> = emptyList(),
/** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */ /** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */
val rrule: String? = null, 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 * 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. * 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. */ /** The optional fields that already carry a value — auto-revealed when editing. */
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet { fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
@@ -56,4 +57,5 @@ fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
if (rrule != null) add(TaskFormField.Recurrence) if (rrule != null) add(TaskFormField.Recurrence)
if (!location.isNullOrBlank()) add(TaskFormField.Location) if (!location.isNullOrBlank()) add(TaskFormField.Location)
if (!url.isNullOrBlank()) add(TaskFormField.Url) if (!url.isNullOrBlank()) add(TaskFormField.Url)
if (tags.isNotEmpty()) add(TaskFormField.Tags)
} }
@@ -29,11 +29,11 @@ object TaskSections {
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection { fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
if (task.isCompleted) return TaskSection.COMPLETED if (task.isCompleted) return TaskSection.COMPLETED
val due = task.due ?: return TaskSection.NO_DATE return when (dueWindowOf(task, todayStart, todayEnd)) {
return when { DueWindow.OVERDUE -> TaskSection.OVERDUE
due < todayStart -> TaskSection.OVERDUE DueWindow.TODAY -> TaskSection.TODAY
due < todayEnd -> TaskSection.TODAY DueWindow.UPCOMING -> TaskSection.UPCOMING
else -> TaskSection.UPCOMING DueWindow.NO_DATE -> TaskSection.NO_DATE
} }
} }
} }
@@ -42,6 +42,7 @@ data class ExportTask(
val rdate: String?, val rdate: String?,
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */ /** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
val parentId: Long?, val parentId: Long?,
val tags: List<String> = emptyList(),
) )
/** A task list and everything in it, ready to become one `.ics` document. */ /** A task list and everything in it, ready to become one `.ics` document. */
@@ -88,6 +88,7 @@ object ICalendarWriter {
task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) } task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) }
// URL is a URI, not TEXT: it must not be escaped like one. // URL is a URI, not TEXT: it must not be escaped like one.
task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) } 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.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
task.due?.let { dateProperty("DUE", it, task.isAllDay) } task.due?.let { dateProperty("DUE", it, task.isAllDay) }
@@ -49,6 +49,37 @@ object ICalValues {
return out.toString() 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<String> {
val values = mutableListOf<String>()
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>): String = values.joinToString(",", transform = ::escapeText)
/** How a DATE / DATE-TIME property was written, and whether we can reproduce it. */ /** How a DATE / DATE-TIME property was written, and whether we can reproduce it. */
sealed interface TimeValue { sealed interface TimeValue {
/** `VALUE=DATE` — date-only. */ /** `VALUE=DATE` — date-only. */
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Percent import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material.icons.rounded.Repeat import androidx.compose.material.icons.rounded.Repeat
import androidx.compose.material.icons.rounded.Sell
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
@@ -23,6 +24,7 @@ fun TaskFormField.labelRes(): Int = when (this) {
TaskFormField.Reminder -> R.string.edit_reminder_label TaskFormField.Reminder -> R.string.edit_reminder_label
TaskFormField.Location -> R.string.edit_location_label TaskFormField.Location -> R.string.edit_location_label
TaskFormField.Url -> R.string.edit_url_label TaskFormField.Url -> R.string.edit_url_label
TaskFormField.Tags -> R.string.edit_tags_label
} }
fun TaskFormField.icon(): ImageVector = when (this) { fun TaskFormField.icon(): ImageVector = when (this) {
@@ -34,4 +36,5 @@ fun TaskFormField.icon(): ImageVector = when (this) {
TaskFormField.Reminder -> Icons.Rounded.Notifications TaskFormField.Reminder -> Icons.Rounded.Notifications
TaskFormField.Location -> Icons.Rounded.LocationOn TaskFormField.Location -> Icons.Rounded.LocationOn
TaskFormField.Url -> Icons.Rounded.Link TaskFormField.Url -> Icons.Rounded.Link
TaskFormField.Tags -> Icons.Rounded.Sell
} }
@@ -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),
)
}
}
}
@@ -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() }
}
}
@@ -7,6 +7,8 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.Arrangement 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
@@ -42,6 +44,7 @@ import android.net.Uri
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Link 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.LocationOn
import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Repeat 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.domain.recurrence.RecurringScope
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog 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.linkified
import de.jeanlucmakiola.agendula.ui.common.recurrenceText import de.jeanlucmakiola.agendula.ui.common.recurrenceText
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel 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.readableDescription
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.floret.components.revealOnFocus
import de.jeanlucmakiola.floret.components.GroupedSurface import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.agendula.ui.common.PriorityChip import de.jeanlucmakiola.agendula.ui.common.PriorityChip
import de.jeanlucmakiola.floret.time.formatDate import de.jeanlucmakiola.floret.time.formatDate
@@ -240,6 +245,7 @@ fun TaskDetailScreen(
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun DetailBody( private fun DetailBody(
detail: TaskDetail, 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. // Description — selectable, with its links tappable.
task.description?.readableDescription()?.takeIf { it.isNotBlank() }?.let { description -> task.description?.readableDescription()?.takeIf { it.isNotBlank() }?.let { description ->
add { add {
@@ -606,7 +625,7 @@ private fun AddSubtaskField(onAdd: (String) -> Unit) {
} }
val accentOn = MaterialTheme.colorScheme.onPrimaryContainer val accentOn = MaterialTheme.colorScheme.onPrimaryContainer
Row( 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, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) { Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
@@ -1,9 +1,25 @@
package de.jeanlucmakiola.agendula.ui.edit 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.LocalFirstDayOfWeek
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.material.icons.rounded.Link 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.LocationOn
import androidx.compose.material.icons.rounded.Repeat import androidx.compose.material.icons.rounded.Repeat
import androidx.compose.ui.text.AnnotatedString 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.labelRes
import de.jeanlucmakiola.agendula.ui.common.recurrenceText import de.jeanlucmakiola.agendula.ui.common.recurrenceText
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.floret.components.revealOnFocus
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSectionHeader import de.jeanlucmakiola.floret.components.GroupedSectionHeader
@@ -153,11 +170,25 @@ fun TaskEditScreen(
val requestBack = { if (state.isDirty && !state.saved) showDiscard = true else onBack() } val requestBack = { if (state.isDirty && !state.saved) showDiscard = true else onBack() }
BackHandler(enabled = state.isDirty && !state.saved) { showDiscard = true } 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( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TopAppBar( 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 = { navigationIcon = {
IconButton(onClick = requestBack) { IconButton(onClick = requestBack) {
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close)) Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close))
@@ -180,6 +211,8 @@ fun TaskEditScreen(
EditContent( EditContent(
state = state, state = state,
viewModel = viewModel, viewModel = viewModel,
scroll = scroll,
onTitleBottom = { titleBottom = it },
modifier = Modifier.padding(inner), modifier = Modifier.padding(inner),
) )
} }
@@ -220,6 +253,8 @@ private enum class PickerTarget { Start, Due }
private fun EditContent( private fun EditContent(
state: TaskEditUiState, state: TaskEditUiState,
viewModel: TaskEditViewModel, viewModel: TaskEditViewModel,
scroll: ScrollState,
onTitleBottom: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
@@ -250,7 +285,7 @@ private fun EditContent(
// Shrink the scroll viewport by the keyboard so the focused field // Shrink the scroll viewport by the keyboard so the focused field
// scrolls into view above the IME instead of being hidden. // scrolls into view above the IME instead of being hidden.
.imePadding() .imePadding()
.verticalScroll(rememberScrollState()) .verticalScroll(scroll)
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp), .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
) { ) {
// Title: borderless headline + accent bar, mirroring the detail screen. // Title: borderless headline + accent bar, mirroring the detail screen.
@@ -259,7 +294,9 @@ private fun EditContent(
onValueChange = viewModel::onTitleChange, onValueChange = viewModel::onTitleChange,
placeholder = stringResource(R.string.edit_title_hint), placeholder = stringResource(R.string.edit_title_hint),
textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), 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)) Spacer(Modifier.height(10.dp))
Box( 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()) { OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
TextButton( TextButton(
@@ -727,9 +777,9 @@ private fun EditCard(
} }
} }
if (onClick != null) { 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 { } 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<String>,
draft: String,
suggestions: List<String>,
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. */ /** Picks one hidden optional section to add to the form. */
@Composable @Composable
private fun FieldPickerDialog( private fun FieldPickerDialog(
@@ -8,15 +8,20 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskFormError import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.populatedFields import de.jeanlucmakiola.agendula.domain.populatedFields
import de.jeanlucmakiola.agendula.domain.htmlToPlainText import de.jeanlucmakiola.agendula.domain.htmlToPlainText
import de.jeanlucmakiola.agendula.domain.looksLikeHtml 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.rebasedForAllDay
import de.jeanlucmakiola.agendula.domain.readableDescription import de.jeanlucmakiola.agendula.domain.readableDescription
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
@@ -25,6 +30,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -51,6 +57,11 @@ data class TaskEditUiState(
val reminders: List<Int> = emptyList(), val reminders: List<Int> = emptyList(),
val location: String = "", val location: String = "",
val url: String = "", val url: String = "",
val tags: List<String> = 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<String> = emptyList(),
val rrule: String? = null, val rrule: String? = null,
/** Editing one occurrence of a series: saving asks how far the edit reaches. */ /** Editing one occurrence of a series: saving asks how far the edit reaches. */
val isOccurrence: Boolean = false, val isOccurrence: Boolean = false,
@@ -114,7 +125,13 @@ class TaskEditViewModel @Inject constructor(
private var defaultFields: Set<TaskFormField> = emptySet() private var defaultFields: Set<TaskFormField> = emptySet()
/** Start a fresh task, optionally pre-selecting a list / parent. */ /** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null, initialTitle: String? = null) { fun bindNew(
presetListId: Long? = null,
parentId: Long? = null,
initialTitle: String? = null,
presetTag: String? = null,
presetFilterId: Long? = null,
) {
if (bound) return if (bound) return
bound = true bound = true
editingTaskId = null editingTaskId = null
@@ -123,12 +140,16 @@ class TaskEditViewModel @Inject constructor(
val settings = settingsPrefs.settings.first() val settings = settingsPrefs.settings.first()
defaultFields = settings.defaultEditFields defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
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 // 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 // since deleted or turned read-only — would give the task a list it
// cannot be saved to. // cannot be saved to.
val writable = lists.filter { it.acceptsWrites } val writable = lists.filter { it.acceptsWrites }
val filterLists = preset?.listIds.orEmpty()
val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } } val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } }
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } } ?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } && (filterLists.isEmpty() || id in filterLists) }
?: writable.firstOrNull { it.id in filterLists }?.id
?: writable.firstOrNull { !it.isLocal }?.id ?: writable.firstOrNull { !it.isLocal }?.id
?: writable.firstOrNull()?.id ?: writable.firstOrNull()?.id
_state.value = withFields( _state.value = withFields(
@@ -137,11 +158,22 @@ class TaskEditViewModel @Inject constructor(
isNew = true, isNew = true,
autofocusTitle = settings.autofocusTitle, autofocusTitle = settings.autofocusTitle,
listId = defaultList, listId = defaultList,
start = if (settings.defaultStartToday) Clock.System.now() else null, // 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, parentId = parentId,
lists = lists, lists = lists,
parentCandidates = loadParents(defaultList, selfId = null), parentCandidates = loadParents(defaultList, selfId = null),
title = initialTitle.orEmpty(), title = initialTitle.orEmpty(),
tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(),
), ),
).also { baseline = it.toForm().copy(title = "") } ).also { baseline = it.toForm().copy(title = "") }
} }
@@ -179,6 +211,7 @@ class TaskEditViewModel @Inject constructor(
reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore }, reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore },
location = task.location.orEmpty(), location = task.location.orEmpty(),
url = task.url.orEmpty(), url = task.url.orEmpty(),
tags = task.tags,
rrule = task.recurrenceRule, rrule = task.recurrenceRule,
originalRule = task.recurrenceRule, originalRule = task.recurrenceRule,
isOccurrence = task.isOccurrence, isOccurrence = task.isOccurrence,
@@ -202,6 +235,18 @@ class TaskEditViewModel @Inject constructor(
return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId } 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). */ /** Reveal an optional section (one tap from the "More fields" picker). */
fun revealField(field: TaskFormField) = update { fun revealField(field: TaskFormField) = update {
val visible = it.visibleFields + field val visible = it.visibleFields + field
@@ -257,6 +302,18 @@ class TaskEditViewModel @Inject constructor(
fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) } fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) }
fun onLocationChange(value: String) = update { it.copy(location = value) } fun onLocationChange(value: String) = update { it.copy(location = value) }
fun onUrlChange(value: String) = update { it.copy(url = 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()) } fun onRecurrenceChange(rrule: String?) = update { it.copy(rrule = rrule, errors = emptySet()) }
/** Dismiss the conflict prompt without saving (the user keeps editing). */ /** Dismiss the conflict prompt without saving (the user keeps editing). */
@@ -339,5 +396,8 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
reminders = reminders, reminders = reminders,
location = location.ifBlank { null }, location = location.ifBlank { null },
url = url.ifBlank { null }, url = url.ifBlank { null },
tags = withDraft(tagDraft),
rrule = rrule, rrule = rrule,
) )
private fun TaskEditUiState.withDraft(draft: String): List<String> = normalizeTags(tags + draft.split(','))
@@ -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 <T> Set<T>.toggle(item: T): Set<T> = 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
},
)
@@ -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<SavedFilter?>(null)
/** `null` until [bind] has loaded the filter. An id of 0 is a new one. */
val draft: StateFlow<SavedFilter?> = _draft.asStateFlow()
val lists: StateFlow<List<TaskList>> = repository.taskLists()
.catch { emit(emptyList()) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** Every tag on an open task. */
val tags: StateFlow<List<String>> = 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()
}
}
}
@@ -30,7 +30,6 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -40,25 +39,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Color
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.ui.common.DefaultListColor import de.jeanlucmakiola.agendula.ui.common.DefaultListColor
import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.ListPalette 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.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSurface import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.pastelize
@@ -258,27 +254,14 @@ internal fun ListNameField(
autoFocus: Boolean, autoFocus: Boolean,
onNameChange: (String) -> Unit, onNameChange: (String) -> Unit,
onImeAction: () -> Unit, onImeAction: () -> Unit,
) { ) = NameField(
val focusRequester = remember { FocusRequester() } name = name,
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), placeholder = stringResource(R.string.list_name_hint),
imeAction = ImeAction.Done, autoFocus = autoFocus,
onNameChange = onNameChange,
onImeAction = onImeAction, onImeAction = onImeAction,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), leading = { ListColorChip(color) },
) )
}
}
}
/** /**
* The palette as two rows of round swatches; the chosen one carries a check. * The palette as two rows of round swatches; the chosen one carries a check.
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.lists
import de.jeanlucmakiola.floret.components.ReorderableColumn import de.jeanlucmakiola.floret.components.ReorderableColumn
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.DragHandle 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.compose.material.icons.rounded.SwapVert
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility 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.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed 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.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.common.ActionShapes import de.jeanlucmakiola.agendula.ui.common.ActionShapes
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SnackChip import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin import de.jeanlucmakiola.floret.components.SnackChipMargin
@@ -114,6 +118,7 @@ fun ListsScreen(
onNewTask: () -> Unit, onNewTask: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onNewFilter: () -> Unit = {},
viewModel: ListsViewModel = hiltViewModel(), viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@@ -179,6 +184,7 @@ fun ListsScreen(
onOpenFilter = onOpenFilter, onOpenFilter = onOpenFilter,
onOpenTask = onOpenTask, onOpenTask = onOpenTask,
onNewList = { newList = true }, onNewList = { newList = true },
onNewFilter = onNewFilter,
onReorder = viewModel::reorderLists, onReorder = viewModel::reorderLists,
topPadding = 0.dp, topPadding = 0.dp,
bottomPadding = inner.calculateBottomPadding() + 96.dp, bottomPadding = inner.calculateBottomPadding() + 96.dp,
@@ -252,6 +258,7 @@ private fun ListsContent(
onOpenFilter: (TaskFilter) -> Unit, onOpenFilter: (TaskFilter) -> Unit,
onOpenTask: (Task) -> Unit, onOpenTask: (Task) -> Unit,
onNewList: () -> Unit, onNewList: () -> Unit,
onNewFilter: () -> Unit,
onReorder: (List<Long>) -> Unit, onReorder: (List<Long>) -> Unit,
topPadding: androidx.compose.ui.unit.Dp, topPadding: androidx.compose.ui.unit.Dp,
bottomPadding: 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<FilterOverview>,
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)) },
)
} }
} }
@@ -8,11 +8,13 @@ import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.floret.time.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.tagsInUse
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -73,6 +75,8 @@ internal fun RemoteLists.Outcome.asFailure(local: ListWriteFailure): ListWriteFa
data class ListOverview(val list: TaskList, val openCount: Int) data class ListOverview(val list: TaskList, val openCount: Int)
data class AccountGroup(val accountName: String, val lists: List<ListOverview>) data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
data class SmartCount(val smart: SmartList, val count: Int) 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 { sealed interface ListsUiState {
data object Loading : ListsUiState data object Loading : ListsUiState
@@ -87,6 +91,10 @@ sealed interface ListsUiState {
val upcoming: List<Task>, val upcoming: List<Task>,
/** Every task (open and completed) — the corpus the home search filters. */ /** Every task (open and completed) — the corpus the home search filters. */
val allTasks: List<Task>, val allTasks: List<Task>,
/** Each tag on an open task, alphabetically. */
val tags: List<TagOverview> = emptyList(),
/** The saved filters, oldest first. */
val filters: List<FilterOverview> = emptyList(),
) : ListsUiState ) : ListsUiState
} }
@@ -125,8 +133,9 @@ class ListsViewModel @Inject constructor(
// ones already ticked off to show "x of y done", so read them too. // ones already ticked off to show "x of y done", so read them too.
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)), repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
settingsPrefs.settings.map { it.hiddenFromSmartLists }.distinctUntilChanged(), settingsPrefs.settings.map { it.hiddenFromSmartLists }.distinctUntilChanged(),
) { lists, openTasks, completedTasks, hidden -> settingsPrefs.savedFilters,
buildContent(lists, openTasks, completedTasks, hidden) as ListsUiState ) { lists, openTasks, completedTasks, hidden, filters ->
buildContent(lists, openTasks, completedTasks, hidden, filters) as ListsUiState
}.recoveringFromProviderFailure { ListsUiState.Failure } }.recoveringFromProviderFailure { ListsUiState.Failure }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
@@ -135,6 +144,7 @@ class ListsViewModel @Inject constructor(
openTasks: List<Task>, openTasks: List<Task>,
completedTasks: List<Task>, completedTasks: List<Task>,
hidden: Set<Long>, hidden: Set<Long>,
savedFilters: List<SavedFilter>,
): ListsUiState.Content { ): ListsUiState.Content {
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
// Count only top-level tasks: a subtask is represented by its parent (and // Count only top-level tasks: a subtask is represented by its parent (and
@@ -177,13 +187,29 @@ class ListsViewModel @Inject constructor(
) )
} }
.sortedBy { it.accountName.lowercase() } .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( return ListsUiState.Content(
smartCounts = smartCounts, smartCounts = smartCounts,
filters = filters,
groups = groups, groups = groups,
todayDone = completedDueToday, todayDone = completedDueToday,
todayTotal = todayTotal, todayTotal = todayTotal,
upcoming = upcoming, upcoming = upcoming,
allTasks = openTasks + completedTasks, allTasks = allTasks,
tags = tags,
) )
} }
@@ -20,6 +20,8 @@ import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel
import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen
import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel 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.imports.ImportScreen
import de.jeanlucmakiola.agendula.ui.lists.ListsScreen import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
@@ -60,6 +62,9 @@ fun AgendulaNavHost(
is NavRequest.OpenList -> nav.navigate(Dest.TaskList.build(TaskFilter.OfList(navRequest.listId))) { is NavRequest.OpenList -> nav.navigate(Dest.TaskList.build(TaskFilter.OfList(navRequest.listId))) {
launchSingleTop = true 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)) is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
} }
onNavRequestConsumed() onNavRequestConsumed()
@@ -85,6 +90,7 @@ fun AgendulaNavHost(
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) }, onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
onOpenTask = { task -> nav.navigate(task.detailRoute()) }, onOpenTask = { task -> nav.navigate(task.detailRoute()) },
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) }, onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
onNewFilter = { nav.navigate(Dest.FilterEdit.build()) },
onOpenSettings = { nav.navigate(Dest.SETTINGS) }, onOpenSettings = { nav.navigate(Dest.SETTINGS) },
) )
} }
@@ -114,6 +120,8 @@ fun AgendulaNavHost(
val filter = Dest.TaskList.filterOf( val filter = Dest.TaskList.filterOf(
smart = args?.getString(Dest.TaskList.ARG_SMART), smart = args?.getString(Dest.TaskList.ARG_SMART),
listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID, 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() val vm: TaskListViewModel = hiltViewModel()
LaunchedEffect(filter) { vm.bind(filter) } LaunchedEffect(filter) { vm.bind(filter) }
@@ -124,9 +132,15 @@ fun AgendulaNavHost(
viewModel = vm, viewModel = vm,
onOpenTask = onOpenTask, onOpenTask = onOpenTask,
onNewTask = { onNewTask = {
val presetListId = (filter as? TaskFilter.OfList)?.listId nav.navigate(
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId)) 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() }, onBack = { nav.popBackStack() },
) )
} }
@@ -139,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 -> composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry ->
val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID 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 } val occurrence = entry.arguments?.getLong(Dest.TaskDetail.ARG_OCC)?.takeIf { it != Dest.NO_ID }
@@ -166,6 +206,8 @@ fun AgendulaNavHost(
val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID 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 occurrence = args?.getLong(Dest.TaskEdit.ARG_OCCURRENCE)?.takeIf { it != Dest.NO_ID }
val title = args?.getString(Dest.TaskEdit.ARG_TITLE) 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() val vm: TaskEditViewModel = hiltViewModel()
LaunchedEffect(taskId, presetListId, parentId) { LaunchedEffect(taskId, presetListId, parentId) {
if (taskId != Dest.NO_ID) { if (taskId != Dest.NO_ID) {
@@ -175,6 +217,8 @@ fun AgendulaNavHost(
presetListId = presetListId.takeIf { it != Dest.NO_ID }, presetListId = presetListId.takeIf { it != Dest.NO_ID },
parentId = parentId.takeIf { it != Dest.NO_ID }, parentId = parentId.takeIf { it != Dest.NO_ID },
initialTitle = title, initialTitle = title,
presetTag = tag,
presetFilterId = filterId,
) )
} }
} }
@@ -53,30 +53,51 @@ object Dest {
} }
/** /**
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or* * One [TaskFilter]'s tasks. Carries one of `listId` (a real list), `smart`
* `smart` (a [SmartList] name); whichever is set decides the filter. * (a [SmartList] name), `tag` or `filterId` (a saved filter); whichever is
* set decides the filter.
*/ */
object TaskList { object TaskList {
const val ARG_LIST_ID = "listId" const val ARG_LIST_ID = "listId"
const val ARG_SMART = "smart" 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( val arguments = listOf(
navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID }, navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null }, 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) { fun build(filter: TaskFilter): String = when (filter) {
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}" is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}" is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
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. // 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.OfLists -> error("OfLists has no task-list screen")
} }
/** Reconstruct the filter from a destination's arguments. */ /** Reconstruct the filter from a destination's arguments. */
fun filterOf(smart: String?, listId: Long): TaskFilter = fun filterOf(smart: String?, listId: Long, tag: String? = null, filterId: Long = NO_ID): TaskFilter = when {
if (smart != null) TaskFilter.Smart(SmartList.valueOf(smart)) smart != null -> TaskFilter.Smart(SmartList.valueOf(smart))
else TaskFilter.OfList(listId) 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. */ /** A single task's detail; `occ` (epoch millis) picks one occurrence of a series. */
@@ -97,7 +118,8 @@ object Dest {
/** /**
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new * 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 { object TaskEdit {
const val ARG_TASK_ID = "taskId" const val ARG_TASK_ID = "taskId"
@@ -105,12 +127,16 @@ object Dest {
const val ARG_PARENT_ID = "parentId" const val ARG_PARENT_ID = "parentId"
const val ARG_OCCURRENCE = "occ" const val ARG_OCCURRENCE = "occ"
const val ARG_TITLE = "title" const val ARG_TITLE = "title"
const val ARG_TAG = "tag"
const val ARG_FILTER_ID = "filterId"
const val route = const val route =
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" + "taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" + "&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
"&$ARG_PARENT_ID={$ARG_PARENT_ID}" + "&$ARG_PARENT_ID={$ARG_PARENT_ID}" +
"&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" + "&$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( val arguments = listOf(
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID }, navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
@@ -118,17 +144,27 @@ object Dest {
navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID }, navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_OCCURRENCE) { 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_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 = fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
if (occurrenceStart == null) "taskEdit?$ARG_TASK_ID=$taskId" if (occurrenceStart == null) "taskEdit?$ARG_TASK_ID=$taskId"
else "taskEdit?$ARG_TASK_ID=$taskId&$ARG_OCCURRENCE=$occurrenceStart" 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 { val params = buildList {
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId") if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
if (parentId != null) add("$ARG_PARENT_ID=$parentId") if (parentId != null) add("$ARG_PARENT_ID=$parentId")
if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}") 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("&")}" return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
} }
@@ -30,6 +30,9 @@ sealed interface NavRequest {
/** One real list — a widget header. */ /** One real list — a widget header. */
data class OpenList(val listId: Long) : NavRequest 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. */ /** An `.ics` another app handed over. */
data class Import(val uri: Uri) : NavRequest data class Import(val uri: Uri) : NavRequest
} }
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -48,6 +49,8 @@ import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -57,14 +60,19 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface 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.Repeat
import androidx.compose.material.icons.rounded.Sell
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import de.jeanlucmakiola.agendula.domain.TaskSortOrder import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.floret.components.revealOnFocus
import de.jeanlucmakiola.floret.components.InlineTextField 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.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -132,6 +140,7 @@ fun TaskListScreen(
onNewTask: () -> Unit, onNewTask: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onEditFilter: (Long) -> Unit = {},
viewModel: TaskListViewModel = hiltViewModel(), viewModel: TaskListViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@@ -150,6 +159,9 @@ fun TaskListScreen(
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle() val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
// The list this screen is about is gone; there is nothing left to show. // The list this screen is about is gone; there is nothing left to show.
LaunchedEffect(listDeleted) { if (listDeleted) onBack() } 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) { LaunchedEffect(listWriteFailure) {
if (listWriteFailure != null) { if (listWriteFailure != null) {
delay(4_000) delay(4_000)
@@ -178,7 +190,7 @@ fun TaskListScreen(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
MediumTopAppBar( MediumTopAppBar(
title = { Text(titleFor(filter, listName)) }, title = { Text(titleFor(filter, listName ?: savedFilter?.name)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
@@ -189,7 +201,14 @@ fun TaskListScreen(
}, },
actions = { actions = {
IconButton(onClick = { showSort = true }, enabled = content != null) { IconButton(onClick = { showSort = true }, enabled = content != null) {
Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title)) BadgedBox(badge = { if (content?.selectedTags?.isNotEmpty() == true) Badge() }) {
Icon(Icons.Rounded.Tune, contentDescription = stringResource(R.string.view_options_title))
}
}
if (savedFilter != null) {
IconButton(onClick = { onEditFilter(savedFilter.id) }) {
Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.filter_edit_title))
}
} }
if (list != null) { if (list != null) {
IconButton( IconButton(
@@ -277,12 +296,13 @@ fun TaskListScreen(
} }
if (showSort && content != null) { if (showSort && content != null) {
OptionPicker( ViewOptionsPicker(
title = stringResource(R.string.sort_title), sortOrder = content.sortOrder,
options = TaskSortOrder.entries, tags = content.availableTags,
selected = content.sortOrder, selectedTags = content.selectedTags,
label = { stringResource(sortLabel(it)) }, onSortOrder = viewModel::setSortOrder,
onSelect = { viewModel.setSortOrder(it); showSort = false }, onToggleTag = viewModel::toggleTagFilter,
onClearTags = viewModel::clearTagFilter,
onDismiss = { showSort = false }, onDismiss = { showSort = false },
) )
} }
@@ -324,7 +344,7 @@ fun TaskListScreen(
} }
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable @Composable
private fun TaskListBody( private fun TaskListBody(
state: TaskListUiState.Content, state: TaskListUiState.Content,
@@ -349,7 +369,8 @@ private fun TaskListBody(
val listId = (filter as? TaskFilter.OfList)?.listId val listId = (filter as? TaskFilter.OfList)?.listId
// In a mixed view (a smart list) the list colour bar + name disambiguate // In a mixed view (a smart list) the list colour bar + name disambiguate
// rows; inside a single real list both are redundant. // 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. // Completed tasks collapse away by default to keep the active list in focus.
var completedExpanded by rememberSaveable { mutableStateOf(false) } var completedExpanded by rememberSaveable { mutableStateOf(false) }
// Parents the user expanded to reveal their subtasks inline. Children are // Parents the user expanded to reveal their subtasks inline. Children are
@@ -379,8 +400,16 @@ private fun TaskListBody(
// When the bottom quick-add bar is shown it already lives in the scaffold's // 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. // bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB.
val bottomBarShown = state.bottomAddBar && listId != null 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( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().then(viewport),
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = inner.calculateTopPadding() + 8.dp, top = inner.calculateTopPadding() + 8.dp,
bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp, bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp,
@@ -659,7 +688,7 @@ internal fun TaskRowContent(
val listName = task.listName?.takeIf { showListName && it.isNotBlank() } val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
// Everything secondary collapses into one quiet supporting line below the title. // Everything secondary collapses into one quiet supporting line below the title.
val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 || val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 ||
listName != null || task.isOccurrence listName != null || task.isOccurrence || task.tags.isNotEmpty()
Surface( Surface(
onClick = onClick, onClick = onClick,
@@ -714,6 +743,7 @@ internal fun TaskRowContent(
subtaskTotal = subtaskTotal, subtaskTotal = subtaskTotal,
listName = listName, listName = listName,
listColor = task.effectiveColor, listColor = task.effectiveColor,
tags = task.tags,
dark = dark, dark = dark,
) )
} }
@@ -745,6 +775,7 @@ private fun TaskMetaLine(
subtaskTotal: Int, subtaskTotal: Int,
listName: String?, listName: String?,
listColor: Int, listColor: Int,
tags: List<String>,
dark: Boolean, dark: Boolean,
) { ) {
val muted = MaterialTheme.colorScheme.onSurfaceVariant val muted = MaterialTheme.colorScheme.onSurfaceVariant
@@ -800,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) { if (listName != null) {
add { add {
Text( Text(
@@ -852,7 +905,7 @@ private fun AddSubtaskRow(
shape = cardShape(position, 22.dp, 6.dp), shape = cardShape(position, 22.dp, 6.dp),
// Same tone as the subtask rows it closes, so the group reads as one unit. // Same tone as the subtask rows it closes, so the group reads as one unit.
color = MaterialTheme.colorScheme.surfaceContainer, color = MaterialTheme.colorScheme.surfaceContainer,
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap).revealOnFocus(),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
@@ -1169,11 +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 @Composable
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) { private fun ViewOptionsPicker(
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title) sortOrder: TaskSortOrder,
tags: List<String>,
selectedTags: Set<String>,
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. // 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.OfLists -> error("OfLists has no task-list screen")
is TaskFilter.Tagged -> filter.tag
is TaskFilter.Smart -> stringResource( is TaskFilter.Smart -> stringResource(
when (filter.list) { when (filter.list) {
SmartList.TODAY -> R.string.smart_today SmartList.TODAY -> R.string.smart_today
@@ -7,8 +7,11 @@ import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.tagsInUse
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSortOrder import de.jeanlucmakiola.agendula.domain.TaskSortOrder
@@ -55,6 +58,14 @@ sealed interface TaskListUiState {
val sortOrder: TaskSortOrder = TaskSortOrder.DUE, val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
/** This real list is kept out of the smart lists. */ /** This real list is kept out of the smart lists. */
val hiddenFromSmartLists: Boolean = false, val hiddenFromSmartLists: Boolean = false,
/** Tags the unnarrowed view carries, offered as filter chips. */
val availableTags: List<String> = emptyList(),
/** The tags [tasks] is narrowed to; a task must carry all of them. */
val selectedTags: Set<String> = 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 ) : TaskListUiState
} }
@@ -83,6 +94,8 @@ class TaskListViewModel @Inject constructor(
private val filter = MutableStateFlow<TaskFilter?>(null) private val filter = MutableStateFlow<TaskFilter?>(null)
private val tagFilter = MutableStateFlow<Set<String>>(emptySet())
/** Occurrences swiped to delete but not yet committed — hidden until the undo lapses. */ /** Occurrences swiped to delete but not yet committed — hidden until the undo lapses. */
private val pendingDeletes = MutableStateFlow<Map<String, Task>>(emptyMap()) private val pendingDeletes = MutableStateFlow<Map<String, Task>>(emptyMap())
@@ -101,20 +114,33 @@ class TaskListViewModel @Inject constructor(
combine(tasks, repository.taskLists()) { rows, lists -> combine(tasks, repository.taskLists()) { rows, lists ->
TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId }) TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId })
} }
is TaskFilter.Smart -> is TaskFilter.Smart, is TaskFilter.Tagged ->
tasks.map { TaskListUiState.Content(it) } 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. // 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.OfLists -> error("OfLists has no task-list screen")
} }
// Drop rows pending an undoable delete so the row vanishes on swipe // Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit, // while the actual provider delete waits for the snackbar to commit,
// and fold in the live UI settings (add-subtask row, bottom add bar). // 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) { 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( st.copy(
tasks = settings.visibleIn(f, st.tasks) filter = f,
.filter { it.occurrenceKey !in pending } tasks = visible
.filter { TaskFiltering.hasAllTags(it, selected) }
.sortedWith(settings.sortOrder.comparator()), .sortedWith(settings.sortOrder.comparator()),
availableTags = available,
selectedTags = selected,
showAddSubtaskRow = settings.showAddSubtaskRow, showAddSubtaskRow = settings.showAddSubtaskRow,
bottomAddBar = settings.bottomAddBar, bottomAddBar = settings.bottomAddBar,
sortOrder = settings.sortOrder, sortOrder = settings.sortOrder,
@@ -163,6 +189,12 @@ class TaskListViewModel @Inject constructor(
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter } 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) { fun toggleComplete(task: Task) {
if (task.isCompleted) { if (task.isCompleted) {
viewModelScope.launch { viewModelScope.launch {
@@ -169,6 +169,12 @@ private fun TaskWidgetBody(
val ready = data as? TaskWidgetData.Ready val ready = data as? TaskWidgetData.Ready
val title = when (filter) { val title = when (filter) {
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list)) is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
is TaskFilter.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 is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
?: context.getString(R.string.widget_tasks_label) ?: context.getString(R.string.widget_tasks_label)
} }
@@ -279,6 +285,8 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo
// still opens straight to it, otherwise this just raises the app. // still opens straight to it, otherwise this just raises the app.
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) } is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ?: 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( WidgetHeader(
title = title, title = title,
@@ -41,6 +41,7 @@ import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
@@ -118,6 +119,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
setContent { setContent {
val settings by settingsPrefs.settings.collectAsState(initial = Settings()) val settings by settingsPrefs.settings.collectAsState(initial = Settings())
val lists by remember { listsFlow }.collectAsState(initial = emptyList()) val lists by remember { listsFlow }.collectAsState(initial = emptyList())
val savedFilters by settingsPrefs.savedFilters.collectAsState(initial = emptyList())
var current by remember { mutableStateOf<TaskFilter?>(null) } var current by remember { mutableStateOf<TaskFilter?>(null) }
var groupByList by remember { mutableStateOf(false) } var groupByList by remember { mutableStateOf(false) }
var background by remember { mutableStateOf(WidgetBackground.SOLID) } var background by remember { mutableStateOf(WidgetBackground.SOLID) }
@@ -143,6 +145,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
WidgetFilterPicker( WidgetFilterPicker(
kind = kind, kind = kind,
lists = lists.filter { it.isVisible }, lists = lists.filter { it.isVisible },
savedFilters = savedFilters,
dark = dark, dark = dark,
selected = current ?: DEFAULT_WIDGET_FILTER, selected = current ?: DEFAULT_WIDGET_FILTER,
groupByList = groupByList, groupByList = groupByList,
@@ -193,6 +196,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
private fun WidgetFilterPicker( private fun WidgetFilterPicker(
kind: WidgetKind, kind: WidgetKind,
lists: List<TaskList>, lists: List<TaskList>,
savedFilters: List<SavedFilter>,
selected: TaskFilter, selected: TaskFilter,
dark: Boolean, dark: Boolean,
groupByList: Boolean, groupByList: Boolean,
@@ -211,21 +215,25 @@ private fun WidgetFilterPicker(
var showBackgroundPicker by remember { mutableStateOf(false) } var showBackgroundPicker by remember { mutableStateOf(false) }
var showThemePicker by remember { mutableStateOf(false) } var showThemePicker by remember { mutableStateOf(false) }
var showRowColorPicker by remember { mutableStateOf(false) } var showRowColorPicker by remember { mutableStateOf(false) }
// Smart lists and real lists are mutually exclusive; only real lists support // Smart lists, saved filters and real lists are mutually exclusive; only real
// picking more than one. `selectedLists` empty + `selectedSmart` non-null is // lists support picking more than one. `selectedSmart` or `selectedFilter`
// a smart-list pick; the reverse (or both empty, before the stored filter // non-null is that pick; otherwise (or before the stored filter loads) it's
// loads) is a list pick. // a list pick.
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) } 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) { var selectedLists by remember(selected) {
mutableStateOf( mutableStateOf(
when (selected) { when (selected) {
is TaskFilter.OfList -> setOf(selected.listId) is TaskFilter.OfList -> setOf(selected.listId)
is TaskFilter.OfLists -> selected.listIds is TaskFilter.OfLists -> selected.listIds
is TaskFilter.Smart -> emptySet() is TaskFilter.Smart, is TaskFilter.Tagged, is TaskFilter.Saved -> emptySet()
}, },
) )
} }
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || selectedLists.isNotEmpty() // 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( FullScreenPicker(
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title), title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
@@ -239,8 +247,11 @@ private fun WidgetFilterPicker(
// a smart list, has to clear it here instead; otherwise it's // a smart list, has to clear it here instead; otherwise it's
// stuck on with no way left to see or undo it. // stuck on with no way left to see or undo it.
if (selectedLists.size <= 1 && groupByList) onGroupByListChange(false) if (selectedLists.size <= 1 && groupByList) onGroupByListChange(false)
selectedSmart?.let { onSave(TaskFilter.Smart(it)) } when {
?: onSave(TaskFilter.OfLists(selectedLists)) selectedSmart != null -> onSave(TaskFilter.Smart(selectedSmart!!))
liveFilter != null -> onSave(TaskFilter.Saved(liveFilter))
else -> onSave(TaskFilter.OfLists(selectedLists))
}
}, },
) { Text(stringResource(android.R.string.ok)) } ) { Text(stringResource(android.R.string.ok)) }
}, },
@@ -254,9 +265,22 @@ private fun WidgetFilterPicker(
position = positionOf(index, WIDGET_SMART_LISTS.size), position = positionOf(index, WIDGET_SMART_LISTS.size),
selected = isSelected, selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null, trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { selectedSmart = smart; selectedLists = emptySet() }, 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()) { if (lists.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.lists_header)) GroupedSectionHeader(stringResource(R.string.lists_header))
lists.forEachIndexed { index, list -> lists.forEachIndexed { index, list ->
@@ -269,6 +293,7 @@ private fun WidgetFilterPicker(
trailing = if (isSelected) ({ SelectedCheck() }) else null, trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onClick = {
selectedSmart = null selectedSmart = null
selectedFilter = null
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
}, },
) )
@@ -100,6 +100,8 @@ internal fun TaskFilter.widgetStorageValue(): String = when (this) {
is TaskFilter.OfList -> "list:$listId" is TaskFilter.OfList -> "list:$listId"
is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}" is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}"
is TaskFilter.Smart -> "smart:${list.name}" 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. */ /** Anything unreadable, or a smart list the widget doesn't offer, falls back to Today. */
@@ -111,6 +113,8 @@ internal fun parseWidgetFilter(raw: String?): TaskFilter {
value.startsWith("lists:") -> value.startsWith("lists:") ->
value.removePrefix("lists:").split(",").mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } } value.removePrefix("lists:").split(",").mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }
.toSet().takeIf { it.isNotEmpty() }?.let { TaskFilter.OfLists(it) } .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:") -> value.startsWith("smart:") ->
WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) } WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) }
else -> null else -> null
@@ -161,12 +165,14 @@ sealed interface TaskWidgetData {
val dynamicColor: Boolean, val dynamicColor: Boolean,
val themeMode: ThemeMode, val themeMode: ThemeMode,
val use24Hour: Boolean, val use24Hour: Boolean,
/** The saved filter's name when [filter] is one; `null` once it's deleted. */
val filterName: String? = null,
) : TaskWidgetData { ) : TaskWidgetData {
/** The one list's id, when [filter] names exactly one real list — seeds a widget's add button. */ /** The one list's id, when [filter] names exactly one real list — seeds a widget's add button. */
val singleListId: Long? = when (filter) { val singleListId: Long? = when (filter) {
is TaskFilter.OfList -> filter.listId is TaskFilter.OfList -> filter.listId
is TaskFilter.OfLists -> filter.listIds.singleOrNull() is TaskFilter.OfLists -> filter.listIds.singleOrNull()
is TaskFilter.Smart -> null is TaskFilter.Smart, is TaskFilter.Tagged, is TaskFilter.Saved -> null
} }
/** [singleListId]'s name. */ /** [singleListId]'s name. */
@@ -271,10 +277,11 @@ internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): F
val ep = widgetEntryPoint() val ep = widgetEntryPoint()
val repository = ep.tasksRepository() val repository = ep.tasksRepository()
return combine( return combine(
repository.tasks(filter), repository.filteredTasks(filter),
repository.taskLists(), repository.taskLists(),
ep.settingsPrefs().settings, ep.settingsPrefs().settings,
) { tasks, lists, settings -> ) { filtered, lists, settings ->
val tasks = filtered.tasks
val listNames = lists.associate { it.id to it.name } val listNames = lists.associate { it.id to it.name }
val data: TaskWidgetData = TaskWidgetData.Ready( val data: TaskWidgetData = TaskWidgetData.Ready(
filter = filter, filter = filter,
@@ -291,6 +298,7 @@ internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): F
dynamicColor = settings.dynamicColor, dynamicColor = settings.dynamicColor,
themeMode = settings.themeMode, themeMode = settings.themeMode,
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)), use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
filterName = filtered.savedFilter?.name,
) )
data data
}.catch { emit(TaskWidgetData.Unavailable) } }.catch { emit(TaskWidgetData.Unavailable) }
+73
View File
@@ -33,4 +33,77 @@
<string name="priority_medium">متوسط</string> <string name="priority_medium">متوسط</string>
<string name="priority_high">مرتفع</string> <string name="priority_high">مرتفع</string>
<string name="detail_list">القائمة</string> <string name="detail_list">القائمة</string>
<string name="add_account_step_of">الخطوة %1$d من %2$d</string>
<string name="onboarding_welcome_title">كل شئ كنت تقصد لفعله</string>
<string name="onboarding_welcome_body">مكان هادئ لمهامك، على هاتف يظل ملكًا لك. لنقم بإعداده — سيستغرق الأمر دقيقة.</string>
<string name="onboarding_welcome_lists_title">القوائم والمهام الفرعية</string>
<string name="onboarding_welcome_lists_body">ابقي الفصل بين العمل والمنزل والمشروع، وقسّم المهام الكبيرة.</string>
<string name="onboarding_welcome_reminders_title">التذكيرات التي تصل</string>
<string name="onboarding_welcome_reminders_body">إشعار عندما يكون شيء ما مستحق، وفقًا لجدولك الزمني.</string>
<string name="onboarding_welcome_sync_title">خادمك، أو لا شيء على الإطلاق</string>
<string name="add_account_browser_confirm_body">توجد صفحة تسجيل الدخول على خادمك، ليس في Agendula — لذا من القيّم إلقاء نظرة عليها قبل كتابة كلمة مرور حسابك هناك.</string>
<string name="onboarding_welcome_button">ابدأ</string>
<string name="reminder_onboarding_title">ابقى على اطلاع بمهامك</string>
<string name="shortcut_today">اليوم</string>
<string name="qs_tile_new_task">مهمة جديدة</string>
<string name="lists_reorder">إعادة ترتيب القوائم</string>
<string name="lists_reorder_done">انتهت إعادة الترتيب</string>
<string name="settings_title">الإعدادات</string>
<string name="settings_section_appearance">المظهر</string>
<string name="settings_about_source">المصدر</string>
<string name="settings_license">الترخيص</string>
<string name="settings_theme">السمة</string>
<string name="settings_theme_light">فاتح</string>
<string name="settings_theme_dark">داكن</string>
<string name="settings_section_reminders">التذكيرات</string>
<string name="settings_list_notifications">الإشعارات</string>
<string name="settings_group_data">البيانات</string>
<string name="settings_group_app">التطبيق</string>
<string name="settings_group_about">حول</string>
<string name="settings_time_format_auto">تلقائي</string>
<string name="settings_week_start_auto">تلقائي</string>
<string name="settings_section_storage">التخزين</string>
<string name="settings_section_accounts">الحسابات</string>
<string name="settings_list_reminder_inherits">الافتراضي (%1$s)</string>
<string name="settings_section_task_form">نموذج مهمة</string>
<string name="settings_about_support">دَعم التطوير</string>
<string name="settings_about_version">الإصدار %1$s</string>
<string name="settings_language">لغة التطبيق</string>
<string name="settings_language_auto">افتراضي النظام</string>
<string name="settings_translate">المساعدة في الترجمة</string>
<string name="settings_theme_system">يتبع النظام</string>
<string name="settings_dynamic_color">اللون الديناميكي</string>
<string name="settings_reminders">التذكيرات المستحقة</string>
<string name="settings_exact_alarms">التوقيت الدقيق</string>
<string name="settings_default_list">القائمة الافتراضية</string>
<string name="settings_time_format">تنسيق الوقت</string>
<string name="settings_time_format_24h">٢٤-ساعة (١٤:٠٠)</string>
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
<string name="settings_privacy">سياسة الخصوصية</string>
<string name="settings_default_reminder_allday">مهام يوم كامل</string>
<string name="settings_reliable_delivery">توصيل موثوق</string>
<string name="settings_snooze_duration">مدة التأجيل</string>
<string name="settings_task_store">مخزن المهام</string>
<string name="settings_store_permission_denied">رُفِض الإذن</string>
<string name="settings_copy_running">جارٍ نسخ المهام…</string>
<string name="settings_export">تصدير المهام</string>
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
<string name="settings_about_logo_desc">رمز تطبيق Agendula</string>
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
<string name="settings_default_reminder">متى لتذكير</string>
<string name="settings_group_look">المظهر و السلوك</string>
<string name="settings_bottom_add_bar">شريط الإضافة السريع السفلي</string>
<string name="settings_time_format_12h">١٢-ساعة (٢:٠٠ م)</string>
<string name="settings_week_start">الأسبوع يبدأ في</string>
<string name="settings_licences">تراخيص مفتوحة المصدر</string>
<string name="settings_notifications_blocked">الإشعارات متوقفة</string>
<string name="settings_store_own">على هذا الجهاز</string>
<string name="settings_allday_reminder_fires_at">يتم الإشعار في %1$s</string>
<string name="settings_store_external">تطبيق مهام آخر</string>
<string name="settings_store_switch_title">تبديل مخزن المهام؟</string>
<string name="settings_copy_confirm_title">نسخ المهام؟</string>
<string name="settings_legacy_import_retrying">جارٍ نقلهم الآن…</string>
<string name="settings_report_problem_hint">افتح متعقب المشكلات</string>
<string name="settings_copy_from_external">نسخ المهام من %1$s</string>
<string name="task_completed">مكتمل</string>
</resources> </resources>
+24 -1
View File
@@ -107,7 +107,7 @@
<string name="reminder_onboarding_body">Agendula benachrichtigt dich, wenn etwas fällig wird, also muss nichts in deinem Kopf leben.</string> <string name="reminder_onboarding_body">Agendula benachrichtigt dich, wenn etwas fällig wird, also muss nichts in deinem Kopf leben.</string>
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string> <string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
<string name="reminder_onboarding_skip_button">Nicht jetzt</string> <string name="reminder_onboarding_skip_button">Nicht jetzt</string>
<string name="reminder_benefit_delivery_title">Agendula erinnert dich</string> <string name="reminder_benefit_delivery_title">Ein kleiner Schubs, wenn es darauf ankommt</string>
<string name="reminder_benefit_delivery_body">Jede Aufgaben mit Fälligkeitsdatum bekommt seine eigene Benachrichtigung.</string> <string name="reminder_benefit_delivery_body">Jede Aufgaben mit Fälligkeitsdatum bekommt seine eigene Benachrichtigung.</string>
<string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string> <string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string>
<string name="reminder_benefit_timing_body">Wähle, wie weit im Voraus du erinnert werden möchtest — für alles oder pro Liste.</string> <string name="reminder_benefit_timing_body">Wähle, wie weit im Voraus du erinnert werden möchtest — für alles oder pro Liste.</string>
@@ -409,4 +409,27 @@
<string name="sync_notice_body">%1$s</string> <string name="sync_notice_body">%1$s</string>
<string name="sync_notices_title">Änderungen durch letzte Synchronisierung</string> <string name="sync_notices_title">Änderungen durch letzte Synchronisierung</string>
<string name="sync_notices_dismiss">Verstanden</string> <string name="sync_notices_dismiss">Verstanden</string>
<string name="recurrence_title">Wiederholung</string>
<plurals name="recurrence_with_count">
<item quantity="one">%1$s, %2$d Mal</item>
<item quantity="other">%1$s, %2$d Mal</item>
</plurals>
<string name="onboarding_exact_alarms_title">Genau rechtzeitig</string>
<string name="onboarding_exact_alarms_body">Android frägt dich, bevor dich eine App minutengenau benachrichtigen darf. Erlaube es und die Erinnerungen kommen, wenn sie fällig sind — ohne die Erlaubnis, könnten sich die Erinnerungen ein paar Minuten verspäten.</string>
<string name="onboarding_exact_alarms_allow">Erlaube genaues Timing</string>
<string name="settings_time_format_12h">12-Stunden (2:00 PM)</string>
<string name="settings_time_format_24h">24-Stunden (14:00)</string>
<string name="settings_time_format_auto_summary">Folgend dem System: %1$s</string>
<string name="settings_notifications_blocked_hint">Erinnerungen können dich nicht erreichen. Drücke, um Benachrichtigungen von Agendula zu erlauben.</string>
<string name="list_delete_confirm_message_synced">\"%1$s\" und alle Aufgaben davon werden von dem Server und jedem anderen Gerät, das damit verbunden ist, gelöscht. Das kann nicht rückgängig gemacht werden.</string>
<string name="onboarding_sync_devices_title">Jedes Gerät auf einmal</string>
<string name="onboarding_quick_add_bar">Schnell-Hinzufügen-Leiste</string>
<string name="onboarding_quick_add_button_hint">Ein schwebender Button, der das ganze Formular öffnet.</string>
<string name="settings_default_reminder_allday">Ganztägige Aufgaben</string>
<string name="onboarding_use_own_store">Stattdessen lokalen Speicher dieses Geräts nutzen</string>
<string name="settings_section_storage">Speicher</string>
<string name="settings_store_own">Auf diesem Gerät</string>
<string name="settings_store_own_hint">Agendulas eigener Speicher. Nichts weiteres zu installieren nötig.</string>
<string name="settings_store_external">Eine andere Aufgaben-App</string>
<string name="settings_store_permission_denied">Erlaubnis verweigert</string>
</resources> </resources>
+31
View File
@@ -39,6 +39,9 @@
<string name="lists_reorder_done">Done reordering</string> <string name="lists_reorder_done">Done reordering</string>
<string name="lists_reorder_handle">Drag to reorder</string> <string name="lists_reorder_handle">Drag to reorder</string>
<string name="sort_title">Sort by</string> <string name="sort_title">Sort by</string>
<string name="view_options_title">Sort and filter</string>
<string name="view_filter_tags">Filter by tag</string>
<string name="view_filter_clear">Clear filter</string>
<string name="sort_due">Due date</string> <string name="sort_due">Due date</string>
<string name="sort_priority">Priority</string> <string name="sort_priority">Priority</string>
<string name="sort_title_alpha">Title</string> <string name="sort_title_alpha">Title</string>
@@ -185,6 +188,9 @@
<string name="edit_location_label">Location</string> <string name="edit_location_label">Location</string>
<string name="edit_url_label">Link</string> <string name="edit_url_label">Link</string>
<string name="edit_url_hint">https://…</string> <string name="edit_url_hint">https://…</string>
<string name="edit_tags_label">Tags</string>
<string name="edit_tags_hint">Add a tag</string>
<string name="edit_tag_remove">Remove tag %1$s</string>
<string name="edit_discard_title">Discard changes?</string> <string name="edit_discard_title">Discard changes?</string>
<string name="edit_discard_body">Your changes to this task haven’t been saved.</string> <string name="edit_discard_body">Your changes to this task haven’t been saved.</string>
<string name="edit_discard_confirm">Discard</string> <string name="edit_discard_confirm">Discard</string>
@@ -212,6 +218,31 @@
<!-- Lists overview --> <!-- Lists overview -->
<string name="lists_header">Lists</string> <string name="lists_header">Lists</string>
<string name="tags_header">Tags</string>
<string name="filters_header">Filters</string>
<string name="filters_empty_hint">Combine due dates, lists, tags and priority into a view of your own</string>
<string name="filter_new_title">New filter</string>
<string name="filter_edit_title">Edit filter</string>
<string name="filter_name_hint">Filter name</string>
<string name="filter_section_due">Due</string>
<string name="filter_section_priority">Priority</string>
<string name="filter_section_lists">Lists</string>
<string name="filter_section_tags">Tags</string>
<string name="filter_any_due">Any due date</string>
<string name="filter_any_list">Any list</string>
<string name="filter_any_tag">Any tag, or none</string>
<string name="filter_priority_any">Any priority</string>
<string name="filter_priority_low">Low and higher</string>
<string name="filter_priority_medium">Medium and higher</string>
<string name="filter_priority_high">High only</string>
<string name="filter_match_all_tags">Match all tags</string>
<string name="filter_match_all_on">A task needs every tag you picked</string>
<string name="filter_match_all_off">A task needs one of the tags you picked</string>
<string name="filter_delete">Delete filter</string>
<string name="filter_delete_title">Delete “%1$s”?</string>
<string name="filter_delete_body">Only the filter goes. Its tasks stay where they are.</string>
<string name="filter_delete_confirm">Delete</string>
<string name="filter_missing">Filter deleted</string>
<string name="new_task">New task</string> <string name="new_task">New task</string>
<string name="lists_failure">Could not read your tasks.</string> <string name="lists_failure">Could not read your tasks.</string>
<string name="lists_empty">No task lists yet.</string> <string name="lists_empty">No task lists yet.</string>
@@ -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)
}
}
@@ -148,6 +148,17 @@ class TaskWriteMapperTest {
assertThat(undone[Tasks.COMPLETED]).isNull() 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 @Test
fun `alarm carries every column the provider's validator demands`() { fun `alarm carries every column the provider's validator demands`() {
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30) val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
@@ -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 @Nested
inner class Validation { inner class Validation {
@@ -39,6 +39,8 @@ class VTodoRoundTripTest {
"mixed-value-types", "mixed-value-types",
"malformed-scalars", "malformed-scalars",
"two-timezones", "two-timezones",
"categories-shapes",
"categories-split",
) )
@TestFactory @TestFactory
@@ -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))
}
}
@@ -50,4 +50,12 @@ class ConvertersTest {
} }
assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE) 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()
}
} }
@@ -29,6 +29,7 @@ private fun form(
start: Instant? = null, start: Instant? = null,
due: Instant? = null, due: Instant? = null,
isAllDay: Boolean = false, isAllDay: Boolean = false,
tags: List<String> = emptyList(),
) = TaskForm( ) = TaskForm(
title = title, title = title,
listId = 1, listId = 1,
@@ -36,6 +37,7 @@ private fun form(
start = start, start = start,
due = due, due = due,
isAllDay = isAllDay, isAllDay = isAllDay,
tags = tags,
) )
class TaskFormWriterTest { class TaskFormWriterTest {
@@ -51,6 +53,12 @@ class TaskFormWriterTest {
assertThat(entity.isDirty).isTrue() 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 @Test
fun `progress and status move together in both directions`() { fun `progress and status move together in both directions`() {
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status) assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status)
@@ -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()
}
}
@@ -58,4 +58,32 @@ class TaskFilteringTest {
assertThat(TaskFiltering.matches(task, TaskFilter.OfList(7), todayStart, todayEnd)).isTrue() assertThat(TaskFiltering.matches(task, TaskFilter.OfList(7), todayStart, todayEnd)).isTrue()
assertThat(TaskFiltering.matches(task, TaskFilter.OfList(8), todayStart, todayEnd)).isFalse() 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()
}
} }
@@ -44,4 +44,16 @@ class TaskFormTest {
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields()) assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields())
.containsExactly(TaskFormField.Progress, TaskFormField.Parent) .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()
}
} }
@@ -13,6 +13,7 @@ fun testTask(
isAllDay: Boolean = false, isAllDay: Boolean = false,
completedAt: Instant? = null, completedAt: Instant? = null,
parentId: Long? = null, parentId: Long? = null,
tags: List<String> = emptyList(),
): Task = Task( ): Task = Task(
taskId = id, taskId = id,
listId = listId, listId = listId,
@@ -38,4 +39,5 @@ fun testTask(
distanceFromCurrent = 0, distanceFromCurrent = 0,
created = null, created = null,
lastModified = null, lastModified = null,
tags = tags,
) )
@@ -35,9 +35,10 @@ class ICalendarWriterTest {
rrule: String? = null, rrule: String? = null,
rdate: String? = null, rdate: String? = null,
parentId: Long? = null, parentId: Long? = null,
tags: List<String> = emptyList(),
) = ExportTask( ) = ExportTask(
taskId, uid, title, description, location, url, priority, status, percentComplete, 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 = 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") 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 @Test
fun `uses CRLF line endings`() { fun `uses CRLF line endings`() {
// RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected // RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.ui.navigation package de.jeanlucmakiola.agendula.ui.navigation
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.TaskFilter
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class DestinationsTest { class DestinationsTest {
@@ -17,4 +18,13 @@ class DestinationsTest {
assertThat(Dest.Settings.build()).isEqualTo(Dest.SETTINGS) assertThat(Dest.Settings.build()).isEqualTo(Dest.SETTINGS)
assertThat(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)).isEqualTo("settings?section=Accounts") 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")
}
} }
@@ -32,7 +32,8 @@ class WidgetDataTest {
fun `every offered filter round-trips through storage`() { fun `every offered filter round-trips through storage`() {
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } + val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } +
TaskFilter.OfList(42) + TaskFilter.OfList(42) +
TaskFilter.OfLists(setOf(1L, 2L, 3L)) TaskFilter.OfLists(setOf(1L, 2L, 3L)) +
TaskFilter.Saved(7)
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) } filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
} }
@@ -45,7 +46,7 @@ class WidgetDataTest {
fun `garbage and smart lists the widget does not offer fall back to Today`() { fun `garbage and smart lists the widget does not offer fall back to Today`() {
listOf( listOf(
"", "list:", "list:-3", "list:abc", "lists:", "lists:abc", "lists:-1,-2", "", "list:", "list:-3", "list:abc", "lists:", "lists:abc", "lists:-1,-2",
"smart:COMPLETED", "smart:nope", "whatever", "smart:COMPLETED", "smart:nope", "filter:", "filter:0", "filter:x", "whatever",
).forEach { ).forEach {
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER) assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
} }
+2
View File
@@ -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 | | `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 | | `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 | | `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 |
@@ -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
@@ -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
@@ -0,0 +1,11 @@
### جديد
- الوسوم: أضفها إلى المهام، وتصفحها من الشاشة الرئيسية، وصفِّ أي قائمة بها. تُزامَن كـ CATEGORIES.
- عوامل التصفية: احفظ عرضك الخاص للمهام المفتوحة حسب الاستحقاق والقائمة والوسم والأولوية، وفي الأداة أيضًا.
- أدوات جديدة: Today وUp next وLists وWeek. يمكن للأداة عرض عدة قوائم.
### تغييرات
- صفوف بنمط الأجندة في أداة المهام.
- يمكن الآن إعادة تسمية القوائم المتزامنة وحذفها.
### إصلاحات
- لم تعد مهام اليوم الكامل تنزاح يومًا غرب UTC.
@@ -1,12 +1,11 @@
### Neu ### Neu
- Neue Widgets: Today, Up next, Lists und Week. - Tags: an Aufgaben vergeben, auf dem Startbildschirm durchsuchen, jede Liste danach filtern.
- Ein Widget kann mehrere Listen zeigen, auf Wunsch nach Liste gruppiert. - Filter: eigene Ansicht offener Aufgaben nach Fälligkeit, Liste, Tag und Priorität, auch im Widget.
- Neue Aufgaben können heute beginnen (Einstellungen → Aufgaben-Formular). - Neue Widgets: Today, Up next, Lists und Week. Ein Widget kann mehrere Listen zeigen.
### Geändert ### Geändert
- Das Aufgaben-Widget zeigt Zeilen im Agenda-Stil, optional farbig. - Agenda-Zeilen im Aufgaben-Widget.
- „At a glance“ entfällt; die Widgets Aufgaben und Today decken es ab. - Synchronisierte Listen lassen sich umbenennen und löschen.
- Synchronisierte Listen lassen sich jetzt auch umbenennen und löschen.
### Behoben ### Behoben
- Doppelter Abstand im Listen-Editor. - Ganztägige Aufgaben landen westlich von UTC nicht mehr einen Tag daneben.
@@ -1,12 +1,11 @@
### Added ### Added
- New home-screen widgets: Today, Up next, Lists and Week. - Tags: add them to tasks, browse them from the home screen and filter any list by them. They sync as CATEGORIES.
- A widget can show tasks from several lists, optionally grouped by list. - Filters: save your own view of open tasks by due date, list, tag and priority, and show it in a widget.
- Settings → Task form can pre-fill a new task's start with today. - New widgets: Today, Up next, Lists and Week. A widget can show several lists.
### Changed ### Changed
- The Tasks widget has agenda-style rows and an optional row colour. - Agenda-style rows in the Tasks widget.
- "At a glance" is gone; the Tasks and Today widgets cover it. - Synced lists can be renamed and deleted.
- Synced lists can now be renamed and deleted too.
### Fixed ### Fixed
- Double padding in the list editor. - All-day tasks no longer land a day off west of UTC.
@@ -1,12 +1,11 @@
### Added ### Added
- New home-screen widgets: Today, Up next, Lists and Week. - Tags: add them to tasks, browse them from the home screen and filter any list by them. They sync as CATEGORIES.
- A widget can show tasks from several lists, optionally grouped by list. - Filters: save your own view of open tasks by due date, list, tag and priority, and show it in a widget.
- Settings → Task form can pre-fill a new task's start with today. - New widgets: Today, Up next, Lists and Week. A widget can show several lists.
### Changed ### Changed
- The Tasks widget has agenda-style rows and an optional row colour. - Agenda-style rows in the Tasks widget.
- "At a glance" is gone; the Tasks and Today widgets cover it. - Synced lists can be renamed and deleted.
- Synced lists can now be renamed and deleted too.
### Fixed ### Fixed
- Double padding in the list editor. - All-day tasks no longer land a day off west of UTC.
@@ -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.
@@ -1,12 +1,11 @@
### Novo ### Novo
- Novos widgets: Today, Up next, Lists e Week. - Tags: adicione às tarefas, navegue pela tela inicial e filtre qualquer lista por elas.
- Um widget pode mostrar várias listas, agrupadas por lista se quiser. - Filtros: salve sua visão das tarefas abertas por data, lista, tag e prioridade, também em widget.
- Novas tarefas podem começar hoje (Ajustes → Formulário de tarefa). - Novos widgets: Today, Up next, Lists e Week. Um widget pode mostrar várias listas.
### Alterado ### Alterado
- O widget Tasks mostra linhas em estilo agenda, com cor opcional. - Linhas em estilo agenda no widget Tasks.
- "At a glance" saiu; os widgets Tasks e Today cobrem o que ele mostrava. - Listas sincronizadas podem ser renomeadas e excluídas.
- Listas sincronizadas agora também podem ser renomeadas e excluídas.
### Corrigido ### Corrigido
- Espaçamento duplo no editor de listas. - Tarefas de dia inteiro não caem mais um dia errado a oeste do UTC.
+2 -2
View File
@@ -1,6 +1,6 @@
[versions] [versions]
agp = "9.2.1" agp = "9.4.1"
kotlin = "2.3.21" kotlin = "2.4.20"
ksp = "2.3.11" ksp = "2.3.11"
hilt = "2.60.1" hilt = "2.60.1"
coreKtx = "1.19.0" coreKtx = "1.19.0"
Binary file not shown.
+4 -2
View File
@@ -1,8 +1,10 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored
+4 -7
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
############################################################################## ##############################################################################
# #
# Gradle start up script for POSIX generated by Gradle. # gradlew start up script for POSIX generated by Gradle.
# #
# Important for running: # Important for running:
# #
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole # bash, then to run this script, type that shell name before the whole
# command line, like: # command line, like:
# #
# ksh Gradle # ksh gradlew
# #
# Busybox and similar reduced shells will NOT work, because this script # Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features: # requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@" "$@"
Vendored
+52 -34
View File
@@ -19,12 +19,39 @@
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem gradlew startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables, and ensure extensions are enabled
if "%OS%"=="Windows_NT" setlocal setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2 1>&2 echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo. 1>&2 1>&2 echo.
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 1>&2 echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation. 1>&2 1>&2 echo location of your Java installation.
goto fail "%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
@@ -59,36 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. 1>&2 1>&2 echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo. 1>&2 1>&2 echo.
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 1>&2 echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation. 1>&2 1>&2 echo location of your Java installation.
goto fail "%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=
@rem Execute Gradle @rem Execute gradlew
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* @rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
:end @rem This label must not be changed. We rely on old scripts being able to jump to this point.
@rem End local scope for the variables with windows NT shell :exitWithErrorLevel
if %ERRORLEVEL% equ 0 goto mainEnd @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+2 -2
View File
@@ -115,8 +115,8 @@ def seed(db: sqlite3.Connection) -> None:
# data0 minutes before, data1 reference (1 = DUE), data3 alarm type. # data0 minutes before, data1 reference (1 = DUE), data3 alarm type.
(1, 1, ALARM_MIMETYPE, "30", "1", None, "1"), (1, 1, ALARM_MIMETYPE, "30", "1", None, "1"),
(2, 9, ALARM_MIMETYPE, "1440", "1", "Ship it", "1"), (2, 9, ALARM_MIMETYPE, "1440", "1", "Ship it", "1"),
# A non-alarm property the import must skip. # A category: data0 the provider's category id, data1 the name. A tag, not an alarm.
(3, 1, "vnd.android.cursor.item/category", "Errands", None, None, None), (3, 1, "vnd.android.cursor.item/category", "1", "Errands", None, None),
], ],
) )