From aa2aade93056203f368b7f45ff5c03db96d872cf Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 9 Oct 2026 12:20:21 +0200 Subject: [PATCH] feat: tags (#35) (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tags, stored as the standard `CATEGORIES` property so they stay compatible with Nextcloud Tasks, tasks.org and DAVx⁵. ## What changed - **Storage and sync:** tags are a new `categories` column (database v2). The migration lifts `CATEGORIES` out of the stored unknown properties for tasks that were synced before, so existing tags show up without a re-download. A line Agendula can't write back exactly as it came (e.g. a duplicated value, or the property split across several lines) stays where it was and still syncs byte for byte. - **Provider mode (OpenTasks / tasks.org):** tags are read and written as the provider's category properties. Writes are a single batch, so a refused tag can't leave half the tags behind or create a task twice. - **Imports:** the copy from OpenTasks/tasks.org and the legacy one-shot import both bring tags across. - **Editor:** a new optional Tags field. Type a tag and press Done or type a comma; tags already in use are suggested while typing, and text left in the field is still saved. - **Display:** tags show as pills on the detail screen and in each row's info line in task lists. - **Using tags:** the home screen has a Tags section listing every tag on an open task with its count. Tapping one opens that tag's tasks across all lists, and "New task" there starts with the tag set. Every task list's sort button is now a "Sort and filter" picker, where you can tick one or more tags to narrow the list. ## Also in here - **Keyboard:** a focused input now scrolls fully above the keyboard, not just its text line. This uses floret-kit's new `revealOnFocus` (jlmakiola/floret-kit#12), and the pointer moves to floret-kit main. - **Edit screen:** once the title scrolls out of view, it shows in the top bar. - **main merged in:** the branch brings main's #40 and #44 (floret-kit on its shared main, Gradle 9.8), which `release/v1.1.0` doesn't have yet. ## Where this differs from the issue - **Filtering by several tags** shows tasks that carry *all* of them, since ticking more tags is meant to narrow the list. - **Tags are case-sensitive:** "Work" and "work" are two tags, matching how they are stored and synced. - **The overview** lists tags on open tasks only, the same set the editor suggests. - **Widgets** can't be set to a tag yet. Closes #35 Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/46 --- .../2.json | 529 ++++++++++++++++++ app/src/androidTest/assets/tasks-v23.db | Bin 20480 -> 20480 bytes .../data/tasks/legacy/OneShotImportTest.kt | 9 + .../tasks/room/TasksDatabaseMigrationTest.kt | 55 +- .../data/tasks/transfer/ExternalImportTest.kt | 13 + .../agendula/data/di/DataModule.kt | 1 + .../data/tasks/AndroidTasksDataSource.kt | 113 +++- .../agendula/data/tasks/Failures.kt | 4 +- .../agendula/data/tasks/TaskWriteMapper.kt | 6 + .../agendula/data/tasks/TasksContract.kt | 12 + .../data/tasks/TasksRepositoryImpl.kt | 2 + .../agendula/data/tasks/ical/VTodoMapper.kt | 30 +- .../data/tasks/legacy/OneShotImport.kt | 22 +- .../data/tasks/room/CategoriesLift.kt | 44 ++ .../agendula/data/tasks/room/Converters.kt | 16 + .../agendula/data/tasks/room/Entities.kt | 5 + .../data/tasks/room/RoomTaskMapper.kt | 2 + .../data/tasks/room/TaskFormWriter.kt | 2 + .../agendula/data/tasks/room/TasksDatabase.kt | 35 +- .../data/tasks/transfer/ExternalImport.kt | 2 + .../jeanlucmakiola/agendula/domain/Models.kt | 9 + .../agendula/domain/TaskFilter.kt | 8 +- .../agendula/domain/TaskForm.kt | 4 +- .../agendula/domain/export/ExportModels.kt | 1 + .../agendula/domain/export/ICalendarWriter.kt | 1 + .../agendula/domain/ical/ICalValues.kt | 31 + .../agendula/ui/common/FormFieldVisuals.kt | 3 + .../agendula/ui/common/TagChip.kt | 57 ++ .../agendula/ui/detail/TaskDetailScreen.kt | 21 +- .../agendula/ui/edit/TaskEditScreen.kt | 131 ++++- .../agendula/ui/edit/TaskEditViewModel.kt | 44 +- .../agendula/ui/lists/ListsScreen.kt | 26 + .../agendula/ui/lists/ListsViewModel.kt | 15 +- .../agendula/ui/navigation/AgendulaNavHost.kt | 6 +- .../agendula/ui/navigation/Destinations.kt | 30 +- .../agendula/ui/tasklist/TaskListScreen.kt | 124 +++- .../agendula/ui/tasklist/TaskListViewModel.kt | 30 +- .../agendula/widget/TaskWidget.kt | 2 + .../widget/TaskWidgetConfigActivity.kt | 2 +- .../agendula/widget/WidgetData.kt | 3 +- app/src/main/res/values/strings.xml | 7 + .../data/tasks/TaskWriteMapperTest.kt | 11 + .../data/tasks/ical/VTodoMapperTest.kt | 59 ++ .../data/tasks/ical/VTodoRoundTripTest.kt | 2 + .../data/tasks/room/CategoriesLiftTest.kt | 35 ++ .../data/tasks/room/ConvertersTest.kt | 8 + .../data/tasks/room/TaskFormWriterTest.kt | 8 + .../agendula/domain/TaskFilteringTest.kt | 28 + .../agendula/domain/TaskFormTest.kt | 12 + .../agendula/domain/TestTasks.kt | 2 + .../domain/export/ICalendarWriterTest.kt | 10 +- app/src/test/resources/vtodo/README.md | 2 + .../resources/vtodo/categories-shapes.ics | 10 + .../test/resources/vtodo/categories-split.ics | 12 + floret-kit | 2 +- gradle/libs.versions.toml | 4 +- gradle/wrapper/gradle-wrapper.jar | Bin 43764 -> 47623 bytes gradle/wrapper/gradle-wrapper.properties | 6 +- gradlew | 11 +- gradlew.bat | 86 +-- scripts/make_import_fixture.py | 4 +- 61 files changed, 1645 insertions(+), 124 deletions(-) create mode 100644 app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/2.json create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLift.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TagChip.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLiftTest.kt create mode 100644 app/src/test/resources/vtodo/categories-shapes.ics create mode 100644 app/src/test/resources/vtodo/categories-split.ics diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/2.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/2.json new file mode 100644 index 0000000..0d38395 --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/2.json @@ -0,0 +1,529 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "14b159c51416ba27653f6320ac7f6f03", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principalUrl", + "columnName": "principal_url", + "affinity": "TEXT" + }, + { + "fieldPath": "homeSetUrl", + "columnName": "home_set_url", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncError", + "columnName": "last_sync_error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "isVisible", + "columnName": "is_visible", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "isSynced", + "columnName": "is_synced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "ctag", + "columnName": "ctag", + "affinity": "TEXT" + }, + { + "fieldPath": "syncToken", + "columnName": "sync_token", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_lists_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "list_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "percentComplete", + "columnName": "percent_complete", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAt", + "columnName": "completed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "classification", + "columnName": "classification", + "affinity": "INTEGER" + }, + { + "fieldPath": "dtstart", + "columnName": "dtstart", + "affinity": "INTEGER" + }, + { + "fieldPath": "due", + "columnName": "due", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT" + }, + { + "fieldPath": "isAllDay", + "columnName": "is_all_day", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT" + }, + { + "fieldPath": "rrule", + "columnName": "rrule", + "affinity": "TEXT" + }, + { + "fieldPath": "rdate", + "columnName": "rdate", + "affinity": "TEXT" + }, + { + "fieldPath": "exdate", + "columnName": "exdate", + "affinity": "TEXT" + }, + { + "fieldPath": "recurrenceId", + "columnName": "recurrence_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "masterId", + "columnName": "master_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "unknownProperties", + "columnName": "unknown_properties", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_list_id_is_deleted", + "unique": false, + "columnNames": [ + "list_id", + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)" + }, + { + "name": "index_tasks_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_tasks_master_id_recurrence_id", + "unique": false, + "columnNames": [ + "master_id", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)" + }, + { + "name": "index_tasks_is_dirty", + "unique": false, + "columnNames": [ + "is_dirty" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)" + }, + { + "name": "index_tasks_list_id_uid_recurrence_id", + "unique": true, + "columnNames": [ + "list_id", + "uid", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)" + } + ], + "foreignKeys": [ + { + "table": "task_lists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "list_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "master_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "parent_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "task_alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesBefore", + "columnName": "minutes_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reference", + "columnName": "reference", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DUE'" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_alarms_task_id", + "unique": false, + "columnNames": [ + "task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '14b159c51416ba27653f6320ac7f6f03')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/assets/tasks-v23.db b/app/src/androidTest/assets/tasks-v23.db index e305e90abadb448158ee637c3791555232f6f13d..d4e5b537ee788e81cc402589eec7e94e5d7e9b61 100644 GIT binary patch delta 52 zcmZozz}T>WaY7Q))H54X7TI&?Gw>haFXPwWEGVGJ&*{o6%fRW&z%M<~QEalmzXGG- IWaY7PPRpG{zMfMy94EzW9%lHj83koRlbGR_eGI07bNKbSWpM2k6f+IPx OBsD$1sB&_?|3m--6%o$> diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt index c040421..36d23a2 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt @@ -212,6 +212,15 @@ class OneShotImportTest { assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands") } + @Test + fun importsCategoryPropertiesAsTags() { + importer.importFrom(fixture()) + val tasks = taskRows() + + assertThat(tasks.getValue("Buy milk").categories).containsExactly("Errands") + assertThat(tasks.getValue("Ship the release").categories).isEmpty() + } + // --- running it ----------------------------------------------------------- @Test diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt index cf4b272..ca0d824 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt @@ -8,27 +8,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -/** - * The migration harness, proven against the committed schema in `app/schemas/`. - * - * There is one schema version today, so all there is to assert is that the helper - * can build v1 from the exported JSON, seed it, and validate it back — i.e. the - * export, the assets wiring and the identity hash all line up. That is the point: - * the first real migration only has to add its own case. - * - * **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s - * `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the - * database, and add a test here shaped like this: - * - * ``` - * helper.createDatabase(TEST_DB, 1).use { db -> - * db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)") - * } - * helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db -> - * // read the seeded rows back — validation proves the shape, not the data - * } - * ``` - */ +/** Migrations, proven against the committed schemas in `app/schemas/`. */ @RunWith(AndroidJUnit4::class) class TasksDatabaseMigrationTest { @@ -62,6 +42,39 @@ class TasksDatabaseMigrationTest { } } + @Test + fun liftsCategoriesOutOfTheResidueWithoutDirtyingTheTask() { + helper.createDatabase(TEST_DB, 1).use { db -> + db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Work', 0)") + db.execSQL( + "INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)", + arrayOf(1, "uid-1", "Claimed", "X-FOO:bar\r\nCATEGORIES:Work,Home\r\n"), + ) + db.execSQL( + "INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)", + arrayOf(2, "uid-2", "Split", "CATEGORIES:a\r\nCATEGORIES:b\r\n"), + ) + db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (3, 1, 'uid-3', 'Untagged')") + } + + helper.runMigrationsAndValidate(TEST_DB, 2, true, TasksDatabase.MIGRATION_1_2).use { db -> + db.query("SELECT categories, unknown_properties, is_dirty FROM tasks ORDER BY id").use { c -> + c.moveToNext() + assertThat(c.getString(0)).isEqualTo("Work,Home") + assertThat(c.getString(1)).doesNotContain("CATEGORIES") + assertThat(c.getString(1)).contains("X-FOO:bar") + assertThat(c.getInt(2)).isEqualTo(0) + + c.moveToNext() + assertThat(c.getString(0)).isEqualTo("a,b") + assertThat(c.getString(1)).isEqualTo("CATEGORIES:a\r\nCATEGORIES:b\r\n") + + c.moveToNext() + assertThat(c.getString(0)).isEmpty() + } + } + } + private companion object { const val TEST_DB = "migration-test.db" } diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt index 874fc6f..8bc1eb3 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt @@ -107,6 +107,17 @@ class ExternalImportTest { assertThat(importer.hasRun.first()).isTrue() } + @Test + fun copiesTagsCleanedUp() = runBlocking { + source.lists = listOf(list(7, "Errands")) + source.tasks = mapOf(7L to listOf(task(100, "Milk", tags = listOf("Shop", " Shop ", "", "Home")))) + + importer.run() + + assertThat(db.tasks().tasks(listId = null, includeCompleted = true).single().task.categories) + .containsExactly("Shop", "Home").inOrder() + } + /** Every list arrives device-only: the account belongs to the sync app. */ @Test fun importedListsAreDeviceOnly() = runBlocking { @@ -241,6 +252,7 @@ class ExternalImportTest { title: String, uid: String? = "uid-$id", parentId: Long? = null, + tags: List = emptyList(), ) = ExportTask( taskId = id, uid = uid, @@ -260,6 +272,7 @@ class ExternalImportTest { rrule = null, rdate = null, parentId = parentId, + tags = tags, ) private companion object { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt index e36b813..65a744b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt @@ -89,6 +89,7 @@ object DataProvideModule { // backed-up `.db` does not. The backup rules carry all three files and // the app checkpoints on ON_STOP. .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) + .addMigrations(TasksDatabase.MIGRATION_1_2) .build() /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt index c317238..b87069c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import android.content.ContentProviderOperation import android.content.ContentResolver import android.content.ContentUris import android.content.ContentValues @@ -19,6 +20,7 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.export.ExportTask +import de.jeanlucmakiola.agendula.domain.normalizeTags import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec @@ -90,11 +92,14 @@ class AndroidTasksDataSource @Inject constructor( // `is_recurring` / `distance_from_current`). The by-name mapper reads // whatever is present and nulls the rest; the repository re-sorts, so we // don't depend on a provider sort column either. - return resolver.query(uri, null, selection, args, null) + val tasks = resolver.query(uri, null, selection, args, null) ?.use { c -> val reader = CursorColumnReader(c) buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) } } ?: emptyList() + if (tasks.isEmpty()) return tasks + val tags = categories(tasks.map { it.taskId }) + return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task } } override fun exportTasks(listId: Long): List { @@ -102,7 +107,7 @@ class AndroidTasksDataSource @Inject constructor( // table's shape varies across provider versions, and the by-name mapper // reads what's there. val uri = TasksContract.tasksUri(authority()) - return resolver.query( + val tasks = resolver.query( uri, null, // _deleted marks a row awaiting a sync round-trip. It's gone as far as @@ -115,21 +120,39 @@ class AndroidTasksDataSource @Inject constructor( val reader = CursorColumnReader(c) buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) } } ?: emptyList() + if (tasks.isEmpty()) return tasks + val tags = categories(tasks.map { it.taskId }) + return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task } } // --- writes --------------------------------------------------------------- override fun insertTask(form: TaskForm): Long { val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) - val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues()) - ?: throw TaskWriteFailedException("insert task") - return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id") + // One batch, so a refused tag leaves no task behind for a retry to duplicate. + val authority = authority() + val ops = arrayListOf( + ContentProviderOperation.newInsert(TasksContract.tasksUri(authority)) + .withValues(values.toContentValues()) + .build(), + ) + normalizeTags(form.tags).forEach { tag -> + ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority)) + .withValues(TaskWriteMapper.categoryValues(0, tag).toContentValues().apply { remove(Properties.TASK_ID) }) + .withValueBackReference(Properties.TASK_ID, 0) + .build() + } + val results = runCatching { resolver.applyBatch(authority, ops) } + .getOrElse { throw TaskWriteFailedException("insert task", it) } + return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull() + ?: throw TaskWriteFailedException("insert task: no id") } override fun updateTask(taskId: Long, form: TaskForm) { val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("update task $taskId") + setCategories(taskId, form.tags) } override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) { @@ -151,6 +174,7 @@ class AndroidTasksDataSource @Inject constructor( (Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due)) val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("update series $seriesId") + setCategories(seriesId, form.tags) } /** @@ -243,8 +267,28 @@ class AndroidTasksDataSource @Inject constructor( ?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence") val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id) val uri = TasksContract.instanceUri(authority(), instanceId) - val rows = resolver.update(uri, values.toContentValues(), null, null) - if (rows == 0) throw TaskWriteFailedException("update instance $instanceId") + // The fork copies the series' properties onto the override, so the tags + // go onto the series first and are put back once the override exists. + // Unchanged tags are inherited as they are, and the series is left alone. + val seriesTags = categories(listOf(taskId))[taskId].orEmpty() + val retag = normalizeTags(form.tags) != seriesTags + if (retag) setCategories(taskId, form.tags) + var failure: Throwable? = null + try { + val rows = resolver.update(uri, values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("update instance $instanceId") + } catch (e: Throwable) { + failure = e + throw e + } finally { + if (retag) { + try { + setCategories(taskId, seriesTags) + } catch (e: Throwable) { + failure?.addSuppressed(e) ?: throw e + } + } + } } /** @@ -348,6 +392,58 @@ class AndroidTasksDataSource @Inject constructor( } ?: emptyList() } + /** Tags by task id for [taskIds], in stored order. */ + private fun categories(taskIds: Collection): Map> { + val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk -> + val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})" + val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray() + resolver.query( + TasksContract.propertiesUri(authority()), + arrayOf(Properties.TASK_ID, TasksContract.Category.NAME), + selection, + args, + Properties.PROPERTY_ID, + )?.use { c -> + val reader = CursorColumnReader(c) + buildList { + while (c.moveToNext()) { + val id = reader.getLong(Properties.TASK_ID) ?: continue + val name = reader.getString(TasksContract.Category.NAME) ?: continue + add(id to name) + } + } + } ?: emptyList() + } + return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) } + } + + /** + * Replaces [taskId]'s category rows in one batch, so a refused insert cannot + * leave half the tags, and leaves them alone when nothing changed so the task + * isn't dirtied. + */ + private fun setCategories(taskId: Long, tags: List) { + val wanted = normalizeTags(tags) + if (categories(listOf(taskId))[taskId].orEmpty() == wanted) return + val authority = authority() + val uri = TasksContract.propertiesUri(authority) + val ops = arrayListOf( + ContentProviderOperation.newDelete(uri) + .withSelection( + "${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?", + arrayOf(taskId.toString(), TasksContract.Category.MIMETYPE), + ) + .build(), + ) + wanted.forEach { + ops += ContentProviderOperation.newInsert(uri) + .withValues(TaskWriteMapper.categoryValues(taskId, it).toContentValues()) + .build() + } + runCatching { resolver.applyBatch(authority, ops) } + .getOrElse { throw TaskWriteFailedException("set categories for task $taskId", it) } + } + override fun setCancelled(taskId: Long, cancelled: Boolean) { val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("cancel task $taskId") @@ -460,3 +556,6 @@ class AndroidTasksDataSource @Inject constructor( return cv } } + +/** SQLite's default bound-argument limit on older Android releases. */ +private const val MAX_SQL_ARGS = 999 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/Failures.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/Failures.kt index d34dae4..608bdc7 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/Failures.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/Failures.kt @@ -5,8 +5,8 @@ class ProviderUnavailableException : IllegalStateException("No tasks provider installed") /** A ContentResolver write returned no URI or affected no rows. */ -class TaskWriteFailedException(operation: String) : - RuntimeException("Task write failed: $operation") +class TaskWriteFailedException(operation: String, cause: Throwable? = null) : + RuntimeException("Task write failed: $operation", cause) /** * The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt index e608c59..7413e12 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt @@ -113,6 +113,12 @@ object TaskWriteMapper { Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE, ) + fun categoryValues(taskId: Long, name: String): Map = mapOf( + Properties.TASK_ID to taskId, + Properties.MIMETYPE to TasksContract.Category.MIMETYPE, + TasksContract.Category.NAME to name, + ) + /** The user-owned columns of a list — what an edit is allowed to change. */ fun listValues(name: String, color: Int): Map = mapOf( Lists.NAME to name.trim(), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt index d3712b1..75f68ee 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt @@ -154,6 +154,18 @@ object TasksContract { const val TYPE_MESSAGE = 1 } + /** + * A category property row — one tag. DAVx5 maps these to and from + * `CATEGORIES`. Inserting with only [NAME] is enough: the provider looks the + * category up in the task's account, or creates it, and fills in `data0`. + */ + object Category { + const val MIMETYPE = "vnd.android.cursor.item/category" + + /** `data1` — the tag as spelled. */ + const val NAME = "data1" + } + fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri() // --- status values (TaskColumns.STATUS_*) -------------------------------- diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index 9019e3f..516ea70 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -66,6 +66,7 @@ class TasksRepositoryImpl @Inject constructor( // Same over-read as Smart: TaskQuery.listId only narrows to one list, so // several lists are read in full and membership is filtered in-memory. is TaskFilter.OfLists -> TaskQuery(includeCompleted = true) + is TaskFilter.Tagged -> TaskQuery(includeCompleted = true) } val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val all = SeriesCollapse.visible(dataSource.tasks(query)) @@ -112,6 +113,7 @@ class TasksRepositoryImpl @Inject constructor( parentId = parentId, location = task.location, url = task.url, + tags = task.tags, ), ) val reminders = dataSource.reminders()[task.taskId].orEmpty() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt index 7461de0..084d84b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt @@ -9,6 +9,7 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser import de.jeanlucmakiola.agendula.domain.ical.ICalProperty import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import de.jeanlucmakiola.agendula.domain.normalizeTags import java.time.ZoneId import kotlin.time.Clock import kotlin.time.Instant @@ -81,6 +82,8 @@ object VTodoMapper { // Parameterised copies stay in the residue verbatim; authoring the // column beside them would emit the same exclusions twice. "RDATE", "EXDATE", + // Several lines, or one the column would not re-serialise exactly. + "CATEGORIES", ) /** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */ @@ -200,6 +203,9 @@ object VTodoMapper { CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 }, ) + val (categories, categoriesProperty) = readCategories(vtodo) + claim(categoriesProperty) + val sequenceProperty = vtodo.property("SEQUENCE") val sequence = take( sequenceProperty, @@ -225,6 +231,7 @@ object VTodoMapper { title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()), description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()), location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()), + categories = categories, // URI, not TEXT — escaping it would corrupt a query string. url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()), status = status, @@ -284,8 +291,9 @@ object VTodoMapper { now: Instant = Clock.System.now(), ): ICalComponent { val residue = parseResidue(entity.unknownProperties) + val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues)) val keptResidue = - residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) } + residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) } val suppressed = keptResidue .map { it.name.uppercase() } .filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE } @@ -307,6 +315,7 @@ object VTodoMapper { add("DESCRIPTION", entity.description?.let(ICalValues::escapeText)) add("LOCATION", entity.location?.let(ICalValues::escapeText)) add("URL", entity.url) + add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList)) add("STATUS", entity.status.toICalName()) add("PERCENT-COMPLETE", entity.percentComplete?.toString()) @@ -386,6 +395,7 @@ object VTodoMapper { private fun contradictsResidue( property: ICalProperty, residue: ICalComponent, + residueCategories: List, entity: TaskEntity, parentUid: String?, ): Boolean { @@ -433,6 +443,7 @@ object VTodoMapper { "PRIORITY" -> entity.priority != PRIORITY_NONE "PERCENT-COMPLETE" -> entity.percentComplete != null "CLASS" -> entity.classification != null + "CATEGORIES" -> entity.categories != residueCategories // Not SEQUENCE: it is the organiser's counter and never ours to bump, // so the column stays at its fallback and the residue always wins. else -> false @@ -475,6 +486,23 @@ object VTodoMapper { // -------------------------------------------------------------- helpers + /** + * Every `CATEGORIES` copy's values, merged, and the one line to claim — only + * when it is the sole, parameterless copy and the column re-serialises it + * byte for byte. Anything else is read for display and stays in the residue. + */ + private fun readCategories(vtodo: ICalComponent): Pair, ICalProperty?> { + val copies = vtodo.properties("CATEGORIES") + val values = normalizeTags(copies.flatMap(::categoryValues)) + val claimable = copies.singleOrNull()?.takeIf { + it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value + } + return values to claimable + } + + private fun categoryValues(property: ICalProperty): List = + normalizeTags(ICalValues.parseTextList(property.value)) + private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL") private class TimeRead( diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt index 9cb0429..d21b9ee 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt @@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE +import de.jeanlucmakiola.agendula.domain.normalizeTags import de.jeanlucmakiola.agendula.domain.statusFromInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -221,12 +222,17 @@ class OneShotImport @Inject constructor( } val alarms = mutableListOf() - db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null) + val categories = mutableMapOf>() + db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties ORDER BY property_id", null) .use { cursor -> val r = CursorColumnReader(cursor) while (cursor.moveToNext()) { - if (r.getString("mimetype") != ALARM_MIMETYPE) continue val taskId = r.getLong("task_id") ?: continue + if (r.getString("mimetype") == CATEGORY_MIMETYPE) { + r.getString("data1")?.let { categories.getOrPut(taskId, ::mutableListOf) += it } + continue + } + if (r.getString("mimetype") != ALARM_MIMETYPE) continue val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue alarms += LegacyAlarm( taskId = taskId, @@ -241,7 +247,7 @@ class OneShotImport @Inject constructor( } } - return LegacySnapshot(lists, rows, alarms) + return LegacySnapshot(lists, rows, alarms, categories.mapValues { normalizeTags(it.value) }) } // --- writing into Room ---------------------------------------------------- @@ -276,7 +282,12 @@ class OneShotImport @Inject constructor( // the unique index; a fresh uid costs the row nothing it still has. val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) } ?: UUID.randomUUID().toString() - val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId) + val entity = row.entity.copy( + listId = listId, + uid = uid, + recurrenceId = recurrenceId, + categories = snapshot.categories[row.id].orEmpty(), + ) val newId = taskDao.insert(entity) taskIds[row.id] = newId inserted += row to entity.copy(id = newId) @@ -379,6 +390,7 @@ class OneShotImport @Inject constructor( const val ARCHIVE_NAME = "tasks.db.imported" private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm" + private const val CATEGORY_MIMETYPE = "vnd.android.cursor.item/category" private const val REFERENCE_START = "2" private val SIDECARS = listOf("-journal", "-wal", "-shm") private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done") @@ -391,6 +403,8 @@ private class LegacySnapshot( val lists: List, val tasks: List, val alarms: List, + /** Tags by legacy task `_id`. */ + val categories: Map>, ) private class LegacyList(val id: Long, val entity: TaskListEntity) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLift.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLift.kt new file mode 100644 index 0000000..a656cbf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLift.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import de.jeanlucmakiola.agendula.domain.ical.ICalComponent +import de.jeanlucmakiola.agendula.domain.ical.ICalParser +import de.jeanlucmakiola.agendula.domain.ical.ICalProperty +import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer +import de.jeanlucmakiola.agendula.domain.ical.ICalValues + +/** + * Lifts `CATEGORIES` out of a residue written before v2 claimed it. + * + * ⚠️ Frozen with [TasksDatabase.MIGRATION_1_2]. The claim rule is copied here + * rather than shared with the mapper, so a later change to what the mapper + * claims cannot change what this migration produced for an earlier upgrader. + */ +internal object CategoriesLift { + + /** + * Every copy's values merged, and the residue with the one claimed line + * removed — or `null` for the residue when nothing was claimed, so the caller + * leaves the stored one alone. + */ + data class Result(val categories: List, val residue: String?, val claimed: Boolean) + + fun lift(residue: String?): Result { + val component = parse(residue) ?: return Result(emptyList(), residue, claimed = false) + val copies = component.properties.filter { it.name.equals("CATEGORIES", ignoreCase = true) } + val values = copies.flatMap(::values) + val claim = copies.singleOrNull()?.takeIf { + it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value + } ?: return Result(values, residue, claimed = false) + val rest = ICalSerializer.serializeProperties(component.properties.filterNot { it === claim }) + + ICalSerializer.serializeAll(component.components) + return Result(values, rest.ifEmpty { null }, claimed = true) + } + + private fun values(property: ICalProperty): List = + ICalValues.parseTextList(property.value).map(String::trim).filter(String::isNotEmpty).distinct() + + private fun parse(text: String?): ICalComponent? { + if (text.isNullOrEmpty()) return null + return runCatching { ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n") }.getOrNull() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt index f4eae17..caf307d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt @@ -2,6 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room import androidx.room.TypeConverter import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import de.jeanlucmakiola.agendula.domain.normalizeTags import de.jeanlucmakiola.agendula.domain.statusFromInt import de.jeanlucmakiola.agendula.domain.toInt import kotlin.time.Instant @@ -36,3 +38,17 @@ object Converters { fun alarmReferenceFrom(value: String): AlarmReference = runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE) } + +/** + * [TaskEntity.categories] stored as a `CATEGORIES` value, so escaping has one + * home; empty when there are none. Scoped to that field rather than the + * database, so no other `List` picks up iCalendar escaping by accident. + */ +object CategoriesConverter { + @TypeConverter + fun toText(value: List): String = ICalValues.formatTextList(normalizeTags(value)) + + @TypeConverter + fun fromText(value: String): List = + if (value.isEmpty()) emptyList() else normalizeTags(ICalValues.parseTextList(value)) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt index ed2c206..513efc1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt @@ -5,6 +5,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import androidx.room.TypeConverters import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE import de.jeanlucmakiola.agendula.domain.TaskStatus import kotlin.time.Instant @@ -143,6 +144,10 @@ data class TaskEntity( @ColumnInfo(name = "url") val url: String? = null, /** ARGB override for the list colour. */ @ColumnInfo(name = "color") val color: Int? = null, + /** RFC 5545 `CATEGORIES` — the tags — in the spelling each task carries them. */ + @ColumnInfo(name = "categories", defaultValue = "") + @field:TypeConverters(CategoriesConverter::class) + val categories: List = emptyList(), // state @ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt index dffe701..5ef7233 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt @@ -49,6 +49,7 @@ object RoomTaskMapper { description = row.task.description, location = row.task.location, url = row.task.url, + tags = row.task.categories, priority = priorityFromICal(row.task.priority), status = row.task.status, percentComplete = row.task.percentComplete, @@ -79,6 +80,7 @@ object RoomTaskMapper { description = task.description, location = task.location, url = task.url, + tags = task.categories, priority = priorityFromICal(task.priority), status = task.status, percentComplete = task.percentComplete, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt index d0729f9..18d4392 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.tasks.room import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import de.jeanlucmakiola.agendula.domain.normalizeTags import de.jeanlucmakiola.agendula.domain.toICal import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.seconds @@ -50,6 +51,7 @@ object TaskFormWriter { description = form.description?.trim()?.ifBlank { null }, location = form.location?.trim()?.ifBlank { null }, url = form.url?.trim()?.ifBlank { null }, + categories = normalizeTags(form.tags), priority = form.priority.toICal(), percentComplete = percent, status = statusFor(percent, current.status), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt index 7d6e597..a203d11 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt @@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase /** * Agendula's own task store. Four tables, designed from @@ -18,7 +20,7 @@ import androidx.room.TypeConverters TaskEntity::class, TaskAlarmEntity::class, ], - version = 1, + version = 2, exportSchema = true, ) @TypeConverters(Converters::class) @@ -30,5 +32,36 @@ abstract class TasksDatabase : RoomDatabase() { companion object { const val NAME = "agendula-tasks.db" + + /** + * v2 adds `categories`. Until then `CATEGORIES` lived in the residue, and + * an unchanged task is never downloaded again, so the values are lifted + * out here. Not marked dirty: nothing changed on the server's side. + */ + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE tasks ADD COLUMN categories TEXT NOT NULL DEFAULT ''") + val lifted = mutableListOf>() + db.query( + "SELECT id, unknown_properties FROM tasks WHERE unknown_properties LIKE '%CATEGORIES%'", + ).use { cursor -> + while (cursor.moveToNext()) { + val result = CategoriesLift.lift(cursor.getString(1)) + if (result.categories.isNotEmpty()) lifted += cursor.getLong(0) to result + } + } + lifted.forEach { (id, result) -> + val categories = CategoriesConverter.toText(result.categories) + if (result.claimed) { + db.execSQL( + "UPDATE tasks SET categories = ?, unknown_properties = ? WHERE id = ?", + arrayOf(categories, result.residue, id), + ) + } else { + db.execSQL("UPDATE tasks SET categories = ? WHERE id = ?", arrayOf(categories, id)) + } + } + } + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt index 8803571..1585bb0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt @@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.export.ExportTask +import de.jeanlucmakiola.agendula.domain.normalizeTags import de.jeanlucmakiola.agendula.domain.toICal import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -213,6 +214,7 @@ class ExternalImport @Inject constructor( description = description, location = location, url = url, + categories = normalizeTags(tags), status = status, percentComplete = percentComplete, completedAt = completedAt, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt index 05b70db..35de203 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt @@ -52,6 +52,8 @@ data class Task( val description: String?, val location: String?, val url: String?, + /** `CATEGORIES`, as the task spells them. */ + val tags: List = emptyList(), val priority: Priority, val status: TaskStatus, val percentComplete: Int?, @@ -161,3 +163,10 @@ fun TaskStatus.toInt(): Int = when (this) { TaskStatus.COMPLETED -> ICalStatus.COMPLETED TaskStatus.CANCELLED -> ICalStatus.CANCELLED } + +/** Tags as every writer stores them: trimmed, no blanks, each spelling once, order kept. */ +fun normalizeTags(tags: List): List = tags.map(String::trim).filter(String::isNotEmpty).distinct() + +/** Every tag on [tasks], each spelling once, alphabetically ignoring case. */ +fun tagsInUse(tasks: Iterable): List = + tasks.flatMap { it.tags }.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt index 077c67d..9ce9d61 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt @@ -5,12 +5,14 @@ import kotlin.time.Instant /** The built-in "smart" lists, computed from due dates rather than membership. */ enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED } -/** What a task screen is showing: one real list, or a smart list. */ +/** What a task screen is showing: one real list, a smart list, or a tag. */ sealed interface TaskFilter { data class OfList(val listId: Long) : TaskFilter /** Several lists shown together, e.g. one widget covering more than one list. */ data class OfLists(val listIds: Set) : TaskFilter data class Smart(val list: SmartList) : TaskFilter + /** Every task carrying [tag], open and completed, across all lists. */ + data class Tagged(val tag: String) : TaskFilter } /** @@ -25,8 +27,12 @@ object TaskFiltering { is TaskFilter.OfList -> task.listId == filter.listId is TaskFilter.OfLists -> task.listId in filter.listIds is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd) + is TaskFilter.Tagged -> filter.tag in task.tags } + /** Whether [task] carries every one of [tags]; an empty set matches everything. */ + fun hasAllTags(task: Task, tags: Set): Boolean = task.tags.containsAll(tags) + private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean { val due = task.due return when (list) { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt index c0c1c69..9adec18 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt @@ -22,6 +22,7 @@ data class TaskForm( val reminders: List = emptyList(), val location: String? = null, val url: String? = null, + val tags: List = emptyList(), /** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */ val rrule: String? = null, ) { @@ -44,7 +45,7 @@ enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITH * a setting; the rest unfold on demand. Declaring these as an enum keeps the * disclosure generic, so adding a field later is one entry plus its card. */ -enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url } +enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url, Tags } /** The optional fields that already carry a value — auto-revealed when editing. */ fun TaskForm.populatedFields(): Set = buildSet { @@ -56,4 +57,5 @@ fun TaskForm.populatedFields(): Set = buildSet { if (rrule != null) add(TaskFormField.Recurrence) if (!location.isNullOrBlank()) add(TaskFormField.Location) if (!url.isNullOrBlank()) add(TaskFormField.Url) + if (tags.isNotEmpty()) add(TaskFormField.Tags) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt index 153a34c..8bc65a7 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt @@ -42,6 +42,7 @@ data class ExportTask( val rdate: String?, /** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */ val parentId: Long?, + val tags: List = emptyList(), ) /** A task list and everything in it, ready to become one `.ics` document. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt index e2700a4..bdd16e2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt @@ -88,6 +88,7 @@ object ICalendarWriter { task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) } // URL is a URI, not TEXT: it must not be escaped like one. task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) } + task.tags.takeIf { it.isNotEmpty() }?.let { rawProperty("CATEGORIES", ICalValues.formatTextList(it)) } task.start?.let { dateProperty("DTSTART", it, task.isAllDay) } task.due?.let { dateProperty("DUE", it, task.isAllDay) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt index 037eb5e..32422f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt @@ -49,6 +49,37 @@ object ICalValues { return out.toString() } + /** + * A multi-valued TEXT property (`CATEGORIES`, §3.8.1.2): split on the + * commas that are not escaped, then unescape each value. + */ + fun parseTextList(value: String): List { + val values = mutableListOf() + val current = StringBuilder() + var i = 0 + while (i < value.length) { + val c = value[i] + when { + c == '\\' && i + 1 < value.length -> { + current.append(c).append(value[i + 1]) + i += 2 + continue + } + c == ',' -> { + values += unescapeText(current.toString()) + current.clear() + } + else -> current.append(c) + } + i++ + } + values += unescapeText(current.toString()) + return values + } + + /** The inverse of [parseTextList]. */ + fun formatTextList(values: List): String = values.joinToString(",", transform = ::escapeText) + /** How a DATE / DATE-TIME property was written, and whether we can reproduce it. */ sealed interface TimeValue { /** `VALUE=DATE` — date-only. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/FormFieldVisuals.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/FormFieldVisuals.kt index b376caf..5fb8649 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/FormFieldVisuals.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/FormFieldVisuals.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Percent import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.Sell import androidx.compose.ui.graphics.vector.ImageVector import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.domain.TaskFormField @@ -23,6 +24,7 @@ fun TaskFormField.labelRes(): Int = when (this) { TaskFormField.Reminder -> R.string.edit_reminder_label TaskFormField.Location -> R.string.edit_location_label TaskFormField.Url -> R.string.edit_url_label + TaskFormField.Tags -> R.string.edit_tags_label } fun TaskFormField.icon(): ImageVector = when (this) { @@ -34,4 +36,5 @@ fun TaskFormField.icon(): ImageVector = when (this) { TaskFormField.Reminder -> Icons.Rounded.Notifications TaskFormField.Location -> Icons.Rounded.LocationOn TaskFormField.Url -> Icons.Rounded.Link + TaskFormField.Tags -> Icons.Rounded.Sell } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TagChip.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TagChip.kt new file mode 100644 index 0000000..accde3a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TagChip.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * A pill naming one tag. Solid secondary by default so it reads against any card; + * [muted] is the quieter tonal look for something not yet applied, like a suggestion. + */ +@Composable +fun TagChip( + name: String, + modifier: Modifier = Modifier, + muted: Boolean = false, + icon: ImageVector? = null, + iconContentDescription: String? = null, + onClick: (() -> Unit)? = null, +) { + val container = if (muted) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.secondary + val content = if (muted) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSecondary + val shape = RoundedCornerShape(50) + val body: @Composable () -> Unit = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(start = 12.dp, end = if (icon != null) 8.dp else 12.dp, top = 6.dp, bottom = 6.dp), + ) { + Text( + text = name, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (icon != null) { + Icon(icon, contentDescription = iconContentDescription, modifier = Modifier.size(16.dp)) + } + } + } + if (onClick != null) { + Surface(onClick = onClick, color = container, contentColor = content, shape = shape, modifier = modifier) { body() } + } else { + Surface(color = container, contentColor = content, shape = shape, modifier = modifier) { body() } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt index 0bf1661..8a191a4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -42,6 +44,7 @@ import android.net.Uri import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Sell import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Repeat @@ -60,6 +63,7 @@ import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog +import de.jeanlucmakiola.agendula.ui.common.TagChip import de.jeanlucmakiola.agendula.ui.common.linkified import de.jeanlucmakiola.agendula.ui.common.recurrenceText import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel @@ -97,6 +101,7 @@ import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.domain.readableDescription import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail +import de.jeanlucmakiola.floret.components.revealOnFocus import de.jeanlucmakiola.floret.components.GroupedSurface import de.jeanlucmakiola.agendula.ui.common.PriorityChip import de.jeanlucmakiola.floret.time.formatDate @@ -240,6 +245,7 @@ fun TaskDetailScreen( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun DetailBody( detail: TaskDetail, @@ -453,6 +459,19 @@ private fun DetailBody( } } + if (task.tags.isNotEmpty()) { + add { + DetailCard(icon = Icons.Rounded.Sell, iconContentDescription = stringResource(R.string.edit_tags_label)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + task.tags.forEach { TagChip(it) } + } + } + } + } + // Description — selectable, with its links tappable. task.description?.readableDescription()?.takeIf { it.isNotBlank() }?.let { description -> add { @@ -606,7 +625,7 @@ private fun AddSubtaskField(onAdd: (String) -> Unit) { } val accentOn = MaterialTheme.colorScheme.onPrimaryContainer Row( - modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 8.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).revealOnFocus().padding(start = 8.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt index f8feec8..62d6856 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt @@ -1,9 +1,25 @@ package de.jeanlucmakiola.agendula.ui.edit +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ScrollState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.text.style.TextOverflow +import de.jeanlucmakiola.agendula.ui.common.TagChip import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import androidx.activity.compose.BackHandler import androidx.compose.material.icons.rounded.Link +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material.icons.rounded.Sell +import androidx.compose.ui.text.input.ImeAction import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.Repeat import androidx.compose.ui.text.AnnotatedString @@ -15,6 +31,7 @@ import de.jeanlucmakiola.agendula.ui.common.icon import de.jeanlucmakiola.agendula.ui.common.labelRes import de.jeanlucmakiola.agendula.ui.common.recurrenceText import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.floret.components.revealOnFocus import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedSectionHeader @@ -153,11 +170,25 @@ fun TaskEditScreen( val requestBack = { if (state.isDirty && !state.saved) showDiscard = true else onBack() } BackHandler(enabled = state.isDirty && !state.saved) { showDiscard = true } + // Once the title field scrolls away it rides in the bar, so a long form never + // loses track of which task is being edited. + val scroll = rememberScrollState() + var titleBottom by remember { mutableIntStateOf(Int.MAX_VALUE) } + val titleInBar by remember { derivedStateOf { scroll.value > titleBottom } } + Scaffold( modifier = modifier, topBar = { TopAppBar( - title = {}, + title = { + AnimatedVisibility( + visible = titleInBar && state.title.isNotBlank(), + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + slideOutVertically { it / 2 }, + ) { + Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + }, navigationIcon = { IconButton(onClick = requestBack) { Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close)) @@ -180,6 +211,8 @@ fun TaskEditScreen( EditContent( state = state, viewModel = viewModel, + scroll = scroll, + onTitleBottom = { titleBottom = it }, modifier = Modifier.padding(inner), ) } @@ -220,6 +253,8 @@ private enum class PickerTarget { Start, Due } private fun EditContent( state: TaskEditUiState, viewModel: TaskEditViewModel, + scroll: ScrollState, + onTitleBottom: (Int) -> Unit, modifier: Modifier = Modifier, ) { val dark = isSystemInDarkTheme() @@ -250,7 +285,7 @@ private fun EditContent( // Shrink the scroll viewport by the keyboard so the focused field // scrolls into view above the IME instead of being hidden. .imePadding() - .verticalScroll(rememberScrollState()) + .verticalScroll(scroll) .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp), ) { // Title: borderless headline + accent bar, mirroring the detail screen. @@ -259,7 +294,9 @@ private fun EditContent( onValueChange = viewModel::onTitleChange, placeholder = stringResource(R.string.edit_title_hint), textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.focusRequester(titleFocus), + modifier = Modifier + .focusRequester(titleFocus) + .onPlaced { onTitleBottom(it.positionInParent().y.toInt() + it.size.height) }, ) Spacer(Modifier.height(10.dp)) Box( @@ -572,6 +609,19 @@ private fun EditContent( } } + OptionalFormSection(visible = TaskFormField.Tags in state.visibleFields) { + Spacer(Modifier.height(gap)) + TagsCard( + tags = state.tags, + draft = state.tagDraft, + suggestions = state.tagSuggestions, + onDraftChange = viewModel::onTagDraftChange, + onCommitDraft = viewModel::commitTagDraft, + onAdd = viewModel::addTag, + onRemove = viewModel::removeTag, + ) + } + OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) { Spacer(Modifier.height(20.dp)) TextButton( @@ -727,9 +777,9 @@ private fun EditCard( } } if (onClick != null) { - Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() } + Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth().revealOnFocus()) { inner() } } else { - Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() } + Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth().revealOnFocus()) { inner() } } } @@ -1040,6 +1090,77 @@ private fun AddReminderCard(position: Position, label: String, onClick: () -> Un } } +/** The input sits beside the icon; the tags and any suggestions run below it. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TagsCard( + tags: List, + draft: String, + suggestions: List, + onDraftChange: (String) -> Unit, + onCommitDraft: () -> Unit, + onAdd: (String) -> Unit, + onRemove: (String) -> Unit, +) { + val query = draft.trim() + val matches = if (query.isEmpty()) { + emptyList() + } else { + suggestions.filter { it.contains(query, ignoreCase = true) && it !in tags }.take(MAX_TAG_SUGGESTIONS) + } + EditCard( + icon = Icons.Rounded.Sell, + iconContentDescription = stringResource(R.string.edit_tags_label), + header = { + InlineTextField( + value = draft, + onValueChange = onDraftChange, + placeholder = stringResource(R.string.edit_tags_hint), + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done, + onImeAction = onCommitDraft, + ) + }, + ) { + if (matches.isNotEmpty()) { + TagFlow(modifier = Modifier.padding(top = 8.dp)) { + matches.forEach { match -> + TagChip( + name = match, + muted = true, + icon = Icons.Rounded.Add, + onClick = { onAdd(match) }, + ) + } + } + } + if (tags.isNotEmpty()) { + TagFlow(modifier = Modifier.padding(top = 8.dp)) { + tags.forEach { tag -> + TagChip( + name = tag, + icon = Icons.Rounded.Close, + iconContentDescription = stringResource(R.string.edit_tag_remove, tag), + onClick = { onRemove(tag) }, + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TagFlow(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { content() } +} + +private const val MAX_TAG_SUGGESTIONS = 8 + /** Picks one hidden optional section to add to the form. */ @Composable private fun FieldPickerDialog( diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt index 304f4ab..fd5add5 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt @@ -8,6 +8,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskForm @@ -17,6 +18,8 @@ import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.populatedFields import de.jeanlucmakiola.agendula.domain.htmlToPlainText import de.jeanlucmakiola.agendula.domain.looksLikeHtml +import de.jeanlucmakiola.agendula.domain.normalizeTags +import de.jeanlucmakiola.agendula.domain.tagsInUse import de.jeanlucmakiola.agendula.domain.rebasedForAllDay import de.jeanlucmakiola.agendula.domain.readableDescription import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope @@ -51,6 +54,11 @@ data class TaskEditUiState( val reminders: List = emptyList(), val location: String = "", val url: String = "", + val tags: List = emptyList(), + /** What is typed in the tag field and not added yet. Saving still takes it. */ + val tagDraft: String = "", + /** Every tag already in use, for completing what the user types. */ + val tagSuggestions: List = emptyList(), val rrule: String? = null, /** Editing one occurrence of a series: saving asks how far the edit reaches. */ val isOccurrence: Boolean = false, @@ -114,7 +122,12 @@ class TaskEditViewModel @Inject constructor( private var defaultFields: Set = emptySet() /** Start a fresh task, optionally pre-selecting a list / parent. */ - fun bindNew(presetListId: Long? = null, parentId: Long? = null, initialTitle: String? = null) { + fun bindNew( + presetListId: Long? = null, + parentId: Long? = null, + initialTitle: String? = null, + presetTag: String? = null, + ) { if (bound) return bound = true editingTaskId = null @@ -142,6 +155,7 @@ class TaskEditViewModel @Inject constructor( lists = lists, parentCandidates = loadParents(defaultList, selfId = null), title = initialTitle.orEmpty(), + tags = listOfNotNull(presetTag), ), ).also { baseline = it.toForm().copy(title = "") } } @@ -179,6 +193,7 @@ class TaskEditViewModel @Inject constructor( reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore }, location = task.location.orEmpty(), url = task.url.orEmpty(), + tags = task.tags, rrule = task.recurrenceRule, originalRule = task.recurrenceRule, isOccurrence = task.isOccurrence, @@ -202,6 +217,18 @@ class TaskEditViewModel @Inject constructor( return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId } } + private var suggestionsRequested = false + + /** Tags on open tasks, read once the user starts typing one rather than on every bind. */ + private fun loadTagSuggestions() { + if (suggestionsRequested) return + suggestionsRequested = true + viewModelScope.launch { + val tasks = runCatching { repository.tasks(TaskFilter.Smart(SmartList.ALL)).first() }.getOrElse { emptyList() } + _state.value = _state.value.copy(tagSuggestions = tagsInUse(tasks)) + } + } + /** Reveal an optional section (one tap from the "More fields" picker). */ fun revealField(field: TaskFormField) = update { val visible = it.visibleFields + field @@ -257,6 +284,18 @@ class TaskEditViewModel @Inject constructor( fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) } fun onLocationChange(value: String) = update { it.copy(location = value) } fun onUrlChange(value: String) = update { it.copy(url = value) } + fun addTag(name: String) = update { it.copy(tags = normalizeTags(it.tags + name), tagDraft = "") } + + /** A typed comma adds what came before it. */ + fun onTagDraftChange(value: String) { + loadTagSuggestions() + update { + if (',' in value) it.copy(tags = it.withDraft(value), tagDraft = "") else it.copy(tagDraft = value) + } + } + + fun commitTagDraft() = update { it.copy(tags = it.withDraft(it.tagDraft), tagDraft = "") } + fun removeTag(name: String) = update { it.copy(tags = it.tags - name) } fun onRecurrenceChange(rrule: String?) = update { it.copy(rrule = rrule, errors = emptySet()) } /** Dismiss the conflict prompt without saving (the user keeps editing). */ @@ -339,5 +378,8 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm( reminders = reminders, location = location.ifBlank { null }, url = url.ifBlank { null }, + tags = withDraft(tagDraft), rrule = rrule, ) + +private fun TaskEditUiState.withDraft(draft: String): List = normalizeTags(tags + draft.split(',')) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index 92088cb..56e6968 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.ui.lists import de.jeanlucmakiola.floret.components.ReorderableColumn import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material.icons.rounded.Sell import androidx.compose.material.icons.rounded.SwapVert import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility @@ -370,6 +371,31 @@ 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)) }, + ) + } + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index 1016ee0..918dac5 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.tagsInUse import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -73,6 +74,7 @@ internal fun RemoteLists.Outcome.asFailure(local: ListWriteFailure): ListWriteFa data class ListOverview(val list: TaskList, val openCount: Int) data class AccountGroup(val accountName: String, val lists: List) data class SmartCount(val smart: SmartList, val count: Int) +data class TagOverview(val tag: String, val openCount: Int) sealed interface ListsUiState { data object Loading : ListsUiState @@ -87,6 +89,8 @@ sealed interface ListsUiState { val upcoming: List, /** Every task (open and completed) — the corpus the home search filters. */ val allTasks: List, + /** Each tag on an open task, alphabetically. */ + val tags: List = emptyList(), ) : ListsUiState } @@ -177,13 +181,22 @@ class ListsViewModel @Inject constructor( ) } .sortedBy { it.accountName.lowercase() } + // Counts the open rows the tag's own screen shows: a subtask folds under a + // parent carrying the same tag, and stands on its own otherwise. + val allTasks = openTasks + completedTasks + val tags = tagsInUse(openTasks).map { tag -> + val tagged = allTasks.filter { tag in it.tags } + val ids = tagged.mapTo(HashSet()) { it.taskId } + TagOverview(tag, tagged.count { !it.isClosed && (!it.isSubtask || it.parentId !in ids) }) + } return ListsUiState.Content( smartCounts = smartCounts, groups = groups, todayDone = completedDueToday, todayTotal = todayTotal, upcoming = upcoming, - allTasks = openTasks + completedTasks, + allTasks = allTasks, + tags = tags, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt index a090218..44b54d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt @@ -114,6 +114,7 @@ fun AgendulaNavHost( val filter = Dest.TaskList.filterOf( smart = args?.getString(Dest.TaskList.ARG_SMART), listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID, + tag = args?.getString(Dest.TaskList.ARG_TAG), ) val vm: TaskListViewModel = hiltViewModel() LaunchedEffect(filter) { vm.bind(filter) } @@ -125,7 +126,8 @@ fun AgendulaNavHost( onOpenTask = onOpenTask, onNewTask = { val presetListId = (filter as? TaskFilter.OfList)?.listId - nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId)) + val tag = (filter as? TaskFilter.Tagged)?.tag + nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId, tag = tag)) }, onBack = { nav.popBackStack() }, ) @@ -166,6 +168,7 @@ fun AgendulaNavHost( val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID val occurrence = args?.getLong(Dest.TaskEdit.ARG_OCCURRENCE)?.takeIf { it != Dest.NO_ID } val title = args?.getString(Dest.TaskEdit.ARG_TITLE) + val tag = args?.getString(Dest.TaskEdit.ARG_TAG) val vm: TaskEditViewModel = hiltViewModel() LaunchedEffect(taskId, presetListId, parentId) { if (taskId != Dest.NO_ID) { @@ -175,6 +178,7 @@ fun AgendulaNavHost( presetListId = presetListId.takeIf { it != Dest.NO_ID }, parentId = parentId.takeIf { it != Dest.NO_ID }, initialTitle = title, + presetTag = tag, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt index 87127dd..be6ecf9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt @@ -53,30 +53,35 @@ object Dest { } /** - * One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or* - * `smart` (a [SmartList] name); whichever is set decides the filter. + * One [TaskFilter]'s tasks. Carries one of `listId` (a real list), `smart` + * (a [SmartList] name) or `tag`; whichever is set decides the filter. */ object TaskList { const val ARG_LIST_ID = "listId" const val ARG_SMART = "smart" - const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}" + const val ARG_TAG = "tag" + const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}&$ARG_TAG={$ARG_TAG}" val arguments = listOf( navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID }, navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null }, + navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null }, ) fun build(filter: TaskFilter): String = when (filter) { is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}" is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}" + is TaskFilter.Tagged -> "taskList?$ARG_TAG=${android.net.Uri.encode(filter.tag)}" // Several lists at once only exists for the widget, which never routes here. is TaskFilter.OfLists -> error("OfLists has no task-list screen") } /** Reconstruct the filter from a destination's arguments. */ - fun filterOf(smart: String?, listId: Long): TaskFilter = - if (smart != null) TaskFilter.Smart(SmartList.valueOf(smart)) - else TaskFilter.OfList(listId) + fun filterOf(smart: String?, listId: Long, tag: String? = null): TaskFilter = when { + smart != null -> TaskFilter.Smart(SmartList.valueOf(smart)) + tag != null -> TaskFilter.Tagged(tag) + else -> TaskFilter.OfList(listId) + } } /** A single task's detail; `occ` (epoch millis) picks one occurrence of a series. */ @@ -105,12 +110,14 @@ object Dest { const val ARG_PARENT_ID = "parentId" const val ARG_OCCURRENCE = "occ" const val ARG_TITLE = "title" + const val ARG_TAG = "tag" const val route = "taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" + "&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" + "&$ARG_PARENT_ID={$ARG_PARENT_ID}" + "&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" + - "&$ARG_TITLE={$ARG_TITLE}" + "&$ARG_TITLE={$ARG_TITLE}" + + "&$ARG_TAG={$ARG_TAG}" val arguments = listOf( navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID }, @@ -118,17 +125,24 @@ object Dest { navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID }, navArgument(ARG_OCCURRENCE) { type = NavType.LongType; defaultValue = NO_ID }, navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null }, + navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null }, ) fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String = if (occurrenceStart == null) "taskEdit?$ARG_TASK_ID=$taskId" else "taskEdit?$ARG_TASK_ID=$taskId&$ARG_OCCURRENCE=$occurrenceStart" - fun buildNew(presetListId: Long? = null, parentId: Long? = null, title: String? = null): String { + fun buildNew( + presetListId: Long? = null, + parentId: Long? = null, + title: String? = null, + tag: String? = null, + ): String { val params = buildList { if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId") if (parentId != null) add("$ARG_PARENT_ID=$parentId") if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}") + if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}") } return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}" } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index d15d6c6..9d2b30c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -48,6 +49,8 @@ import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.Flag import androidx.compose.material3.Checkbox +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon @@ -57,14 +60,19 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material.icons.automirrored.rounded.Sort import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.Sell +import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.AlertDialog import androidx.compose.material3.SwipeToDismissBox import androidx.compose.ui.res.pluralStringResource import de.jeanlucmakiola.agendula.domain.TaskSortOrder +import de.jeanlucmakiola.floret.components.revealOnFocus import de.jeanlucmakiola.floret.components.InlineTextField -import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.SelectedCheck import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -189,7 +197,9 @@ fun TaskListScreen( }, actions = { IconButton(onClick = { showSort = true }, enabled = content != null) { - Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title)) + BadgedBox(badge = { if (content?.selectedTags?.isNotEmpty() == true) Badge() }) { + Icon(Icons.Rounded.Tune, contentDescription = stringResource(R.string.view_options_title)) + } } if (list != null) { IconButton( @@ -277,12 +287,13 @@ fun TaskListScreen( } if (showSort && content != null) { - OptionPicker( - title = stringResource(R.string.sort_title), - options = TaskSortOrder.entries, - selected = content.sortOrder, - label = { stringResource(sortLabel(it)) }, - onSelect = { viewModel.setSortOrder(it); showSort = false }, + ViewOptionsPicker( + sortOrder = content.sortOrder, + tags = content.availableTags, + selectedTags = content.selectedTags, + onSortOrder = viewModel::setSortOrder, + onToggleTag = viewModel::toggleTagFilter, + onClearTags = viewModel::clearTagFilter, onDismiss = { showSort = false }, ) } @@ -324,7 +335,7 @@ fun TaskListScreen( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable private fun TaskListBody( state: TaskListUiState.Content, @@ -349,7 +360,7 @@ private fun TaskListBody( val listId = (filter as? TaskFilter.OfList)?.listId // In a mixed view (a smart list) the list colour bar + name disambiguate // rows; inside a single real list both are redundant. - val showListName = filter is TaskFilter.Smart + val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged // Completed tasks collapse away by default to keep the active list in focus. var completedExpanded by rememberSaveable { mutableStateOf(false) } // Parents the user expanded to reveal their subtasks inline. Children are @@ -379,8 +390,16 @@ private fun TaskListBody( // When the bottom quick-add bar is shown it already lives in the scaffold's // bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB. val bottomBarShown = state.bottomAddBar && listId != null + // While typing, the viewport ends at the keyboard (or at the quick-add bar + // riding on it), so a focused add-subtask row can scroll fully above it. + val imeVisible = WindowInsets.isImeVisible + val viewport = when { + !imeVisible -> Modifier + bottomBarShown -> Modifier.padding(bottom = inner.calculateBottomPadding()) + else -> Modifier.imePadding() + } LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().then(viewport), contentPadding = PaddingValues( top = inner.calculateTopPadding() + 8.dp, bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp, @@ -659,7 +678,7 @@ internal fun TaskRowContent( val listName = task.listName?.takeIf { showListName && it.isNotBlank() } // Everything secondary collapses into one quiet supporting line below the title. val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 || - listName != null || task.isOccurrence + listName != null || task.isOccurrence || task.tags.isNotEmpty() Surface( onClick = onClick, @@ -714,6 +733,7 @@ internal fun TaskRowContent( subtaskTotal = subtaskTotal, listName = listName, listColor = task.effectiveColor, + tags = task.tags, dark = dark, ) } @@ -745,6 +765,7 @@ private fun TaskMetaLine( subtaskTotal: Int, listName: String?, listColor: Int, + tags: List, dark: Boolean, ) { val muted = MaterialTheme.colorScheme.onSurfaceVariant @@ -800,6 +821,28 @@ private fun TaskMetaLine( } } } + if (tags.isNotEmpty()) { + add { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + Icon( + Icons.Rounded.Sell, + contentDescription = stringResource(R.string.edit_tags_label), + tint = muted, + modifier = Modifier.size(13.dp), + ) + Text( + tags.joinToString(", "), + style = style, + color = muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } if (listName != null) { add { Text( @@ -852,7 +895,7 @@ private fun AddSubtaskRow( shape = cardShape(position, 22.dp, 6.dp), // Same tone as the subtask rows it closes, so the group reads as one unit. color = MaterialTheme.colorScheme.surfaceContainer, - modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap).revealOnFocus(), ) { Row( modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp), @@ -1169,11 +1212,64 @@ private fun CenteredMessage(text: String, inner: PaddingValues) { } } +/** + * Sort and tag filter in one place. Stays open while choosing, since several + * tags can be ticked; back leaves. A task must carry every ticked tag. + */ +@Composable +private fun ViewOptionsPicker( + sortOrder: TaskSortOrder, + tags: List, + selectedTags: Set, + onSortOrder: (TaskSortOrder) -> Unit, + onToggleTag: (String) -> Unit, + onClearTags: () -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker( + title = stringResource(R.string.view_options_title), + onDismiss = onDismiss, + actions = { + if (selectedTags.isNotEmpty()) { + TextButton(onClick = onClearTags) { Text(stringResource(R.string.view_filter_clear)) } + } + }, + ) { + GroupedSectionHeader(stringResource(R.string.sort_title)) + TaskSortOrder.entries.forEachIndexed { index, order -> + val isSelected = order == sortOrder + GroupedRow( + title = stringResource(sortLabel(order)), + position = positionOf(index, TaskSortOrder.entries.size), + selected = isSelected, + trailing = if (isSelected) ({ SelectedCheck() }) else null, + onClick = { onSortOrder(order) }, + ) + } + if (tags.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + GroupedSectionHeader(stringResource(R.string.view_filter_tags)) + tags.forEachIndexed { index, tag -> + val isSelected = tag in selectedTags + GroupedRow( + title = tag, + position = positionOf(index, tags.size), + selected = isSelected, + leading = { Icon(Icons.Rounded.Sell, contentDescription = null) }, + trailing = if (isSelected) ({ SelectedCheck() }) else null, + onClick = { onToggleTag(tag) }, + ) + } + } + } +} + @Composable private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) { is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title) // Several lists at once only exists for the widget, which never routes here. is TaskFilter.OfLists -> error("OfLists has no task-list screen") + is TaskFilter.Tagged -> filter.tag is TaskFilter.Smart -> stringResource( when (filter.list) { SmartList.TODAY -> R.string.smart_today diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index f8873fd..477efb5 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -9,6 +9,8 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter +import de.jeanlucmakiola.agendula.domain.TaskFiltering +import de.jeanlucmakiola.agendula.domain.tagsInUse import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskSortOrder @@ -55,6 +57,10 @@ sealed interface TaskListUiState { val sortOrder: TaskSortOrder = TaskSortOrder.DUE, /** This real list is kept out of the smart lists. */ val hiddenFromSmartLists: Boolean = false, + /** Tags the unnarrowed view carries, offered as filter chips. */ + val availableTags: List = emptyList(), + /** The tags [tasks] is narrowed to; a task must carry all of them. */ + val selectedTags: Set = emptySet(), ) : TaskListUiState } @@ -83,6 +89,8 @@ class TaskListViewModel @Inject constructor( private val filter = MutableStateFlow(null) + private val tagFilter = MutableStateFlow>(emptySet()) + /** Occurrences swiped to delete but not yet committed — hidden until the undo lapses. */ private val pendingDeletes = MutableStateFlow>(emptyMap()) @@ -101,7 +109,7 @@ class TaskListViewModel @Inject constructor( combine(tasks, repository.taskLists()) { rows, lists -> TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId }) } - is TaskFilter.Smart -> + is TaskFilter.Smart, is TaskFilter.Tagged -> tasks.map { TaskListUiState.Content(it) } // Several lists at once only exists for the widget, which never routes here. is TaskFilter.OfLists -> error("OfLists has no task-list screen") @@ -109,12 +117,20 @@ class TaskListViewModel @Inject constructor( // Drop rows pending an undoable delete so the row vanishes on swipe // while the actual provider delete waits for the snackbar to commit, // and fold in the live UI settings (add-subtask row, bottom add bar). - combine(content, pendingDeletes, settingsPrefs.settings) { st, pending, settings -> + combine(content, pendingDeletes, settingsPrefs.settings, tagFilter) { st, pending, settings, tags -> if (st is TaskListUiState.Content) { + val visible = settings.visibleIn(f, st.tasks).filter { it.occurrenceKey !in pending } + val available = tagsInUse(visible) - setOfNotNull((f as? TaskFilter.Tagged)?.tag) + // A tag that vanished can no longer be unticked, so it is dropped + // for good rather than narrowing again when it comes back. + val selected = tags.filterTo(LinkedHashSet()) { it in available } + if (selected.size != tags.size) tagFilter.value = selected st.copy( - tasks = settings.visibleIn(f, st.tasks) - .filter { it.occurrenceKey !in pending } + tasks = visible + .filter { TaskFiltering.hasAllTags(it, selected) } .sortedWith(settings.sortOrder.comparator()), + availableTags = available, + selectedTags = selected, showAddSubtaskRow = settings.showAddSubtaskRow, bottomAddBar = settings.bottomAddBar, sortOrder = settings.sortOrder, @@ -163,6 +179,12 @@ class TaskListViewModel @Inject constructor( fun bind(taskFilter: TaskFilter) { filter.value = taskFilter } + fun toggleTagFilter(tag: String) { + tagFilter.value = if (tag in tagFilter.value) tagFilter.value - tag else tagFilter.value + tag + } + + fun clearTagFilter() { tagFilter.value = emptySet() } + fun toggleComplete(task: Task) { if (task.isCompleted) { viewModelScope.launch { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt index 88a8426..8e10742 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt @@ -169,6 +169,7 @@ private fun TaskWidgetBody( val ready = data as? TaskWidgetData.Ready val title = when (filter) { is TaskFilter.Smart -> context.getString(smartListLabel(filter.list)) + is TaskFilter.Tagged -> filter.tag is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName ?: context.getString(R.string.widget_tasks_label) } @@ -279,6 +280,7 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo // still opens straight to it, otherwise this just raises the app. is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) } ?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + is TaskFilter.Tagged -> Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } WidgetHeader( title = title, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt index c68cded..ff21de6 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt @@ -221,7 +221,7 @@ private fun WidgetFilterPicker( when (selected) { is TaskFilter.OfList -> setOf(selected.listId) is TaskFilter.OfLists -> selected.listIds - is TaskFilter.Smart -> emptySet() + is TaskFilter.Smart, is TaskFilter.Tagged -> emptySet() }, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt index 4ee3794..352e5d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt @@ -100,6 +100,7 @@ internal fun TaskFilter.widgetStorageValue(): String = when (this) { is TaskFilter.OfList -> "list:$listId" is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}" is TaskFilter.Smart -> "smart:${list.name}" + is TaskFilter.Tagged -> error("A tag has no widget") } /** Anything unreadable, or a smart list the widget doesn't offer, falls back to Today. */ @@ -166,7 +167,7 @@ sealed interface TaskWidgetData { val singleListId: Long? = when (filter) { is TaskFilter.OfList -> filter.listId is TaskFilter.OfLists -> filter.listIds.singleOrNull() - is TaskFilter.Smart -> null + is TaskFilter.Smart, is TaskFilter.Tagged -> null } /** [singleListId]'s name. */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99586cf..cca85bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,6 +39,9 @@ Done reordering Drag to reorder Sort by + Sort and filter + Filter by tag + Clear filter Due date Priority Title @@ -185,6 +188,9 @@ Location Link https://… + Tags + Add a tag + Remove tag %1$s Discard changes? Your changes to this task haven’t been saved. Discard @@ -212,6 +218,7 @@ Lists + Tags New task Could not read your tasks. No task lists yet. diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt index 4ec590e..2ede4d0 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt @@ -148,6 +148,17 @@ class TaskWriteMapperTest { assertThat(undone[Tasks.COMPLETED]).isNull() } + @Test + fun `category row carries only the name, so the provider resolves the category itself`() { + val values = TaskWriteMapper.categoryValues(taskId = 12L, name = "Work") + + assertThat(values).containsExactly( + TasksContract.Properties.TASK_ID, 12L, + TasksContract.Properties.MIMETYPE, "vnd.android.cursor.item/category", + TasksContract.Category.NAME, "Work", + ) + } + @Test fun `alarm carries every column the provider's validator demands`() { val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt index 9f08ba0..b2e1c5e 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt @@ -303,6 +303,65 @@ class VTodoMapperTest { } } + @Nested + inner class Categories { + + @Test + fun `a single plain line is claimed into the column`() { + val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work,Home\\, Garden")) + assertThat(mapped.entity.categories).containsExactly("Work", "Home, Garden").inOrder() + assertThat(mapped.entity.unknownProperties.orEmpty()).doesNotContain("CATEGORIES") + } + + @Test + fun `several lines are read but round-trip from the residue`() { + val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work", "CATEGORIES:Errands")) + assertThat(mapped.entity.categories).containsExactly("Work", "Errands").inOrder() + + val written = VTodoMapper.write(mapped.entity).properties("CATEGORIES").map { it.value } + assertThat(written).containsExactly("Work", "Errands") + } + + @Test + fun `a line the column would not reproduce exactly stays in the residue`() { + val mapped = VTodoMapper.read(vtodo("CATEGORIES;LANGUAGE=de:Arbeit", "SUMMARY:x")) + assertThat(mapped.entity.categories).containsExactly("Arbeit") + assertThat(VTodoMapper.read(vtodo("CATEGORIES:a, b")).entity.unknownProperties) + .contains("CATEGORIES:a, b") + + val written = VTodoMapper.write(mapped.entity).properties("CATEGORIES") + assertThat(written.single().param("LANGUAGE")).isEqualTo("de") + } + + @Test + fun `editing the tags evicts the residue copies and authors one line`() { + val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work", "CATEGORIES:Errands")) + val edited = mapped.entity.copy(categories = listOf("Work", "Home")) + + val written = VTodoMapper.write(edited).properties("CATEGORIES") + assertThat(written.single().value).isEqualTo("Work,Home") + assertThat(written.single().params).isEmpty() + } + + @Test + fun `no tags writes no line`() { + assertThat(VTodoMapper.write(entity()).properties("CATEGORIES")).isEmpty() + } + + @Test + fun `a duplicated or blank tag is dropped and the line stays in the residue`() { + val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work, ,Work")) + assertThat(mapped.entity.categories).containsExactly("Work") + assertThat(VTodoMapper.write(mapped.entity).properties("CATEGORIES").single().value).isEqualTo("Work, ,Work") + } + + @Test + fun `an unclean column is cleaned on the way out`() { + val written = VTodoMapper.write(entity().copy(categories = listOf(" a ", "", "a", "b"))) + assertThat(written.properties("CATEGORIES").single().value).isEqualTo("a,b") + } + } + @Nested inner class Validation { diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt index 48b0895..55e7c6b 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt @@ -39,6 +39,8 @@ class VTodoRoundTripTest { "mixed-value-types", "malformed-scalars", "two-timezones", + "categories-shapes", + "categories-split", ) @TestFactory diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLiftTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLiftTest.kt new file mode 100644 index 0000000..8fd047d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/CategoriesLiftTest.kt @@ -0,0 +1,35 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class CategoriesLiftTest { + + @Test + fun `claims a lone plain line and keeps the rest of the residue`() { + val result = CategoriesLift.lift("X-FOO:bar\r\nCATEGORIES:Work,Home\r\n") + assertThat(result.categories).containsExactly("Work", "Home").inOrder() + assertThat(result.claimed).isTrue() + assertThat(result.residue).contains("X-FOO:bar") + assertThat(result.residue).doesNotContain("CATEGORIES") + } + + @Test + fun `a residue that was only the line becomes null`() { + assertThat(CategoriesLift.lift("CATEGORIES:Work\r\n").residue).isNull() + } + + @Test + fun `several lines are read but not claimed`() { + val residue = "CATEGORIES:a\r\nCATEGORIES:b\r\n" + val result = CategoriesLift.lift(residue) + assertThat(result.categories).containsExactly("a", "b").inOrder() + assertThat(result.claimed).isFalse() + assertThat(result.residue).isEqualTo(residue) + } + + @Test + fun `nothing to lift from an empty residue`() { + assertThat(CategoriesLift.lift(null)).isEqualTo(CategoriesLift.Result(emptyList(), null, claimed = false)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt index 2ef8067..3c8272c 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt @@ -50,4 +50,12 @@ class ConvertersTest { } assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE) } + + @Test + fun `categories round-trip with escaped commas and come back cleaned`() { + val stored = CategoriesConverter.toText(listOf("a,b", " c ", "", "c")) + assertThat(stored).isEqualTo("a\\,b,c") + assertThat(CategoriesConverter.fromText(stored)).containsExactly("a,b", "c").inOrder() + assertThat(CategoriesConverter.fromText("")).isEmpty() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt index 7a1ed8b..b64fb59 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt @@ -29,6 +29,7 @@ private fun form( start: Instant? = null, due: Instant? = null, isAllDay: Boolean = false, + tags: List = emptyList(), ) = TaskForm( title = title, listId = 1, @@ -36,6 +37,7 @@ private fun form( start = start, due = due, isAllDay = isAllDay, + tags = tags, ) class TaskFormWriterTest { @@ -51,6 +53,12 @@ class TaskFormWriterTest { assertThat(entity.isDirty).isTrue() } + @Test + fun `tags are trimmed and blanks dropped`() { + val entity = TaskFormWriter.apply(task(), form(tags = listOf(" Work ", "", " ", "Home")), NOW, ZONE) + assertThat(entity.categories).containsExactly("Work", "Home").inOrder() + } + @Test fun `progress and status move together in both directions`() { assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFilteringTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFilteringTest.kt index 9be0ed7..7749331 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFilteringTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFilteringTest.kt @@ -58,4 +58,32 @@ class TaskFilteringTest { assertThat(TaskFiltering.matches(task, TaskFilter.OfList(7), todayStart, todayEnd)).isTrue() assertThat(TaskFiltering.matches(task, TaskFilter.OfList(8), todayStart, todayEnd)).isFalse() } + + @Test + fun `Tagged matches open and completed tasks carrying the tag`() { + val filter = TaskFilter.Tagged("Work") + assertThat(TaskFiltering.matches(testTask(tags = listOf("Home", "Work")), filter, todayStart, todayEnd)).isTrue() + assertThat( + TaskFiltering.matches(testTask(tags = listOf("Work"), status = TaskStatus.COMPLETED), filter, todayStart, todayEnd), + ).isTrue() + assertThat(TaskFiltering.matches(testTask(tags = listOf("work")), filter, todayStart, todayEnd)).isFalse() + } + + @Test + fun `narrowing by tags needs every one of them`() { + val task = testTask(tags = listOf("Home", "Work")) + assertThat(TaskFiltering.hasAllTags(task, emptySet())).isTrue() + assertThat(TaskFiltering.hasAllTags(task, setOf("Work"))).isTrue() + assertThat(TaskFiltering.hasAllTags(task, setOf("Work", "Home"))).isTrue() + assertThat(TaskFiltering.hasAllTags(task, setOf("Work", "Errands"))).isFalse() + } + + @Test + fun `tags in use are listed once each, alphabetically ignoring case`() { + val tasks = listOf( + testTask(id = 1, tags = listOf("work", "Home")), + testTask(id = 2, tags = listOf("Home", "errands")), + ) + assertThat(tagsInUse(tasks)).containsExactly("errands", "Home", "work").inOrder() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt index 3b49e2d..fb42d1f 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt @@ -44,4 +44,16 @@ class TaskFormTest { assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields()) .containsExactly(TaskFormField.Progress, TaskFormField.Parent) } + + @Test + fun `tags auto-reveal when the task has any`() { + assertThat(TaskForm(title = "x", listId = 1, tags = listOf("Work")).populatedFields()) + .containsExactly(TaskFormField.Tags) + } + + @Test + fun `tags are normalised to trimmed, non-blank, unique spellings in order`() { + assertThat(normalizeTags(listOf(" Work", "", "Home ", "Work", "work"))) + .containsExactly("Work", "Home", "work").inOrder() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt index 9ed7d17..ad92ce0 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt @@ -13,6 +13,7 @@ fun testTask( isAllDay: Boolean = false, completedAt: Instant? = null, parentId: Long? = null, + tags: List = emptyList(), ): Task = Task( taskId = id, listId = listId, @@ -38,4 +39,5 @@ fun testTask( distanceFromCurrent = 0, created = null, lastModified = null, + tags = tags, ) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt index 86c6b8c..43bf0ed 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt @@ -35,9 +35,10 @@ class ICalendarWriterTest { rrule: String? = null, rdate: String? = null, parentId: Long? = null, + tags: List = emptyList(), ) = ExportTask( taskId, uid, title, description, location, url, priority, status, percentComplete, - start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId, + start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId, tags, ) private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String = @@ -59,6 +60,13 @@ class ICalendarWriterTest { assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO") } + @Test + fun `writes the tags as one CATEGORIES line, escaped`() { + val lines = linesOf(write(task(tags = listOf("Work", "Home, Garden")))) + assertThat(lines).contains("CATEGORIES:Work,Home\\, Garden") + assertThat(linesOf(write(task())).none { it.startsWith("CATEGORIES") }).isTrue() + } + @Test fun `uses CRLF line endings`() { // RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected diff --git a/app/src/test/resources/vtodo/README.md b/app/src/test/resources/vtodo/README.md index ca0516b..cf4ee53 100644 --- a/app/src/test/resources/vtodo/README.md +++ b/app/src/test/resources/vtodo/README.md @@ -30,3 +30,5 @@ VTIMEZONE bodies, fold positions, parameter quoting). | `malformed-scalars.ics` | `PRIORITY:11`, `PERCENT-COMPLETE:150`, `SEQUENCE:x`, `STATUS:X-DEFERRED`, `CLASS:X-INTERNAL` — values no spec allows. Clamping or defaulting any of them is a silent rewrite of somebody's data | | `two-timezones.ics` | `DTSTART` and `DUE` in different zones. One `timezone` column cannot author both | | `folded-utf8.ics` | A line folded mid-emoji-adjacent, to prove folding counts octets and splits on character boundaries | +| `categories-shapes.ics` | One `CATEGORIES` line with an escaped comma inside a value — the one shape the column claims, so it is re-authored and must come out identical | +| `categories-split.ics` | One line per tag, a `LANGUAGE` parameter and a space after a comma. Read for display, but none of it is ours to rewrite | diff --git a/app/src/test/resources/vtodo/categories-shapes.ics b/app/src/test/resources/vtodo/categories-shapes.ics new file mode 100644 index 0000000..43ab4f3 --- /dev/null +++ b/app/src/test/resources/vtodo/categories-shapes.ics @@ -0,0 +1,10 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Several Clients//EN +BEGIN:VTODO +UID:categories-001 +DTSTAMP:20260901T120000Z +SUMMARY:One line, an escaped comma +CATEGORIES:Work,Home\, Garden +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/categories-split.ics b/app/src/test/resources/vtodo/categories-split.ics new file mode 100644 index 0000000..ec52464 --- /dev/null +++ b/app/src/test/resources/vtodo/categories-split.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Several Clients//EN +BEGIN:VTODO +UID:categories-002 +DTSTAMP:20260901T120000Z +SUMMARY:One line per tag, one with a language +CATEGORIES:Errands +CATEGORIES;LANGUAGE=de:Einkauf +CATEGORIES:a, b +END:VTODO +END:VCALENDAR diff --git a/floret-kit b/floret-kit index cb109c0..0b44d35 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit cb109c0e82f4513a7fb31f898de1c22128508bb5 +Subproject commit 0b44d353d2a617a0c0e0b9f8c71948e2e97e37e4 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 263000f..57d8d63 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -agp = "9.2.1" -kotlin = "2.3.21" +agp = "9.4.1" +kotlin = "2.4.20" ksp = "2.3.11" hilt = "2.60.1" coreKtx = "1.19.0" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baabb587c669f562ae36f953de2481846..5097068a8d375f5bf0d15693fb5b3615c972a801 100644 GIT binary patch delta 39622 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 delta 35825 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
g-vUc>#35|rt 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b21b69e..1eca32e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,10 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip -distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f +distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 23d15a9..249efbb 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (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: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (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. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # 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 if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index db3a6ac..3185a43 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,12 +19,39 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +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 if "%DIRNAME%"=="" set DIRNAME=. @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,36 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line -set CLASSPATH= -@rem Execute Gradle -"%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 Execute gradlew +@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 End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -: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 +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/scripts/make_import_fixture.py b/scripts/make_import_fixture.py index 3c87a44..a2118b3 100644 --- a/scripts/make_import_fixture.py +++ b/scripts/make_import_fixture.py @@ -115,8 +115,8 @@ def seed(db: sqlite3.Connection) -> None: # data0 minutes before, data1 reference (1 = DUE), data3 alarm type. (1, 1, ALARM_MIMETYPE, "30", "1", None, "1"), (2, 9, ALARM_MIMETYPE, "1440", "1", "Ship it", "1"), - # A non-alarm property the import must skip. - (3, 1, "vnd.android.cursor.item/category", "Errands", None, None, None), + # A category: data0 the provider's category id, data1 the name. A tag, not an alarm. + (3, 1, "vnd.android.cursor.item/category", "1", "Errands", None, None), ], )