diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/2.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/2.json new file mode 100644 index 0000000..0d38395 --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/2.json @@ -0,0 +1,529 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "14b159c51416ba27653f6320ac7f6f03", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principalUrl", + "columnName": "principal_url", + "affinity": "TEXT" + }, + { + "fieldPath": "homeSetUrl", + "columnName": "home_set_url", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncError", + "columnName": "last_sync_error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "isVisible", + "columnName": "is_visible", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "isSynced", + "columnName": "is_synced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "ctag", + "columnName": "ctag", + "affinity": "TEXT" + }, + { + "fieldPath": "syncToken", + "columnName": "sync_token", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_lists_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "list_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "percentComplete", + "columnName": "percent_complete", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAt", + "columnName": "completed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "classification", + "columnName": "classification", + "affinity": "INTEGER" + }, + { + "fieldPath": "dtstart", + "columnName": "dtstart", + "affinity": "INTEGER" + }, + { + "fieldPath": "due", + "columnName": "due", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT" + }, + { + "fieldPath": "isAllDay", + "columnName": "is_all_day", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT" + }, + { + "fieldPath": "rrule", + "columnName": "rrule", + "affinity": "TEXT" + }, + { + "fieldPath": "rdate", + "columnName": "rdate", + "affinity": "TEXT" + }, + { + "fieldPath": "exdate", + "columnName": "exdate", + "affinity": "TEXT" + }, + { + "fieldPath": "recurrenceId", + "columnName": "recurrence_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "masterId", + "columnName": "master_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "unknownProperties", + "columnName": "unknown_properties", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_list_id_is_deleted", + "unique": false, + "columnNames": [ + "list_id", + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)" + }, + { + "name": "index_tasks_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_tasks_master_id_recurrence_id", + "unique": false, + "columnNames": [ + "master_id", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)" + }, + { + "name": "index_tasks_is_dirty", + "unique": false, + "columnNames": [ + "is_dirty" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)" + }, + { + "name": "index_tasks_list_id_uid_recurrence_id", + "unique": true, + "columnNames": [ + "list_id", + "uid", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)" + } + ], + "foreignKeys": [ + { + "table": "task_lists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "list_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "master_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "parent_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "task_alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesBefore", + "columnName": "minutes_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reference", + "columnName": "reference", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DUE'" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_alarms_task_id", + "unique": false, + "columnNames": [ + "task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '14b159c51416ba27653f6320ac7f6f03')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/assets/tasks-v23.db b/app/src/androidTest/assets/tasks-v23.db index e305e90..d4e5b53 100644 Binary files a/app/src/androidTest/assets/tasks-v23.db and b/app/src/androidTest/assets/tasks-v23.db differ diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt index c040421..36d23a2 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt @@ -212,6 +212,15 @@ class OneShotImportTest { assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands") } + @Test + fun importsCategoryPropertiesAsTags() { + importer.importFrom(fixture()) + val tasks = taskRows() + + assertThat(tasks.getValue("Buy milk").categories).containsExactly("Errands") + assertThat(tasks.getValue("Ship the release").categories).isEmpty() + } + // --- running it ----------------------------------------------------------- @Test diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt index cf4b272..ca0d824 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt @@ -8,27 +8,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -/** - * The migration harness, proven against the committed schema in `app/schemas/`. - * - * There is one schema version today, so all there is to assert is that the helper - * can build v1 from the exported JSON, seed it, and validate it back — i.e. the - * export, the assets wiring and the identity hash all line up. That is the point: - * the first real migration only has to add its own case. - * - * **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s - * `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the - * database, and add a test here shaped like this: - * - * ``` - * helper.createDatabase(TEST_DB, 1).use { db -> - * db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)") - * } - * helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db -> - * // read the seeded rows back — validation proves the shape, not the data - * } - * ``` - */ +/** Migrations, proven against the committed schemas in `app/schemas/`. */ @RunWith(AndroidJUnit4::class) class TasksDatabaseMigrationTest { @@ -62,6 +42,39 @@ class TasksDatabaseMigrationTest { } } + @Test + fun liftsCategoriesOutOfTheResidueWithoutDirtyingTheTask() { + helper.createDatabase(TEST_DB, 1).use { db -> + db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Work', 0)") + db.execSQL( + "INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)", + arrayOf(1, "uid-1", "Claimed", "X-FOO:bar\r\nCATEGORIES:Work,Home\r\n"), + ) + db.execSQL( + "INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)", + arrayOf(2, "uid-2", "Split", "CATEGORIES:a\r\nCATEGORIES:b\r\n"), + ) + db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (3, 1, 'uid-3', 'Untagged')") + } + + helper.runMigrationsAndValidate(TEST_DB, 2, true, TasksDatabase.MIGRATION_1_2).use { db -> + db.query("SELECT categories, unknown_properties, is_dirty FROM tasks ORDER BY id").use { c -> + c.moveToNext() + assertThat(c.getString(0)).isEqualTo("Work,Home") + assertThat(c.getString(1)).doesNotContain("CATEGORIES") + assertThat(c.getString(1)).contains("X-FOO:bar") + assertThat(c.getInt(2)).isEqualTo(0) + + c.moveToNext() + assertThat(c.getString(0)).isEqualTo("a,b") + assertThat(c.getString(1)).isEqualTo("CATEGORIES:a\r\nCATEGORIES:b\r\n") + + c.moveToNext() + assertThat(c.getString(0)).isEmpty() + } + } + } + private companion object { const val TEST_DB = "migration-test.db" } diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt index 874fc6f..8bc1eb3 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt @@ -107,6 +107,17 @@ class ExternalImportTest { assertThat(importer.hasRun.first()).isTrue() } + @Test + fun copiesTagsCleanedUp() = runBlocking { + source.lists = listOf(list(7, "Errands")) + source.tasks = mapOf(7L to listOf(task(100, "Milk", tags = listOf("Shop", " Shop ", "", "Home")))) + + importer.run() + + assertThat(db.tasks().tasks(listId = null, includeCompleted = true).single().task.categories) + .containsExactly("Shop", "Home").inOrder() + } + /** Every list arrives device-only: the account belongs to the sync app. */ @Test fun importedListsAreDeviceOnly() = runBlocking { @@ -241,6 +252,7 @@ class ExternalImportTest { title: String, uid: String? = "uid-$id", parentId: Long? = null, + tags: List = emptyList(), ) = ExportTask( taskId = id, uid = uid, @@ -260,6 +272,7 @@ class ExternalImportTest { rrule = null, rdate = null, parentId = parentId, + tags = tags, ) private companion object { diff --git a/app/src/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 1b33c55..5097068 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ 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), ], )