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