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