Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e34c60577 | ||
|
|
caf4357cee | ||
|
|
97164feb39 | ||
|
|
8a521d32f9 | ||
|
|
b68582177b | ||
|
|
05f43d6f76 | ||
|
|
7498ffb37e | ||
|
|
7bcac70cec | ||
|
|
023c0e0157 | ||
|
|
efcd641ea8 | ||
|
|
7d072d0bf7 | ||
|
|
bda1a60959 | ||
|
|
2d7b6309c0 | ||
|
|
78b1cfbaed | ||
|
|
2887176e88 | ||
|
|
44da66bb85 | ||
|
|
c194984a8b | ||
|
|
8f06f24112 | ||
|
|
fb678d008b | ||
|
|
0770158b02 | ||
|
|
d64a770164 | ||
|
|
ff0cc28465 | ||
|
|
6a1733d97b | ||
|
|
7a94ee6786 | ||
|
|
7b5bf86cfa | ||
|
|
ddff57e8fe | ||
|
|
150e5c5f2c | ||
|
|
75ad41c41e |
No files matched your search
+35
-1
@@ -7,6 +7,26 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.3.1] - 2026-11-09
|
||||
|
||||
### Changed
|
||||
- Ticking off the current occurrence of a repeating task moves the series on
|
||||
to its next date and keeps a completed copy, as tasks.org and Apple
|
||||
Reminders do. Other apps and the web views of servers such as Infomaniak now
|
||||
see it as done instead of still open. Unticking it moves the series back,
|
||||
as long as the series hasn't moved on or changed its start since (#137).
|
||||
|
||||
### Fixed
|
||||
- With tasks.org as the storage provider, recurring tasks are recognised as
|
||||
recurring. Before, editing one removed its recurrence and ticking it off
|
||||
closed the whole series. With tasks.org and OpenTasks alike, the editor now
|
||||
shows the task's repeat rule instead of none, and an occurrence changed on
|
||||
its own stays part of its series (#135).
|
||||
- With tasks.org or OpenTasks as the storage provider, the order you give your
|
||||
lists is kept instead of snapping back (#136).
|
||||
|
||||
## [1.3.0] - 2026-11-02
|
||||
|
||||
### Added
|
||||
- Self-hosted servers with a self-signed certificate can be added: Agendula
|
||||
shows the certificate's fingerprint and asks whether to trust it. A server
|
||||
@@ -17,13 +37,27 @@ All notable changes to this project are documented here. The format follows
|
||||
- Pull down on the task list to sync (#73).
|
||||
- Task list sections can be collapsed (#91).
|
||||
- A pure black theme for AMOLED screens (#99).
|
||||
- Agendula can show up as "Tasks" in your launcher (#104).
|
||||
- Agendula can show up as "Tasks" in your launcher, picked from launcher
|
||||
previews as in Calendula (#104).
|
||||
- Reminder notifications show the task's description (#94).
|
||||
|
||||
### Changed
|
||||
- Adding or opening a task from a widget returns you to the home screen
|
||||
afterwards (#93).
|
||||
- A swipe that starts at the screen edge no longer swipes the task (#90).
|
||||
- Today also lists tasks that start today, and keeps the ones completed today
|
||||
in a Completed section (#123).
|
||||
|
||||
### Fixed
|
||||
- A task created from Today, or from a Today widget, is due today, so it shows
|
||||
up there (#122).
|
||||
- Completing a task from the list, a widget or another device now cancels its
|
||||
upcoming reminder and clears one already showing (#127, #129).
|
||||
- The splash screen follows the app's theme, including Black and dynamic
|
||||
colour, instead of flashing white in dark mode (#119).
|
||||
- A task saved without its default start or due reminder because it had
|
||||
already rung (a start of "now", say) gets it once that date is moved later,
|
||||
if the task has no reminder from that date (#121).
|
||||
|
||||
## [1.2.0] - 2026-10-26
|
||||
|
||||
|
||||
@@ -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 = 1030001
|
||||
versionName = "1.3.0-beta.1"
|
||||
versionCode = 1030199
|
||||
versionName = "1.3.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -0,0 +1,768 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 5,
|
||||
"identityHash": "fcb14ff68d892c72fa7459204276abb6",
|
||||
"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, `label` 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"
|
||||
},
|
||||
{
|
||||
"fieldPath": "label",
|
||||
"columnName": "label",
|
||||
"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, `reminders_off` INTEGER NOT NULL DEFAULT 0, 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"
|
||||
},
|
||||
{
|
||||
"fieldPath": "remindersOff",
|
||||
"columnName": "reminders_off",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"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, `uid` TEXT NOT NULL DEFAULT '', `raw` 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"
|
||||
},
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "raw",
|
||||
"columnName": "raw",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "advanced_occurrences",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `rule_after` TEXT, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, `undoable` INTEGER NOT NULL DEFAULT 1, `settled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "copyId",
|
||||
"columnName": "copy_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "masterId",
|
||||
"columnName": "master_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "anchor",
|
||||
"columnName": "anchor",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "advancedTo",
|
||||
"columnName": "advanced_to",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priorDtstart",
|
||||
"columnName": "prior_dtstart",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "priorDue",
|
||||
"columnName": "prior_due",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "priorStatus",
|
||||
"columnName": "prior_status",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priorPercentComplete",
|
||||
"columnName": "prior_percent_complete",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "priorCompletedAt",
|
||||
"columnName": "prior_completed_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "spent",
|
||||
"columnName": "spent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ruleAfter",
|
||||
"columnName": "rule_after",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "droppedExdates",
|
||||
"columnName": "dropped_exdates",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fromOverride",
|
||||
"columnName": "from_override",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "undoable",
|
||||
"columnName": "undoable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "settled",
|
||||
"columnName": "settled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"copy_id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_advanced_occurrences_master_id_anchor",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"master_id",
|
||||
"anchor"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` ON `${TABLE_NAME}` (`master_id`, `anchor`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "tasks",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"copy_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "tasks",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"master_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "advanced_detached",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `copy_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `task_id` INTEGER, FOREIGN KEY(`copy_id`) REFERENCES `advanced_occurrences`(`copy_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "copyId",
|
||||
"columnName": "copy_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "anchor",
|
||||
"columnName": "anchor",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "taskId",
|
||||
"columnName": "task_id",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_advanced_detached_copy_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"copy_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_copy_id` ON `${TABLE_NAME}` (`copy_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_advanced_detached_task_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"task_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_task_id` ON `${TABLE_NAME}` (`task_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "advanced_occurrences",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"copy_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"copy_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "tasks",
|
||||
"onDelete": "SET NULL",
|
||||
"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, 'fcb14ff68d892c72fa7459204276abb6')"
|
||||
]
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.After
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* List order against a real external provider (#136). Skipped unless one is
|
||||
* installed and its permissions are granted.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExternalProviderListOrderTest {
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val resolver = ProviderResolver(AndroidProviderEnvironment(context)).apply {
|
||||
storageMode = StorageMode.EXTERNAL
|
||||
}
|
||||
private val source = AndroidTasksDataSource(context, resolver)
|
||||
private val created = mutableListOf<Long>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val provider = resolver.resolve()
|
||||
assumeTrue("no external tasks provider, or not granted", provider != null && resolver.hasPermission(provider))
|
||||
listOf("Order A", "Order B", "Order C").forEach { created += source.createLocalList(it, 0) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = created.forEach { runCatching { source.deleteList(it) } }
|
||||
|
||||
private fun ours() = source.taskLists().map { it.id }.filter { it in created }
|
||||
|
||||
/** Waits until the provider has gone quiet, so a change seen afterwards is the test's own. */
|
||||
private fun settle() {
|
||||
var last = System.currentTimeMillis()
|
||||
val observer = source.registerObserver { last = System.currentTimeMillis() }
|
||||
while (System.currentTimeMillis() - last < 1_500) Thread.sleep(100)
|
||||
observer.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aReorderIsKeptAndReported() {
|
||||
val (a, b, c) = created
|
||||
settle()
|
||||
val changed = CountDownLatch(1)
|
||||
val observer = source.registerObserver { changed.countDown() }
|
||||
|
||||
source.reorderLists(listOf(c, a, b))
|
||||
|
||||
assertThat(changed.await(5, TimeUnit.SECONDS)).isTrue()
|
||||
observer.close()
|
||||
assertThat(ours()).containsExactly(c, a, b).inOrder()
|
||||
assertThat(AndroidTasksDataSource(context, resolver).taskLists().map { it.id }.filter { it in created })
|
||||
.containsExactly(c, a, b).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeletedListLeavesNoPositionBehind() {
|
||||
val (a, b, c) = created
|
||||
source.reorderLists(listOf(c, a, b))
|
||||
|
||||
source.deleteList(c)
|
||||
created -= c
|
||||
|
||||
source.taskLists()
|
||||
assertThat(source.storedListOrder().keys).doesNotContain(c)
|
||||
}
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Recurrence against a real external provider (#135). Skipped unless one is
|
||||
* installed and its permissions are granted, so it only runs where set up:
|
||||
* `pm grant <app> org.tasks.permission.READ_TASKS` (and WRITE_TASKS).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExternalProviderRecurrenceTest {
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val resolver = ProviderResolver(AndroidProviderEnvironment(context)).apply {
|
||||
storageMode = StorageMode.EXTERNAL
|
||||
}
|
||||
private val source = AndroidTasksDataSource(context, resolver)
|
||||
private var listId = 0L
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val provider = resolver.resolve()
|
||||
assumeTrue("no external tasks provider, or not granted", provider != null && resolver.hasPermission(provider))
|
||||
listId = source.createLocalList("Recurrence test", 0)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
if (listId != 0L) source.deleteList(listId)
|
||||
}
|
||||
|
||||
private fun dailyTask(): Long {
|
||||
// Milliseconds and all, as the "start today" default gives.
|
||||
val start = Clock.System.now() + 1.hours
|
||||
return source.insertTask(
|
||||
TaskForm(title = "Daily", listId = listId, start = start, due = start + 1.hours, rrule = "FREQ=DAILY"),
|
||||
reminders = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun storedRule(taskId: Long): String? = context.contentResolver.query(
|
||||
TasksContract.tasksUri(resolver.resolve()!!.authority),
|
||||
arrayOf(Tasks.RRULE),
|
||||
"${Tasks.ID} = ?",
|
||||
arrayOf(taskId.toString()),
|
||||
null,
|
||||
)?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
|
||||
private fun setRule(taskId: Long, rule: String) {
|
||||
context.contentResolver.update(
|
||||
TasksContract.tasksUri(resolver.resolve()!!.authority),
|
||||
ContentValues().apply { put(Tasks.RRULE, rule) },
|
||||
"${Tasks.ID} = ?",
|
||||
arrayOf(taskId.toString()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSeriesReadsAsRecurringWithItsRule() {
|
||||
val id = dailyTask()
|
||||
|
||||
val listed = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }.minBy { it.occurrenceStart!! }
|
||||
assertThat(listed.isRecurring).isTrue()
|
||||
assertThat(listed.isOccurrence).isTrue()
|
||||
assertThat(listed.recurrenceRule).isEqualTo("FREQ=DAILY")
|
||||
|
||||
val opened = source.task(id)!!
|
||||
assertThat(opened.recurrenceRule).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun savingTheSeriesKeepsItsRule() {
|
||||
val id = dailyTask()
|
||||
val task = source.task(id)!!
|
||||
|
||||
source.updateSeries(
|
||||
id,
|
||||
task.occurrenceStart!!,
|
||||
TaskForm(title = "Daily, renamed", listId = listId, start = task.start, due = task.due, rrule = task.recurrenceRule),
|
||||
reminders = null,
|
||||
)
|
||||
|
||||
assertThat(storedRule(id)).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingAnOccurrenceMovesTheSeriesOn() {
|
||||
val id = dailyTask()
|
||||
val task = source.task(id)!!
|
||||
|
||||
source.setCompletedInstance(id, task.occurrenceStart!!, completed = true)
|
||||
|
||||
assertSeriesMovedOn(id, task.occurrenceStart!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTickedOccurrenceReadsAsItsCopy() {
|
||||
val id = dailyTask()
|
||||
val anchor = source.task(id)!!.occurrenceStart!!
|
||||
|
||||
source.setCompletedInstance(id, anchor, completed = true)
|
||||
|
||||
val read = source.task(id, anchor)!!
|
||||
assertThat(read.taskId).isNotEqualTo(id)
|
||||
assertThat(read.isClosed).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun undoingATickMovesTheSeriesBack() {
|
||||
val id = dailyTask()
|
||||
val anchor = source.task(id)!!.occurrenceStart!!
|
||||
source.setCompletedInstance(id, anchor, completed = true)
|
||||
val copy = source.task(id, anchor)!!.taskId
|
||||
|
||||
source.setCompletedInstance(id, anchor, completed = false)
|
||||
|
||||
val read = source.task(id, anchor)!!
|
||||
assertThat(read.taskId).isEqualTo(id)
|
||||
assertThat(read.isClosed).isFalse()
|
||||
assertThat(source.task(copy)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningTheCopyItselfMovesTheSeriesBack() {
|
||||
val id = dailyTask()
|
||||
val anchor = source.task(id)!!.occurrenceStart!!
|
||||
source.setCompletedInstance(id, anchor, completed = true)
|
||||
val copy = source.task(id, anchor)!!.taskId
|
||||
|
||||
source.setCompleted(copy, completed = false)
|
||||
|
||||
assertThat(source.task(id, anchor)!!.taskId).isEqualTo(id)
|
||||
assertThat(source.task(copy)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSeriesChangedSinceTheTickReopensOnlyTheCopy() {
|
||||
val id = dailyTask()
|
||||
val anchor = source.task(id)!!.occurrenceStart!!
|
||||
source.setCompletedInstance(id, anchor, completed = true)
|
||||
val copy = source.task(id, anchor)!!.taskId
|
||||
setRule(id, "FREQ=WEEKLY")
|
||||
|
||||
source.setCompletedInstance(id, anchor, completed = false)
|
||||
|
||||
assertThat(source.task(copy)!!.isClosed).isFalse()
|
||||
assertThat(storedRule(id)).isEqualTo("FREQ=WEEKLY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOccurrenceThatIsGoneReadsAsNothing() {
|
||||
val id = dailyTask()
|
||||
val anchor = source.task(id)!!.occurrenceStart!!
|
||||
|
||||
assertThat(source.task(id, anchor + 30.hours)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSeriesStoredWithMillisecondsStillMovesOn() {
|
||||
val id = legacySeries()
|
||||
val task = source.task(id)!!
|
||||
|
||||
source.setCompletedInstance(id, task.occurrenceStart!!, completed = true)
|
||||
|
||||
assertSeriesMovedOn(id, task.occurrenceStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnknownOccurrenceDoesNotCloseTheSeries() {
|
||||
val id = dailyTask()
|
||||
val anchor = source.task(id)!!.occurrenceStart!!
|
||||
|
||||
assertThrows(TaskWriteFailedException::class.java) {
|
||||
source.setCompletedInstance(id, anchor + 30.hours, completed = true)
|
||||
}
|
||||
|
||||
assertThat(source.task(id)!!.isClosed).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundingALegacySeriesLeavesItUnmodified() {
|
||||
val id = legacySeries()
|
||||
val before = storedColumn(id, Tasks.LAST_MODIFIED)
|
||||
|
||||
source.task(id)
|
||||
|
||||
assertThat(storedStart(id)!! % 1000).isEqualTo(0L)
|
||||
assertThat(storedColumn(id, Tasks.LAST_MODIFIED)).isEqualTo(before)
|
||||
}
|
||||
|
||||
/** A daily series as builds before whole-second writes left it, already in sync with its server. */
|
||||
private fun legacySeries(): Long {
|
||||
val start = (Clock.System.now() + 1.hours).toEpochMilliseconds() / 1000 * 1000
|
||||
return insertAsSyncAdapter(
|
||||
Tasks.LIST_ID to listId,
|
||||
Tasks.TITLE to "Old daily",
|
||||
Tasks.DTSTART to start + 345,
|
||||
Tasks.DUE to start + 3_600_345,
|
||||
Tasks.TZ to "UTC",
|
||||
Tasks.RRULE to "FREQ=DAILY",
|
||||
).also {
|
||||
// The app's own reads may already have run the one-time rounding.
|
||||
context.getSharedPreferences("provider_migrations", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertAsSyncAdapter(vararg values: Pair<String, Any>): Long {
|
||||
val uri = TasksContract.asSyncAdapter(
|
||||
TasksContract.tasksUri(resolver.resolve()!!.authority),
|
||||
TasksContract.LOCAL_ACCOUNT_NAME,
|
||||
TasksContract.LOCAL_ACCOUNT_TYPE,
|
||||
)
|
||||
val cv = ContentValues().apply {
|
||||
values.forEach { (k, v) -> if (v is Long) put(k, v) else put(k, v.toString()) }
|
||||
}
|
||||
return context.contentResolver.insert(uri, cv)!!.lastPathSegment!!.toLong()
|
||||
}
|
||||
|
||||
/** Edits the series' first occurrence on its own and returns the override that makes. */
|
||||
private fun overrideOf(id: Long): Task {
|
||||
val task = source.task(id)!!
|
||||
source.updateInstance(
|
||||
id,
|
||||
task.occurrenceStart!!,
|
||||
TaskForm(title = "Just this once", listId = listId, start = task.start, due = task.due),
|
||||
reminders = null,
|
||||
)
|
||||
return source.tasks(TaskQuery(listId = listId)).single { it.seriesId == id && it.taskId != id }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOverrideBelongsToItsSeries() {
|
||||
val id = dailyTask()
|
||||
val anchor = source.task(id)!!.occurrenceStart
|
||||
|
||||
val override = overrideOf(id)
|
||||
|
||||
assertThat(override.title).isEqualTo("Just this once")
|
||||
assertThat(override.isOccurrence).isTrue()
|
||||
assertThat(override.occurrenceStart).isEqualTo(anchor)
|
||||
assertThat(override.recurrenceRule).isEqualTo("FREQ=DAILY")
|
||||
assertThat(source.task(override.taskId, override.occurrenceStart)?.taskId).isEqualTo(override.taskId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAllDayDueAddedToTheSeriesStaysOnItsDay() {
|
||||
val day = Clock.System.now().toEpochMilliseconds() / 86_400_000 * 86_400_000
|
||||
val id = source.insertTask(
|
||||
TaskForm(title = "All day", listId = listId, start = Instant.fromEpochMilliseconds(day), isAllDay = true, rrule = "FREQ=DAILY"),
|
||||
reminders = null,
|
||||
)
|
||||
val task = source.task(id)!!
|
||||
// Local midnight the next day, as the date picker hands it over.
|
||||
val localMidnight = Instant.fromEpochMilliseconds(day + 86_400_000 - java.util.TimeZone.getDefault().getOffset(day))
|
||||
|
||||
source.updateSeries(
|
||||
id,
|
||||
task.occurrenceStart!!,
|
||||
TaskForm(title = "All day", listId = listId, start = task.start, due = localMidnight, isAllDay = true, rrule = "FREQ=DAILY"),
|
||||
reminders = null,
|
||||
)
|
||||
|
||||
assertThat(storedColumn(id, Tasks.DUE)!!.toLong() % 86_400_000).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun savingAllFromAnOverrideKeepsTheSeriesWhereItIs() {
|
||||
val id = dailyTask()
|
||||
val before = storedStart(id)
|
||||
val override = overrideOf(id)
|
||||
|
||||
source.updateSeries(
|
||||
id,
|
||||
override.occurrenceStart!!,
|
||||
TaskForm(title = "Renamed", listId = listId, start = override.start, due = override.due, rrule = override.recurrenceRule),
|
||||
reminders = null,
|
||||
)
|
||||
|
||||
assertThat(storedStart(id)).isEqualTo(before)
|
||||
assertThat(storedRule(id)).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingAnOverrideExcludesItsOccurrence() {
|
||||
val id = dailyTask()
|
||||
val override = overrideOf(id)
|
||||
|
||||
source.deleteOverride(override.taskId, override.occurrenceStart!!)
|
||||
|
||||
// Excluded on the master, not just the override dropped.
|
||||
assertThat(storedColumn(id, Tasks.EXDATE)).isNotNull()
|
||||
val open = source.tasks(TaskQuery(listId = listId)).filter { it.seriesId == id }
|
||||
assertThat(open.map { it.occurrenceStart }).doesNotContain(override.occurrenceStart)
|
||||
assertThat(storedRule(id)).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editingAnOverrideAgainKeepsItsNewTagsAndReminders() {
|
||||
val id = dailyTask()
|
||||
val override = overrideOf(id)
|
||||
val form = TaskForm(
|
||||
title = override.title,
|
||||
listId = listId,
|
||||
start = override.start,
|
||||
due = override.due,
|
||||
tags = listOf("urgent"),
|
||||
reminders = listOf(TaskReminder(minutesBefore = 15)),
|
||||
)
|
||||
|
||||
source.updateInstance(override.taskId, override.occurrenceStart!!, form, form.reminders)
|
||||
|
||||
assertThat(source.task(override.taskId)!!.tags).containsExactly("urgent")
|
||||
assertThat(source.reminders()[override.taskId]).containsExactly(TaskReminder(minutesBefore = 15))
|
||||
assertThat(source.task(id)?.tags.orEmpty()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOccurrenceIsFoundByItsAnchorToTheSecond() {
|
||||
val id = dailyTask()
|
||||
val override = overrideOf(id)
|
||||
|
||||
val found = source.task(id, override.occurrenceStart!! + 345.milliseconds)
|
||||
|
||||
assertThat(found?.taskId).isEqualTo(override.taskId)
|
||||
}
|
||||
|
||||
private fun storedStart(taskId: Long): Long? = storedColumn(taskId, Tasks.DTSTART)?.toLong()
|
||||
|
||||
private fun storedColumn(taskId: Long, column: String): String? = context.contentResolver.query(
|
||||
TasksContract.tasksUri(resolver.resolve()!!.authority),
|
||||
arrayOf(column),
|
||||
"${Tasks.ID} = ?",
|
||||
arrayOf(taskId.toString()),
|
||||
null,
|
||||
)?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
|
||||
private fun assertSeriesMovedOn(id: Long, completed: Instant) {
|
||||
assertThat(storedRule(id)).isNotNull()
|
||||
val open = source.tasks(TaskQuery(listId = listId)).filter { it.seriesId == id }
|
||||
assertThat(open).isNotEmpty()
|
||||
assertThat(open.minOf { it.occurrenceStart!! }).isGreaterThan(completed)
|
||||
}
|
||||
}
|
||||
+5
@@ -124,6 +124,7 @@ class AgendulaTaskProviderTest {
|
||||
|
||||
@Test
|
||||
fun aBadWriteReachesTheClientAsIllegalArgument() {
|
||||
assumeTrue("Agendula is running on an external provider", ownStore())
|
||||
// No list has this id, so nothing in the app's real store is touched.
|
||||
val values = ContentValues().apply {
|
||||
put("list_id", Long.MAX_VALUE)
|
||||
@@ -135,6 +136,7 @@ class AgendulaTaskProviderTest {
|
||||
|
||||
@Test
|
||||
fun aFailingBatchWritesNothing() {
|
||||
assumeTrue("Agendula is running on an external provider", ownStore())
|
||||
val missingList = Long.MAX_VALUE
|
||||
val operations = arrayListOf(
|
||||
ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(),
|
||||
@@ -145,6 +147,9 @@ class AgendulaTaskProviderTest {
|
||||
.use { assertThat(it.count).isEqualTo(0) }
|
||||
}
|
||||
|
||||
/** Whether the app's own store is active: only then does the provider take writes at all. */
|
||||
private fun ownStore() = runCatching { resolver.delete(uri("tasks"), "_id = -1", null) }.isSuccess
|
||||
|
||||
/** stdout and stderr of [command]; the denial lands on stderr. */
|
||||
private fun shell(command: String): String {
|
||||
val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||
|
||||
+18
-2
@@ -734,9 +734,11 @@ class ProviderWriterTest {
|
||||
val series = db.tasks().insert(
|
||||
TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null),
|
||||
)
|
||||
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||
// The second occurrence: ticking the first moves the series on instead.
|
||||
val second = today + 1.days
|
||||
val row = readRow(ProviderTable.INSTANCES, instanceId(second))
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(second)), null)
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null)
|
||||
|
||||
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||
@@ -795,6 +797,20 @@ class ProviderWriterTest {
|
||||
|
||||
private fun store() = RoomTasksDataSource(db)
|
||||
|
||||
@Test
|
||||
fun reopeningATickedOccurrencesCopyMovesTheSeriesBack() {
|
||||
val series = db.tasks().insert(
|
||||
TaskEntity(listId = home, uid = "w", title = "Weekly", dtstart = today, rrule = "FREQ=WEEKLY", timezone = "UTC"),
|
||||
)
|
||||
store().setCompletedInstance(series, today, true)
|
||||
val copy = db.tasks().allIn(home).single { it.id != series }
|
||||
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 0), byId(ProviderTable.TASKS, copy.id), null)
|
||||
|
||||
assertThat(task(series)!!.dtstart).isEqualTo(today)
|
||||
assertThat(task(copy.id)).isNull()
|
||||
}
|
||||
|
||||
/** Every column of one published row, as a client that read it would write it back. */
|
||||
private fun readRow(table: ProviderTable, id: Long): Map<String, Any?> {
|
||||
val columns = table.columns.map { it.first }
|
||||
|
||||
+625
-5
@@ -211,7 +211,8 @@ class RoomTasksDataSourceTest {
|
||||
@Test
|
||||
fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
// Overdue occurrences before it, so it is not the first and forks an override.
|
||||
makeRecurring(id, now - 14.days)
|
||||
val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } }
|
||||
val before = open()
|
||||
val target = before.first { it.distanceFromCurrent == 0 }
|
||||
@@ -237,7 +238,8 @@ class RoomTasksDataSourceTest {
|
||||
@Test
|
||||
fun reopeningACompletedOccurrenceReusesItsOverride() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
// Overdue occurrences before it, so it is not the first and forks an override.
|
||||
makeRecurring(id, now - 14.days)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.first { it.taskId == id && it.distanceFromCurrent == 0 }
|
||||
|
||||
@@ -254,7 +256,8 @@ class RoomTasksDataSourceTest {
|
||||
@Test
|
||||
fun cancellingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
// Overdue occurrences before it, so it is not the first and forks an override.
|
||||
makeRecurring(id, now - 14.days)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.first { it.taskId == id && it.distanceFromCurrent == 0 }
|
||||
|
||||
@@ -269,6 +272,621 @@ class RoomTasksDataSourceTest {
|
||||
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingTheFirstOccurrenceMovesTheSeriesOnAndKeepsACopy() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=4")
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
val master = db.tasks().entity(id)!!
|
||||
assertThat(master.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(master.dtstart).isEqualTo(at + 7.days)
|
||||
assertThat(master.due).isEqualTo(at + 8.days)
|
||||
assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;COUNT=3")
|
||||
assertThat(master.isDirty).isTrue()
|
||||
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||
assertThat(copy.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(copy.uid).isNotEqualTo(master.uid)
|
||||
assertThat(copy.rrule).isNull()
|
||||
assertThat(copy.masterId).isNull()
|
||||
assertThat(copy.dtstart).isEqualTo(at)
|
||||
assertThat(copy.title).isEqualTo("Water the plants")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningTheOccurrenceMovesTheSeriesBack() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=4")
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
val master = db.tasks().entity(id)!!
|
||||
assertThat(master.dtstart).isEqualTo(at)
|
||||
assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;COUNT=4")
|
||||
assertThat(db.tasks().allIn(listId).filter { !it.isDeleted }.map { it.id }).containsExactly(id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingAnEditedFirstOccurrenceKeepsItsEdits() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, at)
|
||||
source.updateInstance(id, at, TaskForm(title = "Water the cactus", listId = listId, start = at, due = at + 1.days), reminders = null)
|
||||
val override = db.tasks().override(id, at)!!
|
||||
|
||||
source.setCompletedInstance(override.id, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||
assertThat(copy.title).isEqualTo("Water the cactus")
|
||||
assertThat(copy.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingTheLastOccurrenceClosesTheSeries() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=1")
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(db.tasks().allIn(listId).map { it.id }).containsExactly(id)
|
||||
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancellingTheFirstOccurrenceMovesTheSeriesOn() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, at)
|
||||
|
||||
source.setCancelledInstance(id, at, cancelled = true)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||
assertThat(db.tasks().allIn(listId).single { it.id != id }.status).isEqualTo(TaskStatus.CANCELLED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSeriesWhoseStartWasDeletedMovesPastTheTickedOne() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.deleteOccurrence(id, at)
|
||||
|
||||
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countSpendsExcludedOccurrencesAndReopeningRestoresThem() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Daily"))
|
||||
makeRecurring(id, at, rule = "FREQ=DAILY;COUNT=4")
|
||||
source.deleteOccurrence(id, at + 1.days)
|
||||
val before = db.tasks().entity(id)!!
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
val moved = db.tasks().entity(id)!!
|
||||
assertThat(moved.dtstart).isEqualTo(at + 2.days)
|
||||
assertThat(moved.rrule).isEqualTo("FREQ=DAILY;COUNT=2")
|
||||
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
val back = db.tasks().entity(id)!!
|
||||
assertThat(back.dtstart).isEqualTo(before.dtstart)
|
||||
assertThat(back.rrule).isEqualTo(before.rrule)
|
||||
assertThat(back.exdate).isEqualTo(before.exdate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningTheCopyItselfMovesTheSeriesBack() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||
|
||||
source.setCompleted(copy.id, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
assertThat(db.tasks().allIn(listId).filter { !it.isDeleted }.map { it.id }).containsExactly(id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun undoingAnEditedOccurrenceBringsItsOverrideBack() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.updateInstance(id, at, TaskForm(title = "Just this once", listId = listId, start = at, due = at + 1.days), reminders = null)
|
||||
val override = db.tasks().override(id, at)!!
|
||||
source.setCompletedInstance(override.id, at, completed = true)
|
||||
|
||||
source.setCompletedInstance(override.id, at, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
val back = db.tasks().override(id, at)!!
|
||||
assertThat(back.title).isEqualTo("Just this once")
|
||||
assertThat(back.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStaleTickOfAnOccurrenceMovedPastForksNothing() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun occurrencesClosedBeforeTheUpdateNoLongerHoldTheSeriesBack() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
val master = db.tasks().entity(id)!!
|
||||
db.tasks().insert(
|
||||
master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED, completedAt = at),
|
||||
)
|
||||
|
||||
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
|
||||
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||
assertThat(db.tasks().allIn(listId).count { it.id != id && it.status == TaskStatus.COMPLETED }).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTickedOccurrenceStillResolvesToItsCopy() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
val occurrence = source.task(id, at)!!.occurrenceStart!!
|
||||
|
||||
source.setCompletedInstance(id, occurrence, completed = true)
|
||||
|
||||
val found = source.task(id, occurrence)!!
|
||||
assertThat(found.taskId).isNotEqualTo(id)
|
||||
assertThat(found.isCompleted).isTrue()
|
||||
assertThat(found.occurrenceStart).isEqualTo(occurrence)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theNextOccurrenceStartsWithoutTheLastOnesProgress() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
db.tasks().update(db.tasks().entity(id)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 60))
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
val master = db.tasks().entity(id)!!
|
||||
assertThat(master.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(master.percentComplete).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCopyOfAnEditedOccurrenceKeepsItsOwnProperties() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
val master = db.tasks().entity(id)!!
|
||||
val override = db.tasks().insert(
|
||||
master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, unknownProperties = "COMMENT:only this one"),
|
||||
)
|
||||
|
||||
source.setCompletedInstance(override, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(override)!!.unknownProperties!!.lines()).contains("COMMENT:only this one")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRuleWithAGapOfMoreThanTenYearsKeepsGoing() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Rare"))
|
||||
makeRecurring(id, at, rule = "FREQ=YEARLY;INTERVAL=11")
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
val master = db.tasks().entity(id)!!
|
||||
assertThat(master.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(master.dtstart!!).isGreaterThan(at + (366 * 10).days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningKeepsWhatChangedOnTheSeriesSince() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
source.deleteOccurrence(id, at + 28.days)
|
||||
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
val master = db.tasks().entity(id)!!
|
||||
assertThat(master.dtstart).isEqualTo(at)
|
||||
assertThat(master.exdate).isNotNull()
|
||||
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart?.epochSeconds })
|
||||
.doesNotContain((at + 28.days).epochSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSeriesWithExtraDatesKeepsTheOverrideModel() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
db.tasks().update(db.tasks().entity(id)!!.copy(rdate = "20991231T090000Z"))
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
assertThat(db.tasks().overrides(id).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeletedSeriesIsNotBroughtBackByItsCopy() {
|
||||
val at = now
|
||||
val work = syncedList()
|
||||
val id = source.insertTask(form(title = "Weekly").copy(listId = work))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
val copy = db.tasks().allIn(work).single { it.id != id }
|
||||
source.deleteWithSubtasks(id)
|
||||
|
||||
source.setCompleted(copy.id, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSeriesEndedOnItsLastOpenOccurrenceReopens() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Three"))
|
||||
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=3")
|
||||
val master = db.tasks().entity(id)!!
|
||||
listOf(at, at + 7.days).forEach { done ->
|
||||
db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = done, rrule = null, status = TaskStatus.COMPLETED))
|
||||
}
|
||||
|
||||
source.setCompletedInstance(id, at + 14.days, completed = true)
|
||||
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
// Nothing to move past: the closed occurrences stay the series' own.
|
||||
assertThat(db.tasks().overrides(id)).hasSize(2)
|
||||
|
||||
source.setCompletedInstance(id, at + 14.days, completed = false)
|
||||
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningAnOlderCopyReopensItOnItsOwn() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
|
||||
val reopened = db.tasks().allIn(listId).single { it.id != id && it.dtstart == at }
|
||||
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(db.advancedOccurrences().forCopy(reopened.id)!!.undoable).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSeriesMovesPastOccurrencesAlreadyClosedAfterIt() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
val master = db.tasks().entity(id)!!
|
||||
db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at + 7.days, rrule = null, status = TaskStatus.COMPLETED))
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
|
||||
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDetachedOccurrenceDeletedSinceStaysExcluded() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
val master = db.tasks().entity(id)!!
|
||||
val earlier = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED))
|
||||
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||
source.deleteTask(earlier)
|
||||
|
||||
source.setCompletedInstance(id, at + 7.days, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart?.epochSeconds })
|
||||
.doesNotContain(at.epochSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNestedComponentKeepsItsOwnUid() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
val master = db.tasks().entity(id)!!
|
||||
val override = db.tasks().insert(
|
||||
master.copy(
|
||||
id = 0, masterId = id, recurrenceId = at, rrule = null,
|
||||
unknownProperties = "BEGIN:VALARM\r\nUID:alarm-1\r\nACTION:AUDIO\r\nEND:VALARM",
|
||||
),
|
||||
)
|
||||
|
||||
source.setCompletedInstance(override, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(override)!!.unknownProperties!!.lines()).contains("UID:alarm-1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningTheCopyInTheEditorMovesTheSeriesBack() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||
|
||||
source.updateTask(
|
||||
copy.id,
|
||||
TaskForm(title = copy.title.orEmpty(), listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 0),
|
||||
reminders = null,
|
||||
)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEditedCopyReopensAsATaskOfItsOwn() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||
db.tasks().update(copy.copy(description = "notes"))
|
||||
source.updateTask(copy.id, TaskForm(title = "Renamed", listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 100), null)
|
||||
|
||||
source.setCompleted(copy.id, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||
assertThat(db.tasks().entity(copy.id)!!.title).isEqualTo("Renamed")
|
||||
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCopyWithASubtaskReopensOnItsOwn() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||
source.insertTask(form(title = "Part").copy(parentId = copy.id))
|
||||
|
||||
source.setCompleted(copy.id, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRecurringSubtaskKeepsTheOverrideModel() {
|
||||
val at = now
|
||||
val parent = source.insertTask(form(title = "Parent"))
|
||||
val id = source.insertTask(form(title = "Daily part").copy(parentId = parent))
|
||||
makeRecurring(id, at, rule = "FREQ=DAILY")
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
assertThat(db.tasks().overrides(id)).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSeriesReshapedSinceIsNotMovedBack() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Ten"))
|
||||
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=10")
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
db.tasks().update(db.tasks().entity(id)!!.copy(rrule = "FREQ=DAILY;COUNT=5"))
|
||||
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||
assertThat(db.tasks().entity(id)!!.rrule).isEqualTo("FREQ=DAILY;COUNT=5")
|
||||
assertThat(db.tasks().allIn(listId).single { it.id != id }.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tickingTheSameOccurrenceAgainKeepsOneRecord() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
assertThat(db.advancedOccurrences().forMaster(id)).hasSize(1)
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEditedCopyStillStandsForItsOccurrence() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||
|
||||
db.tasks().update(copy.copy(title = "Watered"))
|
||||
|
||||
assertThat(source.task(id, at)!!.taskId).isEqualTo(copy.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDetachedOccurrenceWithALifeOfItsOwnStaysOut() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
val master = db.tasks().entity(id)!!
|
||||
val earlier = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED))
|
||||
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||
source.insertTask(form(title = "Follow-up").copy(parentId = earlier))
|
||||
|
||||
source.setCompletedInstance(id, at + 7.days, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(earlier)!!.masterId).isNull()
|
||||
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart?.epochSeconds })
|
||||
.doesNotContain(at.epochSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningAnotherOverrideOfAFinishedSeriesLeavesItsLastOccurrenceClosed() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Three"))
|
||||
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=3")
|
||||
val master = db.tasks().entity(id)!!
|
||||
val last = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at + 14.days, rrule = null, status = TaskStatus.COMPLETED))
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
|
||||
source.setCompletedInstance(last, at + 14.days, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(last)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(source.task(id, at + 7.days)!!.isClosed).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningTheCopyWithNewRemindersKeepsThem() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||
|
||||
source.updateTask(
|
||||
copy.id,
|
||||
TaskForm(title = copy.title.orEmpty(), listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 0),
|
||||
reminders = listOf(TaskReminder(minutesBefore = 30)),
|
||||
)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(db.alarms().forTask(copy.id)).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDetachedOccurrenceReopenedSinceStaysOut() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
val master = db.tasks().entity(id)!!
|
||||
val earlier = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED))
|
||||
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||
source.setCompleted(earlier, completed = false)
|
||||
|
||||
source.setCompletedInstance(id, at + 7.days, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
assertThat(db.tasks().entity(earlier)!!.masterId).isNull()
|
||||
assertThat(source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }.map { it.occurrenceStart?.epochSeconds })
|
||||
.doesNotContain(at.epochSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOccurrenceWhoseCopyIsGoneReadsAsNothing() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
source.deleteTask(db.tasks().allIn(listId).single { it.id != id }.id)
|
||||
|
||||
assertThat(source.task(id, at)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun catchingUpOnAnOverdueSeriesKeepsEachTickUndoable() {
|
||||
val at = now - 56.days
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
val first = db.tasks().allIn(listId).single { it.id != id }
|
||||
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||
|
||||
assertThat(db.advancedOccurrences().forCopy(first.id)).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRuleTheServerReorderedStillMovesBack() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Ten"))
|
||||
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=10")
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
db.tasks().update(db.tasks().entity(id)!!.copy(rrule = "count=9;FREQ=WEEKLY"))
|
||||
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningWithProgressKeepsIt() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||
|
||||
source.updateTask(
|
||||
copy.id,
|
||||
TaskForm(title = copy.title.orEmpty(), listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 40),
|
||||
reminders = null,
|
||||
)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
assertThat(db.tasks().override(id, at)!!.percentComplete).isEqualTo(40)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theEditorsTidyingDoesNotCountAsAnEdit() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
db.tasks().update(db.tasks().entity(id)!!.copy(description = "From the server\n"))
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||
|
||||
source.updateTask(
|
||||
copy.id,
|
||||
TaskForm(title = copy.title.orEmpty(), listId = listId, description = copy.description, start = copy.dtstart, due = copy.due, percentComplete = 0),
|
||||
reminders = null,
|
||||
)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() {
|
||||
val id = source.insertTask(form(title = "Buy milk", due = now + 1.days))
|
||||
@@ -657,10 +1275,12 @@ class RoomTasksDataSourceTest {
|
||||
val outOfWindow = db.tasks().insert(
|
||||
TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent),
|
||||
)
|
||||
val at = now
|
||||
val series = db.tasks().insert(
|
||||
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = now, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
|
||||
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = at, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
|
||||
)
|
||||
source.setCompletedInstance(series, now, true)
|
||||
// Not the first occurrence, so it forks an override rather than a copy.
|
||||
source.setCompletedInstance(series, at + 1.days, true)
|
||||
val plain = source.insertTask(form("plain").copy(parentId = parent))
|
||||
|
||||
assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain)
|
||||
|
||||
+12
@@ -162,6 +162,18 @@ class TasksDatabaseMigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addsTheAdvancedOccurrencesTable() {
|
||||
helper.createDatabase(TEST_DB, 4).close()
|
||||
|
||||
helper.runMigrationsAndValidate(TEST_DB, 5, true, TasksDatabase.MIGRATION_4_5).use { db ->
|
||||
db.query("SELECT COUNT(*) FROM advanced_occurrences").use { c ->
|
||||
assertThat(c.moveToFirst()).isTrue()
|
||||
assertThat(c.getInt(0)).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||
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 org.junit.After
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** A server version replacing a moved series takes the moves' copies with it (#137). */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RoomSyncStoreAdvancesTest {
|
||||
|
||||
private val db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext<Context>(), TasksDatabase::class.java)
|
||||
.allowMainThreadQueries().build()
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
@Test
|
||||
fun droppingAdvancesDeletesTheirCopies() {
|
||||
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
|
||||
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
|
||||
val series = db.tasks().insert(TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY"))
|
||||
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
|
||||
val copy = db.tasks().allIn(list).single { it.id != series }
|
||||
db.tasks().update(copy.copy(href = "copy.ics"))
|
||||
|
||||
RoomSyncStore(db).dropAdvances(series)
|
||||
|
||||
assertThat(db.advancedOccurrences().forMaster(series)).isEmpty()
|
||||
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movesTheServerAlreadyHasStay() {
|
||||
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
|
||||
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
|
||||
val series = db.tasks().insert(TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY"))
|
||||
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
|
||||
val copy = db.tasks().allIn(list).single { it.id != series }
|
||||
val store = RoomSyncStore(db)
|
||||
store.markSynced(listOf(db.tasks().entity(series)!!), "s.ics", "1")
|
||||
|
||||
store.dropAdvances(series)
|
||||
|
||||
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCopyGivenASubtaskSurvivesTheServerWinning() {
|
||||
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
|
||||
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
|
||||
val series = db.tasks().insert(TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY"))
|
||||
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
|
||||
val copy = db.tasks().allIn(list).single { it.id != series }
|
||||
db.tasks().insert(TaskEntity(listId = list, uid = "p", title = "Part", parentId = copy.id))
|
||||
|
||||
RoomSyncStore(db).dropAdvances(series)
|
||||
|
||||
assertThat(db.tasks().entity(copy.id)).isNotNull()
|
||||
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTickDuringTheUploadIsNotSettled() {
|
||||
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
|
||||
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
|
||||
val series = db.tasks().insert(
|
||||
TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY", lastModified = at - 1.seconds),
|
||||
)
|
||||
val uploaded = db.tasks().entity(series)!!
|
||||
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
|
||||
val store = RoomSyncStore(db)
|
||||
|
||||
store.markSynced(listOf(uploaded), "s.ics", "1")
|
||||
|
||||
assertThat(db.tasks().entity(series)!!.isDirty).isTrue()
|
||||
assertThat(db.advancedOccurrences().forMaster(series).single().settled).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -609,7 +609,7 @@ class CollectionSyncer(
|
||||
// via `master_id`.
|
||||
val (buried, kept) = local.rows.partition { it.isDeleted && it.recurrenceId != null }
|
||||
if (buried.isNotEmpty()) store.deleteAll(buried.map { it.id })
|
||||
store.markSynced(kept.map { it.id }, key, eTag)
|
||||
store.markSynced(kept, key, eTag)
|
||||
// Both, because a resource that failed as a create was counted under
|
||||
// its UID and is now counted under its href. Clearing one would leak
|
||||
// the other into the store forever.
|
||||
@@ -825,6 +825,7 @@ class CollectionSyncer(
|
||||
}
|
||||
|
||||
pendingDiscard.remove(href)?.let { cause ->
|
||||
store.dropAdvances(masterId)
|
||||
report = report.copy(
|
||||
discardedEdits = report.discardedEdits +
|
||||
DiscardedEdit(uid, losing?.title, cause),
|
||||
|
||||
@@ -3,6 +3,9 @@ package de.jeanlucmakiola.agendula.data.sync
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.recurrenceAnchor
|
||||
import de.jeanlucmakiola.agendula.data.tasks.wholeSecond
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -42,12 +45,21 @@ interface SyncStore {
|
||||
fun deleteAll(taskIds: List<Long>)
|
||||
|
||||
/**
|
||||
* Records href and ETag on a resource's rows, clearing `is_dirty`.
|
||||
* Forgets how ticking occurrences moved [masterId]'s series, and deletes the
|
||||
* copies that left: the server's version replaced the moved series, and its
|
||||
* own overrides stand for those occurrences again. A row given a life of its
|
||||
* own since stays.
|
||||
*/
|
||||
fun dropAdvances(masterId: Long) = Unit
|
||||
|
||||
/**
|
||||
* Records href and ETag on a resource's [rows], as they were serialised,
|
||||
* clearing `is_dirty` on those that did not change since.
|
||||
*
|
||||
* The caller excludes any row it left out of the body — that row is deleted,
|
||||
* not marked synced.
|
||||
*/
|
||||
fun markSynced(taskIds: List<Long>, href: String?, eTag: String?)
|
||||
fun markSynced(rows: List<TaskEntity>, href: String?, eTag: String?)
|
||||
|
||||
fun setParent(taskId: Long, parentId: Long?)
|
||||
|
||||
@@ -97,8 +109,42 @@ class RoomSyncStore @Inject constructor(
|
||||
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
|
||||
}
|
||||
|
||||
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
|
||||
if (taskIds.isNotEmpty()) database.tasks().markSynced(taskIds, href, eTag)
|
||||
override fun dropAdvances(masterId: Long) = database.runInTransaction {
|
||||
val advanced = database.advancedOccurrences()
|
||||
val tasks = database.tasks()
|
||||
val master = tasks.entity(masterId)
|
||||
// Only moves the server never saw, on copies that are still just that.
|
||||
advanced.forMaster(masterId).filter { !it.settled && it.undoable }.forEach { record ->
|
||||
// An edited detached row was unlinked already, so only untouched ones are named.
|
||||
val rows = advanced.detachedFor(record.copyId).mapNotNull { it.taskId } + record.copyId
|
||||
advanced.delete(record.copyId)
|
||||
rows.mapNotNull { tasks.entity(it) }
|
||||
.filter { row ->
|
||||
!row.isDeleted && tasks.subtasks(row.id).isEmpty() &&
|
||||
(master == null || row.listId == master.listId && row.parentId == master.parentId)
|
||||
}
|
||||
.forEach { row ->
|
||||
// Never uploaded: nobody to tell. Uploaded: a tombstone the next run deletes.
|
||||
if (row.href == null) tasks.delete(row.id)
|
||||
else tasks.update(row.copy(isDeleted = true, isDirty = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun markSynced(rows: List<TaskEntity>, href: String?, eTag: String?) {
|
||||
if (rows.isEmpty()) return
|
||||
database.runInTransaction {
|
||||
database.tasks().markSynced(rows.map { it.id }, href, eTag)
|
||||
// Changed while the body was on its way, by a tick or an edit: still owed to the server.
|
||||
rows.forEach { database.tasks().dirtyIfChangedSince(it.id, it.lastModified) }
|
||||
// The server now holds each series as far as the body moved it; a tick
|
||||
// since is still the device's own.
|
||||
rows.filter { it.recurrenceId == null }.forEach { row ->
|
||||
val anchor = row.recurrenceAnchor
|
||||
if (anchor == null) database.advancedOccurrences().settle(listOf(row.id))
|
||||
else database.advancedOccurrences().settleUpTo(row.id, wholeSecond(anchor) + 1.seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setParent(taskId: Long, parentId: Long?) {
|
||||
|
||||
@@ -56,8 +56,8 @@ class SyncWorker @AssistedInject constructor(
|
||||
// log line is the same as saying nothing. Only what is new: the
|
||||
// store has already dropped whatever the user has been told.
|
||||
noticeNotifier.post(accountName, outcome.notices)
|
||||
// Nothing else re-arms reminders for what a sync pulled in: in our
|
||||
// own store no provider broadcast fires.
|
||||
// Here rather than left to ReminderRefresher's debounce: once
|
||||
// the worker returns, nothing keeps the process alive for it.
|
||||
if (outcome.reports.any { it.hadWork || it.discardedEdits.isNotEmpty() }) {
|
||||
runCatching { reminderScheduler.sync() }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
@@ -65,15 +66,12 @@ class AgendulaApp : Application(), Configuration.Provider {
|
||||
// v0.3.x install's tasks, both before anything reads a store.
|
||||
val startupGate = entryPoint.startupGate()
|
||||
val scope = entryPoint.applicationScope()
|
||||
// An alarm is armed off whichever store was active when it was scheduled,
|
||||
// so a switch has to rebuild the set. Armed only once startup's own
|
||||
// null -> stored transition is past, which the launch sync below covers.
|
||||
// A switch rebuilds the alarms through ReminderRefresher, which hears it
|
||||
// as a store change. Channels only once startup's own null -> stored
|
||||
// transition is past, which the launch sync below covers.
|
||||
val started = AtomicBoolean(false)
|
||||
entryPoint.providerResolver().onModeChanged {
|
||||
if (started.get()) {
|
||||
scope.launch { runCatching { scheduler.sync() } }
|
||||
runCatching { entryPoint.taskNotifier().refreshChannel() }
|
||||
}
|
||||
if (started.get()) runCatching { entryPoint.taskNotifier().refreshChannel() }
|
||||
}
|
||||
startupGate.start()
|
||||
refreshNotificationChannels()
|
||||
@@ -88,6 +86,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
||||
scheduler.sync()
|
||||
}
|
||||
runCatching { entryPoint.taskWidgetUpdater().start() }
|
||||
runCatching { entryPoint.reminderRefresher().start() }
|
||||
runCatching { entryPoint.providerSnapshotSource().start() }
|
||||
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
|
||||
}
|
||||
@@ -124,6 +123,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
||||
fun taskNotifier(): TaskNotifier
|
||||
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
|
||||
fun taskWidgetUpdater(): TaskWidgetUpdater
|
||||
fun reminderRefresher(): ReminderRefresher
|
||||
fun providerSnapshotSource(): ProviderSnapshotSource
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.net.toUri
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
|
||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||
@@ -33,9 +34,12 @@ import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
|
||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||
import de.jeanlucmakiola.agendula.ui.theme.SystemTheme
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -49,6 +53,8 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
|
||||
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
|
||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||
// over the app on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -72,6 +78,13 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
AppShortcuts.publish(this)
|
||||
|
||||
// The stored setting, never the view model's placeholder: a night mode
|
||||
// that flips back and forth would recreate the activity each time.
|
||||
lifecycleScope.launch {
|
||||
settingsPrefs.settings.map { it.themeMode to it.dynamicColor }.distinctUntilChanged()
|
||||
.collect { (mode, dynamicColor) -> SystemTheme.apply(this@MainActivity, mode, dynamicColor) }
|
||||
}
|
||||
|
||||
// Only on a fresh launch: after a rotation or process restore the intent
|
||||
// is the same old one and the back stack already holds its destination.
|
||||
val fromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
|
||||
@@ -155,6 +168,7 @@ class MainActivity : ComponentActivity() {
|
||||
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
||||
private const val EXTRA_RETURN_HOME = "de.jeanlucmakiola.agendula.extra.RETURN_HOME"
|
||||
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
||||
private const val EXTRA_DUE_TODAY = "de.jeanlucmakiola.agendula.extra.DUE_TODAY"
|
||||
private const val SHARED_TITLE_LIMIT = 500
|
||||
private const val NO_OCCURRENCE = -1L
|
||||
|
||||
@@ -183,13 +197,17 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
/**
|
||||
* The launcher shortcut's, Quick Settings tile's and a widget's "New
|
||||
* task". [listId] preset the list when it names exactly one; the data
|
||||
* URI keeps each widget's PendingIntent apart from the others.
|
||||
* task". [listId] preset the list when it names exactly one, [dueToday]
|
||||
* the due date for a Today widget; the data URI keeps each widget's
|
||||
* PendingIntent apart from the others.
|
||||
*/
|
||||
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
|
||||
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
|
||||
fun newTaskIntent(context: Context, listId: Long? = null, dueToday: Boolean = false): Intent {
|
||||
val path = listOfNotNull(listId?.toString(), "today".takeIf { dueToday }).joinToString("/")
|
||||
return Intent(ACTION_NEW_TASK, path.ifEmpty { null }?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
|
||||
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
|
||||
.putExtra(EXTRA_DUE_TODAY, dueToday)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
fun todayIntent(context: Context): Intent =
|
||||
Intent(ACTION_TODAY, null, context, MainActivity::class.java)
|
||||
@@ -223,6 +241,7 @@ class MainActivity : ComponentActivity() {
|
||||
when (intent.action) {
|
||||
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
||||
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
||||
dueToday = intent.getBooleanExtra(EXTRA_DUE_TODAY, false),
|
||||
returnHome = true,
|
||||
)
|
||||
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
||||
|
||||
@@ -89,7 +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, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4)
|
||||
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4, TasksDatabase.MIGRATION_4_5)
|
||||
.build()
|
||||
|
||||
/**
|
||||
|
||||
+47
-12
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
@@ -15,6 +16,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Fires when a task's reminder alarm goes off. Re-reads the task (it may have
|
||||
@@ -44,8 +46,7 @@ class DueReminderReceiver : BroadcastReceiver() {
|
||||
}
|
||||
val settings = settingsPrefs.settings.first()
|
||||
if (!settings.remindersEnabled) return@launch
|
||||
val task = runCatching { dataSource.occurrence(taskId, occurrence) }.getOrNull()
|
||||
if (task != null && !task.isClosed) notifier.postDue(task, settings)
|
||||
runCatching { notifier.postIfOpen(dataSource, taskId, occurrence, settings) }
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
@@ -74,17 +75,51 @@ class DueReminderReceiver : BroadcastReceiver() {
|
||||
/**
|
||||
* The occurrence of [taskId] anchored at [occurrenceStart] (epoch millis), or the
|
||||
* task's current one when there is no anchor or it can no longer be found.
|
||||
* [TasksDataSource.task] alone resolves a series to whichever occurrence is
|
||||
* current, which is not necessarily the one a reminder was armed for.
|
||||
*
|
||||
* ⚠️ A ticked or edited occurrence is a row of its own with its own id, so
|
||||
* matching list rows on [taskId] misses it; [TasksDataSource.task] with the
|
||||
* anchor finds it by start. The list scan is for a provider whose exception
|
||||
* rows that lookup does not reach.
|
||||
*/
|
||||
internal fun TasksDataSource.occurrence(taskId: Long, occurrenceStart: Long): Task? {
|
||||
val current = task(taskId) ?: return null
|
||||
if (occurrenceStart == ScheduledReminder.NO_OCCURRENCE ||
|
||||
current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
|
||||
) {
|
||||
return current
|
||||
val anchor = occurrenceStart.takeIf { it != ScheduledReminder.NO_OCCURRENCE }?.let(Instant::fromEpochMilliseconds)
|
||||
val found = task(taskId, anchor) ?: return null
|
||||
if (anchor == null || found.occurrenceStart == anchor) return found
|
||||
return tasks(TaskQuery(listId = found.listId, includeCompleted = true))
|
||||
.firstOrNull { (it.taskId == taskId || it.seriesId == taskId) && it.occurrenceStart == anchor }
|
||||
?: found
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a reminder posted for [occurrenceStart] is done with, given [current]
|
||||
* as [occurrence] resolved it.
|
||||
*
|
||||
* - No task: gone, but only when [missingMeansGone] — a provider that fails a
|
||||
* read hands back nothing rather than throwing.
|
||||
* - [occurrenceStart] null: posted before reminders recorded their occurrence,
|
||||
* so only a task that does not recur can be judged.
|
||||
* - A lookup that fell back to another occurrence says nothing about the
|
||||
* reminded one, so it stays.
|
||||
*/
|
||||
internal fun reminderIsStale(occurrenceStart: Long?, current: Task?, missingMeansGone: Boolean): Boolean {
|
||||
if (current == null) return missingMeansGone
|
||||
val same = when (occurrenceStart) {
|
||||
null -> current.occurrenceStart == null
|
||||
ScheduledReminder.NO_OCCURRENCE -> true
|
||||
else -> current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
|
||||
}
|
||||
return tasks(TaskQuery(listId = current.listId, includeCompleted = true))
|
||||
.firstOrNull { it.taskId == taskId && it.occurrenceStart?.toEpochMilliseconds() == occurrenceStart }
|
||||
?: current
|
||||
return same && current.isClosed
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the reminder for [taskId]'s occurrence if it is still open, then checks
|
||||
* once more: a completion landing between the read and the post may already
|
||||
* have had its withdrawal pass, which ran before there was anything to withdraw.
|
||||
*/
|
||||
internal fun TaskNotifier.postIfOpen(dataSource: TasksDataSource, taskId: Long, occurrenceStart: Long, settings: Settings) {
|
||||
val task = dataSource.occurrence(taskId, occurrenceStart) ?: return
|
||||
if (task.isClosed) return
|
||||
postDue(task, settings)
|
||||
val now = runCatching { dataSource.occurrence(taskId, occurrenceStart) }.getOrElse { return }
|
||||
if (now == null || now.isClosed) cancel(taskId)
|
||||
}
|
||||
+1
-4
@@ -32,7 +32,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var notifier: TaskNotifier
|
||||
@Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler
|
||||
@Inject lateinit var reminderScheduler: ReminderScheduler
|
||||
@Inject lateinit var repository: TasksRepository
|
||||
@Inject lateinit var dataSource: TasksDataSource
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@@ -54,7 +53,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
val anchor = occurrence.takeIf { it != ScheduledReminder.NO_OCCURRENCE }
|
||||
?.let(Instant::fromEpochMilliseconds)
|
||||
repository.setCompleted(taskId, anchor, completed = true)
|
||||
reminderScheduler.sync()
|
||||
}
|
||||
|
||||
ACTION_SNOOZE -> runCatching {
|
||||
@@ -67,8 +65,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
val settings = settingsPrefs.settings.first()
|
||||
if (!settings.remindersEnabled) return@runCatching
|
||||
startupGate.awaitReady()
|
||||
val task = dataSource.occurrence(taskId, occurrence)
|
||||
if (task != null && !task.isClosed) notifier.postDue(task, settings)
|
||||
notifier.postIfOpen(dataSource, taskId, occurrence, settings)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -21,7 +21,9 @@ internal data class ReminderDiff(
|
||||
fired: Set<ScheduledReminder> = emptySet(),
|
||||
isArmed: (ScheduledReminder) -> Boolean,
|
||||
): ReminderDiff {
|
||||
val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) }
|
||||
// A fired entry counts even once the store dropped it, so a task
|
||||
// reopened within the grace does not ring again.
|
||||
val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) } + fired
|
||||
return ReminderDiff(
|
||||
cancel = live - desired,
|
||||
arm = desired - live,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Keeps reminders in step with the store: any change to tasks, lists or alarms —
|
||||
* a tick in the list or on a widget, a sync pulling in a completion from another
|
||||
* device, an edit, a store switch — runs one debounced [ReminderScheduler.sync].
|
||||
* Started once from `AgendulaApp`, after the startup gate, so it observes the
|
||||
* right store.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderRefresher @Inject constructor(
|
||||
dataSource: TasksDataSource,
|
||||
scheduler: ReminderScheduler,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
) {
|
||||
private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerTaskObserver) { scheduler.sync() }
|
||||
|
||||
fun start() {
|
||||
changes.start()
|
||||
}
|
||||
|
||||
/** Re-sync soon, folded into any pass a store change already asked for. */
|
||||
fun requestRefresh() = changes.request()
|
||||
|
||||
private companion object {
|
||||
const val DEBOUNCE_MILLIS = 1_000L
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -23,8 +24,9 @@ import javax.inject.Singleton
|
||||
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
|
||||
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
|
||||
* and arms one exact [AlarmManager] alarm per reminder, within a rolling window. Re-run
|
||||
* on app start, on boot, on a store switch, and on an external provider change;
|
||||
* it diffs against [ScheduledReminderStore] so only changed alarms move.
|
||||
* on app start, on boot, on a store switch and on every change to the store (see
|
||||
* [ReminderRefresher]); it diffs against [ScheduledReminderStore] so only changed
|
||||
* alarms move, and takes down posted reminders whose task is done with.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderScheduler @Inject constructor(
|
||||
@@ -34,6 +36,7 @@ class ReminderScheduler @Inject constructor(
|
||||
private val store: ScheduledReminderStore,
|
||||
private val providerResolver: ProviderResolver,
|
||||
private val snoozeScheduler: ReminderSnoozeScheduler,
|
||||
private val notifier: TaskNotifier,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
private val syncLock = Mutex()
|
||||
@@ -65,19 +68,23 @@ class ReminderScheduler @Inject constructor(
|
||||
// Gate on whether the store is readable, not on whether a provider
|
||||
// resolves: our own store deliberately resolves to no provider, so the
|
||||
// latter clears every reminder in the default mode.
|
||||
if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
|
||||
val readable = providerResolver.canReadStore()
|
||||
if (!settings.remindersEnabled || !readable) {
|
||||
clearAll()
|
||||
// Switched off, a reminder already up still goes when its task does.
|
||||
if (readable) withdrawStale()
|
||||
return
|
||||
}
|
||||
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
||||
.getOrElse { return }
|
||||
// Per-task leads. One query for all of them.
|
||||
val perTask = runCatching { dataSource.reminders() }.getOrElse { emptyMap() }
|
||||
val now = System.currentTimeMillis()
|
||||
val desired = ReminderPlanner.plan(
|
||||
tasks = tasks,
|
||||
perTask = perTask,
|
||||
settings = settings,
|
||||
now = System.currentTimeMillis(),
|
||||
now = now,
|
||||
zone = ZoneId.systemDefault(),
|
||||
)
|
||||
|
||||
@@ -92,7 +99,10 @@ class ReminderScheduler @Inject constructor(
|
||||
|
||||
// ⚠️ The store survives reboot, force-stop and restore; the alarms don't.
|
||||
// Trusting it alone left every reminder after a reboot unarmed for good.
|
||||
val fired = store.fired()
|
||||
// ⚠️ Kept through the missed-reminder grace even once it drops out of
|
||||
// the plan: a tick undone a moment later would otherwise re-arm a
|
||||
// trigger in the past, and the reminder would ring a second time.
|
||||
val fired = store.fired().filterTo(HashSet()) { it in desired || it.triggerAt >= now - ReminderPlanner.MISSED_GRACE_MS }
|
||||
val diff = ReminderDiff.of(store.all(), desired, fired) { !afterReboot && !rearm && isArmed(it) }
|
||||
diff.cancel.forEach { cancel(it) }
|
||||
// ⚠️ Only what was actually armed. A throw mid-loop used to skip the
|
||||
@@ -100,7 +110,25 @@ class ReminderScheduler @Inject constructor(
|
||||
// — uncancellable, and firing for tasks that no longer exist — and the
|
||||
// exception escaped into BootReceiver's goAsync().
|
||||
val armed = diff.arm.filter { schedule(it) }
|
||||
store.replace(diff.keep + armed, fired = fired intersect desired, exact = canExact)
|
||||
store.replace(diff.keep + armed, fired = fired, exact = canExact)
|
||||
withdrawStale()
|
||||
}
|
||||
|
||||
/**
|
||||
* A reminder already on screen outlives its alarm: completing the task in
|
||||
* the list, on a widget or on another device has to take it down too.
|
||||
*/
|
||||
private fun withdrawStale() {
|
||||
// Only our own store tells a deleted task from a failed read.
|
||||
val missingMeansGone = providerResolver.mode() == StorageMode.OWN
|
||||
runCatching {
|
||||
notifier.withdrawStale { taskId, occurrence ->
|
||||
runCatching {
|
||||
val current = dataSource.occurrence(taskId, occurrence ?: ScheduledReminder.NO_OCCURRENCE)
|
||||
reminderIsStale(occurrence, current, missingMeansGone)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the system still holds this alarm's PendingIntent. */
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateFormat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
@@ -103,6 +104,11 @@ class TaskNotifier @Inject constructor(
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(tapIntent)
|
||||
.addExtras(
|
||||
Bundle().apply {
|
||||
putLong(EXTRA_OCCURRENCE, task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE)
|
||||
},
|
||||
)
|
||||
.addAction(0, context.getString(R.string.reminder_action_done), actionIntent(task, ReminderActionReceiver.ACTION_DONE))
|
||||
.addAction(
|
||||
0,
|
||||
@@ -117,6 +123,24 @@ class TaskNotifier @Inject constructor(
|
||||
NotificationManagerCompat.from(context).cancel(taskId.toString(), NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes down every posted reminder [isStale] says is done with — completed,
|
||||
* cancelled or deleted since it went off, here or on another device. The
|
||||
* occurrence is null for a reminder posted before it was recorded.
|
||||
*/
|
||||
fun withdrawStale(isStale: (taskId: Long, occurrenceStart: Long?) -> Boolean) {
|
||||
// Only the current store's: another store's task ids name other tasks.
|
||||
val prefix = "$LIST_CHANNEL_PREFIX${resolver.mode().name.lowercase()}_"
|
||||
manager.activeNotifications
|
||||
.filter { it.id == NOTIFICATION_ID && it.notification.channelId?.startsWith(prefix) == true }
|
||||
.forEach { posted ->
|
||||
val taskId = posted.tag?.toLongOrNull() ?: return@forEach
|
||||
val extras = posted.notification.extras
|
||||
val occurrence = if (extras.containsKey(EXTRA_OCCURRENCE)) extras.getLong(EXTRA_OCCURRENCE) else null
|
||||
if (isStale(taskId, occurrence)) cancel(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun actionIntent(task: Task, action: String): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
task.taskId.toInt(),
|
||||
@@ -190,6 +214,7 @@ class TaskNotifier @Inject constructor(
|
||||
private const val LEGACY_CHANNEL_ID = "task_reminders"
|
||||
private const val LIST_CHANNEL_PREFIX = "reminders_"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val EXTRA_OCCURRENCE = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
|
||||
private const val CHANNEL_PREFS = "reminder_channels"
|
||||
private const val KEY_LEGACY_IMPORTANCE = "legacy_importance"
|
||||
|
||||
|
||||
+436
-80
@@ -7,9 +7,12 @@ import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||
@@ -52,14 +55,47 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
// --- reads ----------------------------------------------------------------
|
||||
|
||||
override fun taskLists(): List<TaskList> {
|
||||
val uri = TasksContract.listsUri(authority())
|
||||
val sort = "${Lists.ACCOUNT_NAME}, ${Lists.NAME}"
|
||||
return resolver.query(uri, TaskProjections.LISTS, null, null, sort)?.use { c ->
|
||||
val authority = authority()
|
||||
val lists = resolver.query(TasksContract.listsUri(authority), TaskProjections.LISTS, null, null, null)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) add(TaskMapper.taskList(reader)) }
|
||||
} ?: emptyList()
|
||||
buildList<TaskList> { while (c.moveToNext()) add(TaskMapper.taskList(reader)) }
|
||||
} ?: return emptyList()
|
||||
val order = synchronized(listOrderLock) {
|
||||
val stored = storedListOrder(authority)
|
||||
val known = lists.mapTo(HashSet()) { it.id }
|
||||
// A list gone from the provider takes its position along, as its id may be
|
||||
// reused — but not on an empty answer, which is as likely a provider mid-update.
|
||||
if (lists.isNotEmpty() && stored.keys.any { it !in known }) {
|
||||
stored.filterKeys { it in known }.also { storeListOrder(authority, it) }
|
||||
} else {
|
||||
stored
|
||||
}
|
||||
}
|
||||
return ListOrder.sorted(lists, order)
|
||||
}
|
||||
|
||||
/** The order Agendula keeps for [authority]'s lists. */
|
||||
internal fun storedListOrder(authority: String = authority()): Map<Long, Int> =
|
||||
ListOrder.decode(listOrder.getString(authority, null))
|
||||
|
||||
private val listOrder get() = context.getSharedPreferences(LIST_ORDER, Context.MODE_PRIVATE)
|
||||
|
||||
private fun storeListOrder(authority: String, order: Map<Long, Int>) {
|
||||
listOrder.edit().putString(authority, ListOrder.encode(order)).commit()
|
||||
}
|
||||
|
||||
/** Kept by Agendula, as the provider's lists table has no order of its own. */
|
||||
override fun reorderLists(listIds: List<Long>) {
|
||||
val authority = authority()
|
||||
synchronized(listOrderLock) {
|
||||
storeListOrder(authority, ListOrder.reordered(taskLists().map { it.id }, listIds))
|
||||
}
|
||||
// Wakes the observers on the lists without asking any sync adapter to upload: nothing changed in the provider.
|
||||
runCatching { resolver.notifyChange(TasksContract.listsUri(authority), null, 0) }
|
||||
}
|
||||
|
||||
private val listOrderLock = Any()
|
||||
|
||||
override fun tasks(query: TaskQuery): List<Task> {
|
||||
val clauses = mutableListOf<String>()
|
||||
val args = mutableListOf<String>()
|
||||
@@ -73,14 +109,22 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
override fun task(taskId: Long): Task? {
|
||||
val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
|
||||
return rows.firstOrNull { it.distanceFromCurrent == 0 } ?: rows.firstOrNull()
|
||||
roundLegacyAnchors()
|
||||
val rows = instanceRows(resolver.query(TasksContract.instancesUri(authority()), null, "${Instances.TASK_ID} = ?", arrayOf(taskId.toString()), null))
|
||||
return (rows.firstOrNull { it.task.distanceFromCurrent == 0 } ?: rows.firstOrNull())?.let { enrich(listOf(it)).single() }
|
||||
}
|
||||
|
||||
override fun task(taskId: Long, occurrenceStart: Instant?): Task? {
|
||||
if (occurrenceStart == null) return task(taskId)
|
||||
val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
|
||||
return rows.firstOrNull { it.occurrenceStart == occurrenceStart } ?: task(taskId)
|
||||
roundLegacyAnchors()
|
||||
val rows = instanceRows(queryOccurrences(taskId))
|
||||
val chosen = rows.firstOrNull { it.anchor == occurrenceStart }
|
||||
?: rows.firstOrNull { it.anchor?.let { a -> sameSecond(a, occurrenceStart) } == true }
|
||||
if (chosen != null) return enrich(listOf(chosen)).single()
|
||||
tickedCopy(taskId, occurrenceStart)?.let { copy -> return task(copy)?.copy(occurrenceStart = occurrenceStart) }
|
||||
// A plain task has one occurrence, whatever it was addressed by. A series
|
||||
// answers null rather than another occurrence a write would then land on.
|
||||
return if (seriesRules(listOf(taskId)).isEmpty()) task(taskId) else null
|
||||
}
|
||||
|
||||
override fun subtasks(parentTaskId: Long): List<Task> =
|
||||
@@ -107,23 +151,74 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
/**
|
||||
* All columns and no sort: the instances view differs across provider
|
||||
* versions (tasks.org's has no `is_recurring`), the by-name mapper nulls what
|
||||
* is missing, and the repository re-sorts.
|
||||
*/
|
||||
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
|
||||
val uri = TasksContract.instancesUri(authority())
|
||||
// projection = null (all columns): the instances view differs across
|
||||
// provider versions (e.g. tasks.org's bundled OpenTasks has no
|
||||
// `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.
|
||||
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
|
||||
roundLegacyAnchors()
|
||||
return instanceTasks(resolver.query(TasksContract.instancesUri(authority()), null, selection, args, null))
|
||||
}
|
||||
|
||||
/** Maps [cursor]'s instance rows, with the rule each series keeps on its `tasks` row, and their tags. */
|
||||
private fun instanceTasks(cursor: Cursor?): List<Task> = enrich(instanceRows(cursor))
|
||||
|
||||
/** [cursor]'s instance rows, before their rules and tags are looked up. */
|
||||
private fun instanceRows(cursor: Cursor?): List<InstanceRow> {
|
||||
var flagged = false
|
||||
val rows = cursor?.use { c ->
|
||||
flagged = c.getColumnIndex(Instances.IS_RECURRING) >= 0
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) add(TaskMapper.instance(reader)) }
|
||||
}.orEmpty()
|
||||
// Where the provider flags recurrence, only flagged rows need their rule looked up.
|
||||
return if (flagged) rows.map { if (it.task.isRecurring) it else it.copy(ruleOwner = NO_RULE) } else rows
|
||||
}
|
||||
|
||||
/** [rows] completed with the rule each series keeps on its `tasks` row, and their tags. */
|
||||
private fun enrich(rows: List<InstanceRow>): List<Task> {
|
||||
if (rows.isEmpty()) return emptyList()
|
||||
val rules = seriesRules(rows.map { it.ruleOwner }.filter { it != NO_RULE })
|
||||
val tasks = rows.map { TaskMapper.withRule(it, rules[it.ruleOwner]) }
|
||||
val tags = categories(tasks.map { it.taskId })
|
||||
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
||||
}
|
||||
|
||||
/** The recurrence of each of [taskIds] that is a series, keyed by task id. */
|
||||
private fun seriesRules(taskIds: Collection<Long>): Map<Long, SeriesRule> = queryByIds(
|
||||
TasksContract.tasksUri(authority()),
|
||||
arrayOf(Tasks.ID, Tasks.RRULE, Tasks.RDATE),
|
||||
Tasks.ID,
|
||||
taskIds,
|
||||
filter = "${Tasks.RRULE} IS NOT NULL OR ${Tasks.RDATE} IS NOT NULL",
|
||||
) { r -> r.getLong(Tasks.ID)?.let { id -> TaskMapper.seriesRule(r)?.let { id to it } } }.toMap()
|
||||
|
||||
/**
|
||||
* Rows of [uri] whose [idColumn] is one of [ids], in chunks that stay under
|
||||
* SQLite's bound-argument limit, each mapped by [read] (null rows dropped).
|
||||
*/
|
||||
private fun <T : Any> queryByIds(
|
||||
uri: Uri,
|
||||
projection: Array<String>,
|
||||
idColumn: String,
|
||||
ids: Collection<Long>,
|
||||
filter: String? = null,
|
||||
filterArgs: List<String> = emptyList(),
|
||||
sortOrder: String? = null,
|
||||
read: (ColumnReader) -> T?,
|
||||
): List<T> = ids.distinct().chunked(MAX_SQL_ARGS - filterArgs.size).flatMap { chunk ->
|
||||
val selection = buildString {
|
||||
append("$idColumn IN (${chunk.joinToString(",") { "?" }})")
|
||||
if (filter != null) append(" AND ($filter)")
|
||||
}
|
||||
val args = (chunk.map(Long::toString) + filterArgs).toTypedArray()
|
||||
resolver.query(uri, projection, selection, args, sortOrder)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) read(reader)?.let(::add) }
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||
// projection = null for the same reason queryInstances uses it: the tasks
|
||||
// table's shape varies across provider versions, and the by-name mapper
|
||||
@@ -194,7 +289,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||
val occurrence = task(seriesId, occurrenceStart)
|
||||
val occurrence = occurrence(seriesId, occurrenceStart) { TaskMapper.instance(it).task } ?: task(seriesId)
|
||||
val anchors = resolver.query(
|
||||
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
|
||||
)?.use { c ->
|
||||
@@ -202,14 +297,14 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
val r = CursorColumnReader(c)
|
||||
r.getLong(Tasks.DTSTART) to r.getLong(Tasks.DUE)
|
||||
}
|
||||
fun shifted(anchor: Long?, was: Instant?, now: Instant?): Long? = when {
|
||||
now == null -> null
|
||||
anchor == null || was == null -> now.toEpochMilliseconds()
|
||||
else -> anchor + (now - was).inWholeMilliseconds
|
||||
}
|
||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) +
|
||||
(Tasks.DTSTART to shifted(anchors?.first, occurrence?.start, form.start)) +
|
||||
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
|
||||
val base = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||
// Without an anchor to shift, the form's own value stands — already pinned for an all-day series.
|
||||
fun shifted(column: String, anchor: Long?, was: Instant?, now: Instant?): Any? =
|
||||
if (now == null || anchor == null || was == null) base[column]
|
||||
else providerMillis(anchor + (now - was).inWholeMilliseconds)
|
||||
val values = base +
|
||||
(Tasks.DTSTART to shifted(Tasks.DTSTART, anchors?.first, occurrence?.start, form.start)) +
|
||||
(Tasks.DUE to shifted(Tasks.DUE, 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)
|
||||
@@ -253,9 +348,10 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
if (!c.moveToFirst()) return@use null
|
||||
val r = CursorColumnReader(c)
|
||||
val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return@use null
|
||||
val rule = TaskMapper.seriesRule(r)
|
||||
RecurrenceSpec(
|
||||
rrule = r.getString(Tasks.RRULE)?.ifBlank { null },
|
||||
rdate = r.getString(Tasks.RDATE),
|
||||
rrule = rule?.rrule,
|
||||
rdate = rule?.rdate,
|
||||
exdate = r.getString(Tasks.EXDATE),
|
||||
anchor = Instant.fromEpochMilliseconds(anchor),
|
||||
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
|
||||
@@ -290,7 +386,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
val original = r.getLong(Tasks.ORIGINAL_INSTANCE_TIME) ?: continue
|
||||
if (original >= from.toEpochMilliseconds()) r.getLong(Tasks.ID)?.let(::add)
|
||||
if (providerMillis(original) >= providerMillis(from.toEpochMilliseconds())) r.getLong(Tasks.ID)?.let(::add)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
@@ -302,7 +398,10 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||
if (reminders == null) return writeInstance(taskId, occurrenceStart, form)
|
||||
val hit = instanceFor(taskId, occurrenceStart)
|
||||
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
||||
hit.overrideId?.let { return updateOverride(it, form, reminders) }
|
||||
if (reminders == null) return writeInstance(taskId, hit.instanceId, form)
|
||||
// ⚠️ Written to the series, and then put back. Forking an occurrence
|
||||
// copies the task's *current* properties onto the new override row, so
|
||||
// setting the alarm beforehand is the only way to carry it across — and
|
||||
@@ -311,15 +410,26 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
val seriesReminders = alarmRows(taskId).map { it.second }
|
||||
writeReminders(taskId, reminders)
|
||||
try {
|
||||
writeInstance(taskId, occurrenceStart, form)
|
||||
writeInstance(taskId, hit.instanceId, form)
|
||||
} finally {
|
||||
setReminders(taskId, seriesReminders)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
||||
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
||||
/**
|
||||
* An occurrence that already has its own row is written there directly. The
|
||||
* fork-and-restore dance in [updateInstance] would put its old tags and
|
||||
* reminders back over the new ones.
|
||||
*/
|
||||
private fun updateOverride(overrideId: Long, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||
val rows = resolver.update(taskUri(authority(), overrideId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update override $overrideId")
|
||||
setCategories(overrideId, form.tags)
|
||||
writeReminders(overrideId, reminders)
|
||||
}
|
||||
|
||||
private fun writeInstance(taskId: Long, instanceId: Long, form: TaskForm) {
|
||||
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||
// The fork copies the series' properties onto the override, so the tags
|
||||
@@ -346,28 +456,147 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** An occurrence's instance row, and the override row behind it if it has one. */
|
||||
private class InstanceHit(val instanceId: Long, val overrideId: Long?)
|
||||
|
||||
/**
|
||||
* The provider's instance row id for one occurrence.
|
||||
* The provider's instance row for one occurrence.
|
||||
*
|
||||
* The seam addresses occurrences by `(taskId, occurrenceStart)`; writing
|
||||
* through the instances URI still needs the row id, so it is looked up here
|
||||
* rather than carried around above the data layer. Selection is on `task_id`
|
||||
* only — the anchor is matched in Kotlin because the column that holds it
|
||||
* (`instance_original_time`) is missing on older provider schemas, where a
|
||||
* WHERE clause naming it would throw instead of falling back.
|
||||
* rather than carried around above the data layer.
|
||||
*/
|
||||
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? {
|
||||
val uri = TasksContract.instancesUri(authority())
|
||||
val selection = "${Instances.TASK_ID} = ?"
|
||||
return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c ->
|
||||
private fun instanceFor(taskId: Long, occurrenceStart: Instant): InstanceHit? {
|
||||
roundLegacyAnchors()
|
||||
return occurrence(taskId, occurrenceStart) { r ->
|
||||
r.getLong(Tasks.ID)?.let { id ->
|
||||
InstanceHit(id, r.getLong(Instances.TASK_ID)?.takeIf { r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? = instanceFor(taskId, occurrenceStart)?.instanceId
|
||||
|
||||
/**
|
||||
* [read] applied to the instance row of [taskId]'s occurrence at
|
||||
* [occurrenceStart] — its own, or an override's that replaced it — matched in
|
||||
* whole seconds, as the provider expands them. The anchor is matched in
|
||||
* Kotlin because `instance_original_time` is missing on older schemas.
|
||||
*/
|
||||
private fun <T> occurrence(taskId: Long, occurrenceStart: Instant, read: (ColumnReader) -> T): T? =
|
||||
queryOccurrences(taskId)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
// An exact match first: a row of the same second is a fallback, not a peer.
|
||||
var near: Int? = null
|
||||
while (c.moveToNext()) {
|
||||
if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) {
|
||||
return@use reader.getLong(Tasks.ID)
|
||||
val anchor = TaskMapper.occurrenceAnchor(reader) ?: continue
|
||||
if (anchor == occurrenceStart) return@use read(reader)
|
||||
if (near == null && sameSecond(anchor, occurrenceStart)) near = c.position
|
||||
}
|
||||
near?.let { c.moveToPosition(it); read(reader) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance rows of [taskId] and of the overrides standing in for its
|
||||
* occurrences. A provider whose view lacks `original_instance_id` gets the
|
||||
* task's own rows only, rather than a failed query.
|
||||
*/
|
||||
private fun queryOccurrences(taskId: Long): Cursor? {
|
||||
val authority = authority()
|
||||
val uri = TasksContract.instancesUri(authority)
|
||||
val id = taskId.toString()
|
||||
if (ownRowsOnlyFor != authority) {
|
||||
try {
|
||||
return resolver.query(uri, null, OCCURRENCE_OF, arrayOf(id, id), null)
|
||||
} catch (e: SQLiteException) {
|
||||
rememberIfMissingColumn(authority, e)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
rememberIfMissingColumn(authority, e)
|
||||
}
|
||||
}
|
||||
return resolver.query(uri, null, "${Instances.TASK_ID} = ?", arrayOf(id), null)
|
||||
}
|
||||
|
||||
/** Only a view without the column is remembered; any other failure falls back for this query alone. */
|
||||
private fun rememberIfMissingColumn(authority: String, e: Exception) {
|
||||
val message = e.message.orEmpty()
|
||||
if (Tasks.ORIGINAL_INSTANCE_ID in message || "no such column" in message || "Invalid column" in message) {
|
||||
ownRowsOnlyFor = authority
|
||||
} else {
|
||||
Log.w(TAG, "Occurrence query failed, reading the task's own rows", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider whose instances view turned out to lack `original_instance_id`. */
|
||||
@Volatile
|
||||
private var ownRowsOnlyFor: String? = null
|
||||
|
||||
@Volatile
|
||||
private var anchorsRoundedFor: String? = null
|
||||
|
||||
private val roundingLock = Any()
|
||||
|
||||
/**
|
||||
* Rounds the DTSTART/DUE a build before [providerMillis] stored with
|
||||
* milliseconds, which the provider cannot move past a completed occurrence.
|
||||
* Once per provider, before the first read: concurrent readers wait for it.
|
||||
* Written as the rows' sync adapter, since the server only ever had whole
|
||||
* seconds, so nothing is dirtied or re-uploaded. Overrides need nothing: the
|
||||
* provider forks them at its own whole-second instance times.
|
||||
*
|
||||
* Never fails a read. A row the provider refuses is left as it was and tried
|
||||
* again on the next start; only a pass that rounded everything is remembered.
|
||||
*/
|
||||
private fun roundLegacyAnchors() {
|
||||
val authority = authority()
|
||||
if (anchorsRoundedFor == authority) return
|
||||
synchronized(roundingLock) {
|
||||
if (anchorsRoundedFor == authority) return
|
||||
val prefs = context.getSharedPreferences(MIGRATIONS, Context.MODE_PRIVATE)
|
||||
val key = "anchors_rounded:$authority"
|
||||
val done = prefs.getBoolean(key, false) || runCatching { roundAll(authority) }
|
||||
.onFailure { Log.w(TAG, "Rounding legacy anchors failed", it) }
|
||||
.getOrDefault(false)
|
||||
if (done) prefs.edit().putBoolean(key, true).apply()
|
||||
anchorsRoundedFor = authority
|
||||
}
|
||||
}
|
||||
|
||||
/** True when every stale row was rounded. */
|
||||
private fun roundAll(authority: String): Boolean {
|
||||
val columns = listOf(Tasks.DTSTART, Tasks.DUE)
|
||||
val ops = resolver.query(
|
||||
TasksContract.tasksUri(authority),
|
||||
arrayOf(Tasks.ID, Tasks.ACCOUNT_NAME, Tasks.ACCOUNT_TYPE) + columns,
|
||||
// Only series: the provider moves nothing else, so nothing else needs rounding.
|
||||
"(${columns.joinToString(" OR ") { "$it % 1000 != 0" }}) AND (${Tasks.RRULE} IS NOT NULL OR ${Tasks.RDATE} IS NOT NULL)",
|
||||
null,
|
||||
null,
|
||||
)?.use { c ->
|
||||
val r = CursorColumnReader(c)
|
||||
buildList<ContentProviderOperation> {
|
||||
while (c.moveToNext()) {
|
||||
val id = r.getLong(Tasks.ID) ?: continue
|
||||
val values = buildMap<String, Any?> {
|
||||
for (column in columns) {
|
||||
val millis = r.getLong(column) ?: continue
|
||||
if (providerMillis(millis) != millis) put(column, providerMillis(millis))
|
||||
}
|
||||
}
|
||||
val name = r.getString(Tasks.ACCOUNT_NAME)
|
||||
val type = r.getString(Tasks.ACCOUNT_TYPE)
|
||||
val uri = if (name.isNullOrEmpty() || type.isNullOrEmpty()) taskUri(authority, id)
|
||||
else TasksContract.asSyncAdapter(taskUri(authority, id), name, type)
|
||||
add(ContentProviderOperation.newUpdate(uri).withValues(values.toContentValues()).build())
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
} ?: return false
|
||||
if (ops.isEmpty()) return true
|
||||
val batch = runCatching { resolver.applyBatch(authority, ArrayList(ops)) }.getOrNull()
|
||||
if (batch != null) return batch.all { (it.count ?: 0) > 0 }
|
||||
// One refused row fails the whole batch; row by row, the rest still go.
|
||||
return ops.map { op -> runCatching { resolver.applyBatch(authority, arrayListOf(op)).single().count ?: 0 }.getOrDefault(0) > 0 }
|
||||
.all { it }
|
||||
}
|
||||
|
||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
|
||||
@@ -430,29 +659,19 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
/** 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) }
|
||||
}
|
||||
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> = queryByIds(
|
||||
TasksContract.propertiesUri(authority()),
|
||||
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
|
||||
Properties.TASK_ID,
|
||||
taskIds,
|
||||
filter = "${Properties.MIMETYPE} = ?",
|
||||
filterArgs = listOf(TasksContract.Category.MIMETYPE),
|
||||
sortOrder = Properties.PROPERTY_ID,
|
||||
) { r ->
|
||||
val id = r.getLong(Properties.TASK_ID)
|
||||
val name = r.getString(TasksContract.Category.NAME)
|
||||
if (id == null || name == null) null else id to name
|
||||
}.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
|
||||
|
||||
/**
|
||||
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
|
||||
@@ -482,16 +701,21 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
||||
if (!cancelled && movedBack(taskId, null)) return
|
||||
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
|
||||
}
|
||||
|
||||
/** Through the instances URI, like [setCompletedInstance]. */
|
||||
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) {
|
||||
if (!cancelled && movedBack(taskId, occurrenceStart)) return
|
||||
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
||||
?: return setCancelled(taskId, cancelled)
|
||||
?: return tickedCopy(taskId, occurrenceStart)?.let { setCancelled(it, cancelled) }
|
||||
?: plainUnlessSeries(taskId, occurrenceStart) { setCancelled(taskId, cancelled) }
|
||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||
val rows = resolver.update(uri, cancelValues(cancelled).toContentValues(), null, null)
|
||||
val rows = rememberingCopy(taskId, occurrenceStart, closing = cancelled) {
|
||||
resolver.update(uri, cancelValues(cancelled).toContentValues(), null, null)
|
||||
}
|
||||
if (rows == 0) throw TaskWriteFailedException("cancel instance $instanceId")
|
||||
}
|
||||
|
||||
@@ -501,6 +725,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
)
|
||||
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||
if (!completed && movedBack(taskId, null)) return
|
||||
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
|
||||
@@ -508,22 +733,133 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
|
||||
/**
|
||||
* Through the instances URI, which is what makes the provider fork an override
|
||||
* rather than close the series. No instance row for the anchor means the task
|
||||
* is not a series after all — the plain write is then the right one.
|
||||
* rather than close the series. No instance row for the anchor on a task that
|
||||
* is not a series means the plain write is the right one.
|
||||
*/
|
||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
|
||||
if (!completed && movedBack(taskId, occurrenceStart)) return
|
||||
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
||||
?: return setCompleted(taskId, completed)
|
||||
?: return tickedCopy(taskId, occurrenceStart)?.let { setCompleted(it, completed) }
|
||||
?: plainUnlessSeries(taskId, occurrenceStart) { setCompleted(taskId, completed) }
|
||||
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||
val rows = rememberingCopy(taskId, occurrenceStart, closing = completed) {
|
||||
resolver.update(uri, values.toContentValues(), null, null)
|
||||
}
|
||||
if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId")
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [write], and when it closed an occurrence the provider then detached
|
||||
* into a task of its own, remembers that copy, so the occurrence can still be
|
||||
* read and reopened, and how the series moved, so reopening can move it back.
|
||||
*/
|
||||
private fun rememberingCopy(taskId: Long, occurrenceStart: Instant, closing: Boolean, write: () -> Int): Int {
|
||||
if (!closing) return write()
|
||||
val authority = authority()
|
||||
val lastId = runCatching { lastTaskId(authority) }.getOrNull() ?: return write()
|
||||
val before = runCatching { seriesState(authority, taskId) }.getOrNull()
|
||||
return write().also { rows ->
|
||||
if (rows > 0) {
|
||||
runCatching {
|
||||
detachedSince(authority, lastId)?.let { copy ->
|
||||
val after = before?.let { seriesState(authority, taskId) }?.takeIf { it != before }
|
||||
val tick = Tick(copy, taskId.takeIf { after != null }, before.takeIf { after != null }, after, System.currentTimeMillis())
|
||||
ticks.put(ticks.key(authority, taskId, occurrenceStart), tick)
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Looking up the detached copy failed", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes the tick that left [taskId]'s occurrence at [occurrenceStart] — or,
|
||||
* with no occurrence, the tick that left the copy [taskId]: the series goes
|
||||
* back where it was and the copy goes, as long as the series is still where
|
||||
* the tick moved it and the copy has nothing filed under it. False when it
|
||||
* cannot, so the caller reopens the copy as a task of its own.
|
||||
*/
|
||||
private fun movedBack(taskId: Long, occurrenceStart: Instant?): Boolean = runCatching {
|
||||
val authority = authority()
|
||||
val (key, tick) = occurrenceStart?.let { at -> ticks.key(authority, taskId, at).let { k -> ticks.get(k)?.let { k to it } } }
|
||||
?: ticks.forCopy(authority, taskId)
|
||||
?: return false
|
||||
val series = tick.seriesId ?: return false
|
||||
val before = tick.before ?: return false
|
||||
if (seriesState(authority, series) != tick.after || subtaskIds(tick.copyId).isNotEmpty()) return false
|
||||
val restore = before.filter { (column, value) -> tick.after?.get(column) != value }
|
||||
val ops = arrayListOf(
|
||||
ContentProviderOperation.newUpdate(taskUri(authority, series)).withValues(restore.toContentValues()).build(),
|
||||
ContentProviderOperation.newDelete(taskUri(authority, tick.copyId)).build(),
|
||||
)
|
||||
resolver.applyBatch(authority, ops)
|
||||
ticks.remove(key)
|
||||
true
|
||||
}.onFailure { Log.w(TAG, "Moving the series back failed", it) }.getOrDefault(false)
|
||||
|
||||
/** The columns a tick moves a series by, or null when [taskId] is no series. */
|
||||
private fun seriesState(authority: String, taskId: Long): Map<String, Any?>? {
|
||||
if (seriesRules(listOf(taskId)).isEmpty()) return null
|
||||
return resolver.query(taskUri(authority, taskId), SERIES_STATE, null, null, null)?.use { c ->
|
||||
if (!c.moveToFirst()) return@use null
|
||||
SERIES_STATE.indices.associate { i ->
|
||||
SERIES_STATE[i] to when (c.getType(i)) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> c.getLong(i)
|
||||
else -> c.getString(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun lastTaskId(authority: String): Long =
|
||||
resolver.query(TasksContract.tasksUri(authority), arrayOf(Tasks.ID), null, null, "${Tasks.ID} DESC")
|
||||
?.use { c -> if (c.moveToFirst()) c.getLong(0) else 0L } ?: 0L
|
||||
|
||||
/** The one plain task the provider created after [afterId], or null when it was not exactly one. */
|
||||
private fun detachedSince(authority: String, afterId: Long): Long? =
|
||||
resolver.query(TasksContract.tasksUri(authority), null, "${Tasks.ID} > ?", arrayOf(afterId.toString()), null)
|
||||
?.use { c ->
|
||||
val r = CursorColumnReader(c)
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
val plain = r.getString(Tasks.RRULE) == null && r.getString(Tasks.RDATE) == null &&
|
||||
r.getLong(Tasks.ORIGINAL_INSTANCE_ID) == null
|
||||
if (plain) r.getLong(Tasks.ID)?.let(::add)
|
||||
}
|
||||
}
|
||||
}?.singleOrNull()
|
||||
|
||||
private val ticks = TickedCopies { context.getSharedPreferences(TICKED_COPIES, Context.MODE_PRIVATE) }
|
||||
|
||||
/** The copy a tick left for [taskId]'s occurrence at [occurrenceStart], if one was remembered. */
|
||||
private fun tickedCopy(taskId: Long, occurrenceStart: Instant): Long? =
|
||||
ticks.get(ticks.key(authority(), taskId, occurrenceStart))?.copyId
|
||||
|
||||
/**
|
||||
* [write] for a task that turned out not to be a series. A series whose
|
||||
* occurrence was not found fails instead: the plain write would close all of it.
|
||||
*/
|
||||
private fun plainUnlessSeries(taskId: Long, occurrenceStart: Instant, write: () -> Unit) {
|
||||
if (seriesRules(listOf(taskId)).isNotEmpty()) {
|
||||
throw TaskWriteFailedException("instance $taskId@$occurrenceStart: no such occurrence")
|
||||
}
|
||||
write()
|
||||
}
|
||||
|
||||
override fun deleteTask(taskId: Long) {
|
||||
resolver.delete(taskUri(authority(), taskId), null, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Through its instance, which the provider turns into an EXDATE on the master,
|
||||
* as the Room store does. Deleting the row alone leaves the master without one.
|
||||
*/
|
||||
override fun deleteOverride(taskId: Long, occurrenceStart: Instant) {
|
||||
val instanceId = instanceIdFor(taskId, occurrenceStart) ?: return deleteTask(taskId)
|
||||
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
|
||||
}
|
||||
|
||||
override fun createLocalList(name: String, color: Int): Long {
|
||||
val uri = TasksContract.asSyncAdapter(
|
||||
TasksContract.listsUri(authority()),
|
||||
@@ -594,5 +930,25 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private const val LIST_ORDER = "provider_list_order"
|
||||
|
||||
/** An instance row of the task, or of an override standing in for one of its occurrences. */
|
||||
private const val OCCURRENCE_OF = "${Instances.TASK_ID} = ? OR ${Tasks.ORIGINAL_INSTANCE_ID} = ?"
|
||||
|
||||
private const val MIGRATIONS = "provider_migrations"
|
||||
|
||||
private const val TICKED_COPIES = "provider_ticked_copies"
|
||||
|
||||
/** What a tick changes on a series, and an undo changes back. */
|
||||
private val SERIES_STATE = arrayOf(
|
||||
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
|
||||
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE,
|
||||
)
|
||||
|
||||
private const val TAG = "AndroidTasksDataSource"
|
||||
|
||||
/** No rule to look up: the provider says the row does not recur. */
|
||||
private const val NO_RULE = -1L
|
||||
|
||||
/** SQLite's default bound-argument limit on older Android releases. */
|
||||
private const val MAX_SQL_ARGS = 999
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
|
||||
/**
|
||||
* Agendula's own order for an external provider's lists, which the provider has
|
||||
* no column for. Stored as `id=position` pairs. A list without one counts as
|
||||
* position 0, as a new list does in Agendula's own store.
|
||||
*/
|
||||
object ListOrder {
|
||||
|
||||
fun decode(raw: String?): Map<Long, Int> = raw.orEmpty().split(',').mapNotNull { pair ->
|
||||
val (id, position) = pair.split('=').takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||
id.toLongOrNull()?.let { key -> position.toIntOrNull()?.let { key to it } }
|
||||
}.toMap()
|
||||
|
||||
fun encode(order: Map<Long, Int>): String = order.entries.joinToString(",") { "${it.key}=${it.value}" }
|
||||
|
||||
/**
|
||||
* Positions for every list in [shown] — the lists in their current order —
|
||||
* after one account's [listIds] were rearranged: those take each other's
|
||||
* slots in their new order, and every other list keeps its own.
|
||||
*/
|
||||
fun reordered(shown: List<Long>, listIds: List<Long>): Map<Long, Int> {
|
||||
val moved = listIds.iterator()
|
||||
val moving = listIds.toSet()
|
||||
val result = shown.map { if (it in moving && moved.hasNext()) moved.next() else it }.toMutableList()
|
||||
moved.forEachRemaining { if (it !in result) result += it }
|
||||
return result.withIndex().associate { it.value to it.index }
|
||||
}
|
||||
|
||||
fun sorted(lists: List<TaskList>, order: Map<Long, Int>): List<TaskList> = lists.sortedWith(
|
||||
compareBy<TaskList>({ it.accountName.lowercase() }, { order[it.id] ?: 0 }, { it.name.lowercase() })
|
||||
.thenBy { it.name },
|
||||
)
|
||||
}
|
||||
+10
-3
@@ -67,6 +67,7 @@ class ModeRoutingTasksDataSource(
|
||||
active().setCancelledInstance(taskId, occurrenceStart, cancelled)
|
||||
|
||||
override fun deleteTask(taskId: Long) = active().deleteTask(taskId)
|
||||
override fun deleteOverride(taskId: Long, occurrenceStart: Instant) = active().deleteOverride(taskId, occurrenceStart)
|
||||
override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color)
|
||||
override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color)
|
||||
override fun deleteList(listId: Long) = active().deleteList(listId)
|
||||
@@ -81,16 +82,22 @@ class ModeRoutingTasksDataSource(
|
||||
* So the registration moves with the mode, and the switch itself counts as a
|
||||
* change: the data underneath every live flow has just been replaced.
|
||||
*/
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable =
|
||||
follow(onChange) { it.registerObserver(onChange) }
|
||||
|
||||
override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable =
|
||||
follow(onChange) { it.registerTaskObserver(onChange) }
|
||||
|
||||
private fun follow(onChange: () -> Unit, register: (TasksDataSource) -> AutoCloseable): AutoCloseable {
|
||||
val lock = Any()
|
||||
var closed = false
|
||||
var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull()
|
||||
var handle: AutoCloseable? = runCatching { register(active()) }.getOrNull()
|
||||
|
||||
val modeHandle = resolver.onModeChanged {
|
||||
synchronized(lock) {
|
||||
if (!closed) {
|
||||
handle?.let { runCatching { it.close() } }
|
||||
handle = runCatching { active().registerObserver(onChange) }.getOrNull()
|
||||
handle = runCatching { register(active()) }.getOrNull()
|
||||
}
|
||||
}
|
||||
onChange()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Runs [action] once per burst of store changes: [register] hooks the store's
|
||||
* observer, and [request] asks for a pass without one. Started at most once.
|
||||
*/
|
||||
class StoreChangeDebouncer(
|
||||
private val scope: CoroutineScope,
|
||||
private val delayMillis: Long,
|
||||
private val register: (onChange: () -> Unit) -> AutoCloseable,
|
||||
private val action: suspend () -> Unit,
|
||||
) {
|
||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
/** @return whether this call started it; false when it already was. */
|
||||
@OptIn(FlowPreview::class)
|
||||
fun start(): Boolean {
|
||||
if (!started.compareAndSet(false, true)) return false
|
||||
runCatching { register { requests.trySend(Unit) } }
|
||||
scope.launch(Dispatchers.IO) {
|
||||
requests.receiveAsFlow().debounce(delayMillis).collect { runCatching { action() } }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun request() {
|
||||
requests.trySend(Unit)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -22,7 +23,8 @@ internal object StoredReminders {
|
||||
*
|
||||
* A task getting its first date takes the defaults only when the form
|
||||
* names no reminder; one that already had a date takes those of the new
|
||||
* date that no reminder of the form counts from. Otherwise the form is
|
||||
* date that no reminder of the form counts from, and those of a date
|
||||
* whose defaults had all rung when it moved — see [revived]. Otherwise the form is
|
||||
* kept, so an edit that clears every reminder means "use the default
|
||||
* here" as before.
|
||||
*
|
||||
@@ -33,7 +35,7 @@ internal object StoredReminders {
|
||||
fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> {
|
||||
val wanted = form.reminders.map { it.lead }.distinct()
|
||||
if (form.remindersChosen) return wanted
|
||||
return withDefaults(form, current?.start, current?.due, settings, now, zone)
|
||||
return withDefaults(form, current?.start, current?.due, current?.isAllDay ?: form.isAllDay, settings, now, zone)
|
||||
}
|
||||
|
||||
/** [of] for a task whose dates were [start] and [due], whatever the form chose. */
|
||||
@@ -41,6 +43,7 @@ internal object StoredReminders {
|
||||
form: TaskForm,
|
||||
start: Instant?,
|
||||
due: Instant?,
|
||||
wasAllDay: Boolean,
|
||||
settings: Settings,
|
||||
now: Long,
|
||||
zone: ZoneId,
|
||||
@@ -48,10 +51,12 @@ internal object StoredReminders {
|
||||
val wanted = form.reminders.map { it.lead }.distinct()
|
||||
val had = anchorsOf(start, due)
|
||||
val gained = anchorsOf(form.start, form.due) - had
|
||||
if (gained.isEmpty()) return wanted
|
||||
val revived = revived(form, start, due, wasAllDay, wanted, settings, now, zone)
|
||||
if (gained.isEmpty() && revived.isEmpty()) return wanted
|
||||
if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) }
|
||||
val named = wanted.map { it.fromStart }.toSet()
|
||||
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named }
|
||||
val fresh = gained + revived
|
||||
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in fresh && it.fromStart !in named }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,6 +84,41 @@ internal object StoredReminders {
|
||||
return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The dates of a one-off task that moved after every default of theirs
|
||||
* had rung: a task saved then was left without them as past (#121), so
|
||||
* the new date brings them like a date the task gains. Only on a task
|
||||
* with reminders of its own and none from that date — no reminders at
|
||||
* all is the planner's default or turned off, and either holds.
|
||||
*/
|
||||
private fun revived(
|
||||
form: TaskForm,
|
||||
start: Instant?,
|
||||
due: Instant?,
|
||||
wasAllDay: Boolean,
|
||||
wanted: List<TaskReminder>,
|
||||
settings: Settings,
|
||||
now: Long,
|
||||
zone: ZoneId,
|
||||
): Set<Boolean> {
|
||||
if (form.rrule != null || wanted.isEmpty()) return emptySet()
|
||||
val named = wanted.map { it.fromStart }.toSet()
|
||||
val defaults = settings.defaultRemindersFor(form.listId, wasAllDay)
|
||||
fun revived(fromStart: Boolean, was: Instant?, isNow: Instant?): Boolean {
|
||||
if (was == null || isNow == null || fromStart in named) return false
|
||||
val moved = if (wasAllDay == form.isAllDay) {
|
||||
isNow != was
|
||||
} else {
|
||||
isNow.calendarDate(form.isAllDay, zone) != was.calendarDate(wasAllDay, zone)
|
||||
}
|
||||
val leads = defaults.filter { it.fromStart == fromStart }
|
||||
return moved && leads.isNotEmpty() && leads.all {
|
||||
ReminderPlanner.triggerAt(was, wasAllDay, it.minutesBefore, settings.allDayReminderMinuteOfDay, zone) < now
|
||||
}
|
||||
}
|
||||
return setOfNotNull(true.takeIf { revived(true, start, form.start) }, false.takeIf { revived(false, due, form.due) })
|
||||
}
|
||||
|
||||
/** The dates a reminder can count from, as [TaskReminder.fromStart]. */
|
||||
private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> =
|
||||
setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null })
|
||||
|
||||
@@ -13,20 +13,23 @@ import kotlin.time.Instant
|
||||
/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */
|
||||
object TaskMapper {
|
||||
|
||||
fun task(r: ColumnReader): Task {
|
||||
/**
|
||||
* Maps an `instances` row, without its series' rule: the dmfs instances view
|
||||
* (DB 22 and 23) masks `rrule`/`rdate` with `NULL`, so the row alone cannot
|
||||
* tell a series from a one-off on tasks.org's bundled provider (DB 22), which
|
||||
* also lacks `is_recurring`. [withRule] completes it with the rule read from
|
||||
* the master's `tasks` row. An override names its master in
|
||||
* `original_instance_id`.
|
||||
*/
|
||||
fun instance(r: ColumnReader): InstanceRow {
|
||||
fun instant(name: String): Instant? =
|
||||
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
|
||||
|
||||
val rowId = r.getLong(Tasks.ID) ?: 0L
|
||||
// Derived from the rule columns rather than the `is_recurring` column
|
||||
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
|
||||
// absent on tasks.org's bundled provider (DB 22), where reading it
|
||||
// would silently report every recurring task as one-off — and route
|
||||
// its edits onto the series anchor.
|
||||
val recurring = r.getString(Tasks.RRULE) != null ||
|
||||
r.getString(Tasks.RDATE) != null ||
|
||||
r.getBoolean(Instances.IS_RECURRING)
|
||||
return Task(
|
||||
val masterId = r.getLong(Tasks.ORIGINAL_INSTANCE_ID)
|
||||
val recurring = masterId != null || r.getBoolean(Instances.IS_RECURRING)
|
||||
val anchor = occurrenceAnchor(r)
|
||||
val task = Task(
|
||||
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
|
||||
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
|
||||
title = r.getString(Tasks.TITLE).orEmpty(),
|
||||
@@ -47,13 +50,31 @@ object TaskMapper {
|
||||
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
||||
parentId = r.getLong(Tasks.PARENT_ID),
|
||||
isRecurring = recurring,
|
||||
occurrenceStart = if (recurring) occurrenceAnchor(r) else null,
|
||||
occurrenceStart = if (recurring) anchor else null,
|
||||
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
||||
created = instant(Tasks.CREATED),
|
||||
lastModified = instant(Tasks.LAST_MODIFIED),
|
||||
seriesId = if (recurring) r.getLong(Instances.TASK_ID) ?: rowId else null,
|
||||
recurrenceRule = r.getString(Tasks.RRULE),
|
||||
seriesId = if (recurring) masterId ?: r.getLong(Instances.TASK_ID) ?: rowId else null,
|
||||
)
|
||||
return InstanceRow(task, ruleOwner = masterId ?: task.taskId, anchor = anchor)
|
||||
}
|
||||
|
||||
/** [row]'s task as an occurrence of the series [rule] belongs to, or as it is without one. */
|
||||
fun withRule(row: InstanceRow, rule: SeriesRule?): Task {
|
||||
if (rule == null) return row.task
|
||||
return row.task.copy(
|
||||
isRecurring = true,
|
||||
occurrenceStart = row.task.occurrenceStart ?: row.anchor,
|
||||
seriesId = row.task.seriesId ?: row.ruleOwner,
|
||||
recurrenceRule = rule.rrule,
|
||||
)
|
||||
}
|
||||
|
||||
/** The rule columns of a `tasks` row, or null when it does not recur. */
|
||||
fun seriesRule(r: ColumnReader): SeriesRule? {
|
||||
val rrule = r.getString(Tasks.RRULE)?.ifBlank { null }
|
||||
val rdate = r.getString(Tasks.RDATE)?.ifBlank { null }
|
||||
return if (rrule == null && rdate == null) null else SeriesRule(rrule, rdate)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,3 +140,12 @@ object TaskMapper {
|
||||
owner = r.getString(Lists.OWNER),
|
||||
)
|
||||
}
|
||||
|
||||
/** A series' recurrence as stored on its master row. */
|
||||
data class SeriesRule(val rrule: String?, val rdate: String?)
|
||||
|
||||
/**
|
||||
* An instance row's task before its series' rule is applied. [ruleOwner] is the
|
||||
* task whose `tasks` row carries that rule: the master, for an override.
|
||||
*/
|
||||
data class InstanceRow(val task: Task, val ruleOwner: Long, val anchor: Instant?)
|
||||
@@ -17,6 +17,20 @@ private fun Instant.forAllDay(allDay: Boolean): Instant =
|
||||
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whole seconds, as iCalendar stores times. The provider expands a series from
|
||||
* second-precision instants, so a DTSTART carrying milliseconds never matches its
|
||||
* own first occurrence: completing it detaches a copy but leaves the series where
|
||||
* it was, and the same occurrence comes straight back.
|
||||
*/
|
||||
fun providerMillis(epochMillis: Long): Long = Math.floorDiv(epochMillis, 1000L) * 1000L
|
||||
|
||||
/** [at] cut to its whole second, as [providerMillis] does. */
|
||||
fun wholeSecond(at: Instant): Instant = Instant.fromEpochMilliseconds(providerMillis(at.toEpochMilliseconds()))
|
||||
|
||||
/** True when [a] and [b] fall in the same whole second. */
|
||||
fun sameSecond(a: Instant, b: Instant): Boolean = wholeSecond(a) == wholeSecond(b)
|
||||
|
||||
/**
|
||||
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
||||
* so it unit-tests on the JVM; [AndroidTasksDataSource] converts the map to
|
||||
@@ -57,8 +71,8 @@ object TaskWriteMapper {
|
||||
// through DateTime.toAllDay(), which drops the time-of-day and resolves the
|
||||
// remaining date against UTC — so a local-midnight instant lands on the
|
||||
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
|
||||
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
|
||||
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
|
||||
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds()?.let(::providerMillis))
|
||||
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds()?.let(::providerMillis))
|
||||
// DUE and DURATION are mutually exclusive. The provider's Validating
|
||||
// processor evaluates the *merged* row (supplied values over the stored
|
||||
// ones), so writing DUE onto a task that already carries a DURATION throws
|
||||
|
||||
@@ -124,13 +124,17 @@ interface TasksDataSource {
|
||||
*
|
||||
* Implementations fall back to [setCompleted] when the row turns out not to
|
||||
* be a series master — an override, or a plain task the caller happened to
|
||||
* hand an anchor for — so the routing above cannot get this wrong.
|
||||
* hand an anchor for. A series whose occurrence cannot be found fails with
|
||||
* [TaskWriteFailedException] instead: the plain write would close all of it.
|
||||
*/
|
||||
fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean)
|
||||
|
||||
/** Cancel (or restore) one occurrence of a series; the counterpart of [setCompletedInstance]. */
|
||||
fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean)
|
||||
fun deleteTask(taskId: Long)
|
||||
|
||||
/** Delete the override [taskId] standing for one occurrence, excluding that occurrence from its series. */
|
||||
fun deleteOverride(taskId: Long, occurrenceStart: Instant) = deleteTask(taskId)
|
||||
fun createLocalList(name: String, color: Int): Long
|
||||
|
||||
/** Rename and recolour [listId]. */
|
||||
@@ -152,4 +156,7 @@ interface TasksDataSource {
|
||||
|
||||
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
|
||||
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
||||
|
||||
/** Like [registerObserver], but only for changes to tasks, lists and alarms — not account bookkeeping. */
|
||||
fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = registerObserver(onChange)
|
||||
}
|
||||
@@ -68,9 +68,11 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
|
||||
override fun taskDetail(taskId: Long, occurrenceStart: Instant?): Flow<TaskDetail?> = observing {
|
||||
dataSource.task(taskId, occurrenceStart)?.let { task ->
|
||||
// A series' subtasks hang off its master, also when this occurrence is an override.
|
||||
val owner = task.seriesId ?: resolvedRow(task, taskId)
|
||||
TaskDetail(
|
||||
task = task,
|
||||
subtasks = SeriesCollapse.visible(dataSource.subtasks(taskId)).filter { it.taskId != taskId },
|
||||
subtasks = SeriesCollapse.visible(dataSource.subtasks(owner)).filter { it.taskId != owner },
|
||||
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
|
||||
)
|
||||
}
|
||||
@@ -172,7 +174,9 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
) = withContext(io) {
|
||||
// Re-read just before writing: it settles the conflict check *and* tells
|
||||
// us which URI to write through.
|
||||
// An occurrence that is gone is not written through to its series.
|
||||
val current = dataSource.task(taskId, occurrenceStart)
|
||||
?: if (occurrenceStart != null) throw TaskWriteFailedException("update task $taskId@$occurrenceStart: gone") else null
|
||||
// Conflict-safe overwrite: bail if the provider's last_modified moved
|
||||
// since the form captured it (external sync / another app). A null
|
||||
// baseline means "force / overwrite anyway".
|
||||
@@ -201,7 +205,7 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
// Going through the occurrence forks an override instead, which takes
|
||||
// the reminders on its own row and leaves the series' alone.
|
||||
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
|
||||
if (instance == null) dataSource.updateTask(taskId, form, reminders)
|
||||
if (instance == null) dataSource.updateTask(resolvedRow(current, taskId), form, reminders)
|
||||
else dataSource.updateInstance(taskId, instance, form, reminders)
|
||||
}
|
||||
|
||||
@@ -233,14 +237,16 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
override suspend fun deleteTask(taskId: Long, occurrenceStart: Instant?, scope: RecurringScope) =
|
||||
withContext(io) {
|
||||
val current = dataSource.task(taskId, occurrenceStart)
|
||||
// An occurrence that is gone leaves nothing to delete; its series stays.
|
||||
if (current == null && occurrenceStart != null) return@withContext
|
||||
val seriesId = current?.seriesId
|
||||
val occurrence = current?.occurrenceStart
|
||||
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId)
|
||||
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(resolvedRow(current, taskId))
|
||||
when (scope) {
|
||||
RecurringScope.ThisOccurrence ->
|
||||
// An override row deletes itself into an EXDATE; a generated
|
||||
// occurrence has no row to delete.
|
||||
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
|
||||
if (current.taskId != seriesId) dataSource.deleteOverride(current.taskId, occurrence)
|
||||
else dataSource.deleteOccurrence(seriesId, occurrence)
|
||||
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
|
||||
RecurringScope.AllOccurrences -> dataSource.deleteWithSubtasks(seriesId)
|
||||
@@ -291,6 +297,13 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
ticks.close()
|
||||
}
|
||||
}.flowOn(io)
|
||||
|
||||
/**
|
||||
* The row a read for [taskId] resolved to — a past occurrence the series moved
|
||||
* past is a task of its own — which is the one a write has to address.
|
||||
*/
|
||||
private fun resolvedRow(resolved: Task?, taskId: Long): Long = resolved?.taskId ?: taskId
|
||||
|
||||
}
|
||||
|
||||
/** Subtask levels a duplicate follows; deeper than any real list, short of a cycle. */
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import org.json.JSONObject
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* What ticking a provider occurrence left: the copy the provider detached, and
|
||||
* the series' columns before and after the tick, so an undo can move it back.
|
||||
*/
|
||||
internal class Tick(
|
||||
val copyId: Long,
|
||||
val seriesId: Long?,
|
||||
val before: Map<String, Any?>?,
|
||||
val after: Map<String, Any?>?,
|
||||
val at: Long,
|
||||
)
|
||||
|
||||
/** The provider keeps no record of its ticks, so they are kept here for [HORIZON_MS]. */
|
||||
internal class TickedCopies(private val prefs: () -> SharedPreferences) {
|
||||
|
||||
fun key(authority: String, taskId: Long, occurrenceStart: Instant) =
|
||||
"$authority|$taskId|${wholeSecond(occurrenceStart).epochSeconds}"
|
||||
|
||||
fun get(key: String): Tick? = prefs().getString(key, null)?.let(::decode)
|
||||
|
||||
/** The tick that left [copyId], with its key. */
|
||||
fun forCopy(authority: String, copyId: Long): Pair<String, Tick>? =
|
||||
prefs().all.entries.firstNotNullOfOrNull { (key, value) ->
|
||||
if (!key.startsWith("$authority|")) return@firstNotNullOfOrNull null
|
||||
(value as? String)?.let(::decode)?.takeIf { it.copyId == copyId }?.let { key to it }
|
||||
}
|
||||
|
||||
fun put(key: String, tick: Tick) {
|
||||
val stale = prefs().all.filter { (_, value) ->
|
||||
val at = (value as? String)?.let(::decode)?.at ?: 0L
|
||||
tick.at - at > HORIZON_MS
|
||||
}.keys
|
||||
prefs().edit().apply {
|
||||
stale.forEach(::remove)
|
||||
putString(key, encode(tick))
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
prefs().edit().remove(key).apply()
|
||||
}
|
||||
|
||||
private fun encode(tick: Tick): String = JSONObject().apply {
|
||||
put("copy", tick.copyId)
|
||||
put("at", tick.at)
|
||||
tick.seriesId?.let { put("series", it) }
|
||||
tick.before?.let { put("before", columns(it)) }
|
||||
tick.after?.let { put("after", columns(it)) }
|
||||
}.toString()
|
||||
|
||||
private fun columns(values: Map<String, Any?>) = JSONObject().apply {
|
||||
values.forEach { (column, value) -> put(column, value ?: JSONObject.NULL) }
|
||||
}
|
||||
|
||||
private fun decode(raw: String): Tick? = runCatching {
|
||||
val json = JSONObject(raw)
|
||||
Tick(
|
||||
copyId = json.getLong("copy"),
|
||||
seriesId = if (json.has("series")) json.getLong("series") else null,
|
||||
before = json.optJSONObject("before")?.let(::values),
|
||||
after = json.optJSONObject("after")?.let(::values),
|
||||
at = json.getLong("at"),
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private fun values(json: JSONObject): Map<String, Any?> = json.keys().asSequence().associateWith { column ->
|
||||
when (val value = json.get(column)) {
|
||||
JSONObject.NULL -> null
|
||||
is Number -> value.toLong()
|
||||
else -> value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** As long as the own store keeps a tick undoable. */
|
||||
const val HORIZON_MS = 30L * 24 * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
+28
-34
@@ -1,8 +1,7 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
||||
@@ -19,8 +18,6 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -58,47 +55,40 @@ class ProviderWriter internal constructor(
|
||||
store: RoomTasksDataSource,
|
||||
source: ProviderSnapshotSource,
|
||||
resolver: ProviderResolver,
|
||||
scheduler: ReminderScheduler,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } })
|
||||
reminders: ReminderRefresher,
|
||||
) : this(database, store, source, resolver, reminders::requestRefresh)
|
||||
|
||||
private var batchDepth = 0
|
||||
private var batchWrote = false
|
||||
|
||||
/** Inserts one row and returns its published id. */
|
||||
fun insert(table: ProviderTable, values: ProviderValues): Long = write {
|
||||
when (table) {
|
||||
ProviderTable.TASKS -> insertTask(values)
|
||||
ProviderTable.PROPERTIES -> insertProperty(values)
|
||||
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
||||
}
|
||||
fun insert(table: ProviderTable, values: ProviderValues): Long = when (table) {
|
||||
ProviderTable.TASKS -> write { insertTask(values) }
|
||||
ProviderTable.PROPERTIES -> write { insertProperty(values) }
|
||||
ProviderTable.LISTS, ProviderTable.INSTANCES -> readOnly(table)
|
||||
}
|
||||
|
||||
/** Updates every row [selection] matches and returns how many changed. */
|
||||
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = write {
|
||||
when (table) {
|
||||
ProviderTable.TASKS -> {
|
||||
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
||||
val matched = ids(table, selection, args)
|
||||
(if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) }
|
||||
}
|
||||
ProviderTable.INSTANCES -> {
|
||||
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
||||
instances(selection, args).count { updateInstance(it, writable) }
|
||||
}
|
||||
ProviderTable.PROPERTIES -> ids(table, selection, args).count { updateProperty(it, values) }
|
||||
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
||||
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = when (table) {
|
||||
ProviderTable.TASKS -> write {
|
||||
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
||||
val matched = ids(table, selection, args)
|
||||
(if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) }
|
||||
}
|
||||
ProviderTable.INSTANCES -> write {
|
||||
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
||||
instances(selection, args).count { updateInstance(it, writable) }
|
||||
}
|
||||
ProviderTable.PROPERTIES -> write { ids(table, selection, args).count { updateProperty(it, values) } }
|
||||
ProviderTable.LISTS -> readOnly(table)
|
||||
}
|
||||
|
||||
/** Deletes every row [selection] matches and returns how many went. */
|
||||
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = write {
|
||||
when (table) {
|
||||
ProviderTable.TASKS -> ids(table, selection, args).count { deleteTask(it) }
|
||||
ProviderTable.INSTANCES -> instances(selection, args).count { deleteInstance(it) }
|
||||
ProviderTable.PROPERTIES -> ids(table, selection, args).count { deleteProperty(it) }
|
||||
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
||||
}
|
||||
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = when (table) {
|
||||
ProviderTable.TASKS -> write { ids(table, selection, args).count { deleteTask(it) } }
|
||||
ProviderTable.INSTANCES -> write { instances(selection, args).count { deleteInstance(it) } }
|
||||
ProviderTable.PROPERTIES -> write { ids(table, selection, args).count { deleteProperty(it) } }
|
||||
ProviderTable.LISTS -> readOnly(table)
|
||||
}
|
||||
|
||||
/** Runs a client's batch in one transaction, re-arming reminders once at the end. */
|
||||
@@ -117,6 +107,9 @@ class ProviderWriter internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Refused as read-only whichever store is active, before the store is even looked at. */
|
||||
private fun readOnly(table: ProviderTable): Nothing = throw UnsupportedOperationException("${table.path} is read-only")
|
||||
|
||||
private fun <T> write(block: () -> T): T = source.locked {
|
||||
check(resolver.mode() == StorageMode.OWN) {
|
||||
"Agendula is working on top of an external tasks provider; write to that one instead"
|
||||
@@ -196,7 +189,8 @@ class ProviderWriter internal constructor(
|
||||
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||
requireWritableList(current.listId)
|
||||
// An override deletes itself into an EXDATE on its master.
|
||||
if (current.masterId != null) store.deleteTask(taskId) else store.deleteWithSubtasks(taskId)
|
||||
val occurrence = current.recurrenceId
|
||||
if (current.masterId != null && occurrence != null) store.deleteOverride(taskId, occurrence) else store.deleteWithSubtasks(taskId)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* How a series was moved past one of its occurrences (#137). It maps the
|
||||
* occurrence to its completed copy for as long as the copy lives, and while
|
||||
* [undoable] it also holds what reopening the copy needs to move the series
|
||||
* back: the change, not the old series, so edits made since are kept. Local
|
||||
* only: never part of a `VTODO`.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "advanced_occurrences",
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["copy_id"], onDelete = ForeignKey.CASCADE),
|
||||
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["master_id"], onDelete = ForeignKey.CASCADE),
|
||||
],
|
||||
indices = [Index(value = ["master_id", "anchor"])],
|
||||
)
|
||||
data class AdvancedOccurrenceEntity(
|
||||
/** The completed copy of the occurrence. */
|
||||
@PrimaryKey @ColumnInfo(name = "copy_id") val copyId: Long,
|
||||
@ColumnInfo(name = "master_id") val masterId: Long,
|
||||
@ColumnInfo(name = "anchor") val anchor: Instant,
|
||||
/** The anchor the series moved to; it is only moved back while it still has it. */
|
||||
@ColumnInfo(name = "advanced_to") val advancedTo: Instant,
|
||||
@ColumnInfo(name = "prior_dtstart") val priorDtstart: Instant?,
|
||||
@ColumnInfo(name = "prior_due") val priorDue: Instant?,
|
||||
@ColumnInfo(name = "prior_status") val priorStatus: TaskStatus,
|
||||
@ColumnInfo(name = "prior_percent_complete") val priorPercentComplete: Int?,
|
||||
@ColumnInfo(name = "prior_completed_at") val priorCompletedAt: Instant?,
|
||||
/** Occurrences `COUNT` lost to the move. */
|
||||
@ColumnInfo(name = "spent") val spent: Int,
|
||||
/** The `RRULE` the move left; once it differs, the series was reshaped and is not moved back. */
|
||||
@ColumnInfo(name = "rule_after") val ruleAfter: String?,
|
||||
/** The `EXDATE` occurrences the move dropped, as epoch millis, comma-separated. */
|
||||
@ColumnInfo(name = "dropped_exdates", defaultValue = "") val droppedExdates: String = "",
|
||||
/** The copy was an override before, and goes back to being one. */
|
||||
@ColumnInfo(name = "from_override") val fromOverride: Boolean,
|
||||
/** Cleared once the copy is edited, moved or reopened on its own: it is then a task of its own. */
|
||||
@ColumnInfo(name = "undoable", defaultValue = "1") val undoable: Boolean = true,
|
||||
/** Set once the moved series reached the server, which then holds the move itself. */
|
||||
@ColumnInfo(name = "settled", defaultValue = "0") val settled: Boolean = false,
|
||||
) {
|
||||
val dropped: List<Instant>
|
||||
get() = droppedExdates.split(',').mapNotNull { it.toLongOrNull()?.let(Instant::fromEpochMilliseconds) }
|
||||
|
||||
companion object {
|
||||
fun encode(instants: List<Instant>) = instants.joinToString(",") { it.toEpochMilliseconds().toString() }
|
||||
}
|
||||
}
|
||||
|
||||
/** A closed override detached along with an advance; [taskId] is null once that row is gone. */
|
||||
@Entity(
|
||||
tableName = "advanced_detached",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = AdvancedOccurrenceEntity::class,
|
||||
parentColumns = ["copy_id"],
|
||||
childColumns = ["copy_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["task_id"], onDelete = ForeignKey.SET_NULL),
|
||||
],
|
||||
indices = [Index(value = ["copy_id"]), Index(value = ["task_id"])],
|
||||
)
|
||||
data class AdvancedDetachedEntity(
|
||||
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0,
|
||||
@ColumnInfo(name = "copy_id") val copyId: Long,
|
||||
@ColumnInfo(name = "anchor") val anchor: Instant,
|
||||
@ColumnInfo(name = "task_id") val taskId: Long?,
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface AdvancedOccurrenceDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insert(row: AdvancedOccurrenceEntity)
|
||||
|
||||
@Insert
|
||||
fun insertDetached(rows: List<AdvancedDetachedEntity>)
|
||||
|
||||
@Query("SELECT * FROM advanced_occurrences WHERE copy_id = :copyId")
|
||||
fun forCopy(copyId: Long): AdvancedOccurrenceEntity?
|
||||
|
||||
@Query("SELECT * FROM advanced_detached WHERE copy_id = :copyId")
|
||||
fun detachedFor(copyId: Long): List<AdvancedDetachedEntity>
|
||||
|
||||
@Query("SELECT * FROM advanced_occurrences WHERE master_id = :masterId")
|
||||
fun forMaster(masterId: Long): List<AdvancedOccurrenceEntity>
|
||||
|
||||
/** The record for [masterId]'s occurrence within the second starting at [secondStart]. */
|
||||
@Query(
|
||||
"SELECT * FROM advanced_occurrences WHERE master_id = :masterId " +
|
||||
"AND anchor >= :secondStart AND anchor < :secondStart + 1000",
|
||||
)
|
||||
fun at(masterId: Long, secondStart: Instant): AdvancedOccurrenceEntity?
|
||||
|
||||
@Query("DELETE FROM advanced_occurrences WHERE copy_id = :copyId")
|
||||
fun delete(copyId: Long)
|
||||
|
||||
@Query("UPDATE advanced_occurrences SET undoable = 0 WHERE copy_id = :copyId")
|
||||
fun keepAsOwnTask(copyId: Long)
|
||||
|
||||
@Query("UPDATE advanced_occurrences SET settled = 1 WHERE master_id IN (:masterIds)")
|
||||
fun settle(masterIds: List<Long>)
|
||||
|
||||
/** Settles [masterId]'s moves to before [until], the anchor the server now holds. */
|
||||
@Query("UPDATE advanced_occurrences SET settled = 1 WHERE master_id = :masterId AND advanced_to < :until")
|
||||
fun settleUpTo(masterId: Long, until: Instant)
|
||||
|
||||
/** Records of ticks made before [before]: too old to undo. Counted from the tick, not the occurrence, which may be long overdue. */
|
||||
@Query(
|
||||
"DELETE FROM advanced_occurrences WHERE master_id = :masterId " +
|
||||
"AND copy_id IN (SELECT id FROM tasks WHERE created_at < :before)",
|
||||
)
|
||||
fun pruneTickedBefore(masterId: Long, before: Instant)
|
||||
|
||||
/** [taskId], once detached with a tick, has a life of its own and no longer returns to the series. */
|
||||
@Query("UPDATE advanced_detached SET task_id = NULL WHERE task_id = :taskId")
|
||||
fun unlinkDetached(taskId: Long)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.agendula.domain.LocalAccount
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.isClosed
|
||||
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
import kotlin.time.Instant
|
||||
@@ -100,6 +101,10 @@ object RoomTaskMapper {
|
||||
val TaskEntity.isRecurring: Boolean
|
||||
get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
|
||||
|
||||
/** Completed or cancelled. */
|
||||
val TaskEntity.isClosed: Boolean
|
||||
get() = status.isClosed
|
||||
|
||||
/**
|
||||
* The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only
|
||||
* a due date, and RFC 5545 then anchors the recurrence on it — matching how the
|
||||
|
||||
+368
-56
@@ -6,13 +6,17 @@ import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.sameSecond
|
||||
import de.jeanlucmakiola.agendula.data.tasks.wholeSecond
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
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
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -28,6 +32,12 @@ private val WINDOW_FORWARD = 730.days
|
||||
/** Residue lines that describe the old series and must not follow the new one. */
|
||||
private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", "UID")
|
||||
|
||||
/** How long a record keeps mapping a ticked occurrence to its copy. */
|
||||
private val RECORD_HORIZON = 30.days
|
||||
|
||||
/** Only bounds the search: the occurrence count is what stops expansion, so no real gap is cut off. */
|
||||
private val SERIES_LOOKAHEAD = (366 * 1000).days
|
||||
|
||||
/** Enough to count every occurrence before a split point without bounding a real series. */
|
||||
internal const val SPLIT_COUNT_CEILING = 100_000
|
||||
|
||||
@@ -76,6 +86,7 @@ internal fun seriesOccurrences(master: TaskEntity, overrides: List<TaskEntity>,
|
||||
}
|
||||
|
||||
internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
||||
private val TASK_TABLES = arrayOf("tasks", "task_lists", "task_alarms")
|
||||
|
||||
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
|
||||
private const val ACK_CACHE_SIZE = 512
|
||||
@@ -102,6 +113,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
private val tasks get() = database.tasks()
|
||||
private val lists get() = database.taskLists()
|
||||
private val alarms get() = database.alarms()
|
||||
private val advanced get() = database.advancedOccurrences()
|
||||
|
||||
// --- reads ----------------------------------------------------------------
|
||||
|
||||
@@ -129,9 +141,13 @@ class RoomTasksDataSource @Inject constructor(
|
||||
if (occurrenceStart == null) return task(taskId)
|
||||
val row = tasks.task(taskId) ?: return null
|
||||
if (row.task.recurrenceId != null || !row.task.isRecurring) return task(taskId)
|
||||
return occurrencesOf(row, tasks.overrides(taskId), clock.now())
|
||||
.firstOrNull { it.occurrenceStart == occurrenceStart }
|
||||
?: task(taskId)
|
||||
val occurrences = occurrencesOf(row, tasks.overrides(taskId), clock.now())
|
||||
// Null rather than another occurrence: a write addressed to this one must not land on that.
|
||||
return occurrences.firstOrNull { it.occurrenceStart == occurrenceStart }
|
||||
?: occurrences.firstOrNull { it.occurrenceStart?.let { a -> sameSecond(a, occurrenceStart) } == true }
|
||||
?: copyAt(row.task, occurrenceStart)?.let { copy ->
|
||||
tasks.task(copy.id)?.let { RoomTaskMapper.task(it).copy(occurrenceStart = occurrenceStart) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun overrideTask(row: TaskRow, anchor: Instant): Task =
|
||||
@@ -229,10 +245,8 @@ class RoomTasksDataSource @Inject constructor(
|
||||
* reminders sync, a sync landing between the two would upload the task
|
||||
* without them, or with the ones it is about to lose.
|
||||
*/
|
||||
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
|
||||
if (reminders == null) return
|
||||
alarms.syncForTask(taskId, reminders)
|
||||
}
|
||||
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?): Boolean =
|
||||
reminders != null && alarms.syncForTask(taskId, reminders)
|
||||
|
||||
private fun storedReminders(taskId: Long): List<TaskReminder> =
|
||||
alarms.forTask(taskId).map { it.asReminder() }.sortedWith(TaskReminderOrder)
|
||||
@@ -240,7 +254,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = tx {
|
||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
|
||||
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
|
||||
writeReminders(taskId, reminders)
|
||||
val remindersChanged = writeReminders(taskId, reminders)
|
||||
if (current.masterId == null && edited.listId != current.listId) {
|
||||
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
||||
move(current, TaskFormWriter.withRule(edited, form.rrule))
|
||||
@@ -255,18 +269,23 @@ class RoomTasksDataSource @Inject constructor(
|
||||
// in both (the task query skips non-null `master_id`, and the override
|
||||
// query finds no master in B) while still uploading as part of A's
|
||||
// resource.
|
||||
tasks.update(
|
||||
saveRow(
|
||||
current,
|
||||
if (current.masterId == null) {
|
||||
TaskFormWriter.withRule(edited, form.rrule)
|
||||
} else {
|
||||
edited.copy(listId = current.listId, parentId = current.parentId)
|
||||
},
|
||||
changedBesides = remindersChanged,
|
||||
)
|
||||
writes.onWritten(setOf(current.listId))
|
||||
}
|
||||
|
||||
/** See [TaskMove]: the rows move, and the old collection is left a tombstone. */
|
||||
private fun move(current: TaskEntity, edited: TaskEntity) {
|
||||
// A copy moved elsewhere is a task of its own now.
|
||||
advanced.keepAsOwnTask(current.id)
|
||||
advanced.unlinkDetached(current.id)
|
||||
val plan = TaskMove.plan(
|
||||
edited = edited,
|
||||
previous = current,
|
||||
@@ -322,11 +341,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
return@tx
|
||||
}
|
||||
val now = clock.now()
|
||||
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
|
||||
val spent = RecurrenceExpander.expand(
|
||||
spec.copy(exdate = null),
|
||||
ExpansionWindow(from = anchor, until = occurrenceStart, maxOccurrences = SPLIT_COUNT_CEILING),
|
||||
).size
|
||||
val spent = spentBefore(spec, occurrenceStart)
|
||||
val (start, due) = resolvedTimes(master, occurrenceStart)
|
||||
tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, now, zone()))
|
||||
|
||||
@@ -349,9 +364,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
percentComplete = null,
|
||||
completedAt = null,
|
||||
// Recurrence lines belong to the old series; everything else carries over.
|
||||
unknownProperties = master.unknownProperties?.lines()
|
||||
?.filterNot { line -> SERIES_LINES.any { line.startsWith(it, ignoreCase = true) } }
|
||||
?.joinToString("\r\n")?.ifBlank { null },
|
||||
unknownProperties = seriesFreeText(master.unknownProperties),
|
||||
)
|
||||
val applied = TaskFormWriter.apply(base, form.copy(percentComplete = null), now, zone())
|
||||
val delta = applied.dtstart?.let { newStart -> start?.let { newStart - it } }
|
||||
@@ -410,9 +423,12 @@ class RoomTasksDataSource @Inject constructor(
|
||||
|
||||
private inline fun tx(crossinline block: () -> Unit) = database.runInTransaction(Runnable { block() })
|
||||
|
||||
private fun seriesMaster(seriesId: Long): TaskEntity {
|
||||
val row = tasks.entity(seriesId) ?: throw TaskWriteFailedException("series $seriesId")
|
||||
return row.masterId?.let { tasks.entity(it) } ?: row
|
||||
private fun seriesMaster(seriesId: Long): TaskEntity = rowAndMaster(seriesId).second
|
||||
|
||||
/** [taskId]'s row, and its series' master — the row itself unless it is an override. */
|
||||
private fun rowAndMaster(taskId: Long): Pair<TaskEntity, TaskEntity> {
|
||||
val row = tasks.entity(taskId) ?: throw TaskWriteFailedException("series $taskId")
|
||||
return row to (row.masterId?.let { tasks.entity(it) } ?: row)
|
||||
}
|
||||
|
||||
/** The occurrence's own times: its override's if it has one, else generated. */
|
||||
@@ -459,62 +475,354 @@ class RoomTasksDataSource @Inject constructor(
|
||||
|
||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = tx {
|
||||
if (!alarms.syncForTask(taskId, reminders)) return@tx
|
||||
advanced.keepAsOwnTask(taskId)
|
||||
advanced.unlinkDetached(taskId)
|
||||
// The reminders are part of the VTODO now, so changing them is an edit.
|
||||
val current = tasks.entity(taskId) ?: return@tx
|
||||
tasks.update(current.copy(isDirty = true, lastModified = clock.now()))
|
||||
writes.onWritten(setOf(current.listId))
|
||||
}
|
||||
|
||||
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
||||
/** Reopening the copy a ticked occurrence left behind moves its series back, as in [setCompletedInstance]. */
|
||||
override fun setCancelled(taskId: Long, cancelled: Boolean) = tx {
|
||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("cancel task $taskId")
|
||||
tasks.update(TaskFormWriter.cancelled(current, cancelled, clock.now()))
|
||||
saveRow(current, TaskFormWriter.cancelled(current, cancelled, clock.now()))
|
||||
writes.onWritten(setOf(current.listId))
|
||||
}
|
||||
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||
/** Reopening the copy a ticked occurrence left behind moves its series back, as in [setCompletedInstance]. */
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) = tx {
|
||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId")
|
||||
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
|
||||
saveRow(current, TaskFormWriter.completed(current, completed, clock.now()))
|
||||
writes.onWritten(setOf(current.listId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticking one occurrence forks a `RECURRENCE-ID` override carrying the
|
||||
* completion — the same model [updateInstance] writes. Writing the status onto
|
||||
* the master instead would close the series: the master is what
|
||||
* [TaskDao.tasks] filters on, so every occurrence, past and future, would
|
||||
* leave every list at once.
|
||||
* Ticking a series' first open occurrence moves the series on to its next
|
||||
* open one and keeps a completed copy, as tasks.org and Apple Reminders do — a
|
||||
* completed `RECURRENCE-ID` override alone leaves the series showing as open
|
||||
* in most other clients (#137). Closed overrides between become copies too.
|
||||
* Reopening the copy while the series is still where that left it moves it
|
||||
* back. Any other occurrence forks an override carrying the completion, the
|
||||
* same model [updateInstance] writes. Writing the status onto the master
|
||||
* instead would close the series.
|
||||
*/
|
||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) =
|
||||
writeInstance(taskId, occurrenceStart, "complete") { TaskFormWriter.completed(it, completed, clock.now()) }
|
||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = tx {
|
||||
closeOccurrence(taskId, occurrenceStart, completed) { TaskFormWriter.completed(it, completed, clock.now()) }
|
||||
}
|
||||
|
||||
/** Same override model as [setCompletedInstance]. */
|
||||
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) =
|
||||
writeInstance(taskId, occurrenceStart, "cancel") { TaskFormWriter.cancelled(it, cancelled, clock.now()) }
|
||||
/** Same model as [setCompletedInstance]. */
|
||||
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = tx {
|
||||
closeOccurrence(taskId, occurrenceStart, cancelled) { TaskFormWriter.cancelled(it, cancelled, clock.now()) }
|
||||
}
|
||||
|
||||
/** Applies [write] to the override for [occurrenceStart], forking one if there is none yet. */
|
||||
private fun writeInstance(
|
||||
taskId: Long,
|
||||
occurrenceStart: Instant,
|
||||
what: String,
|
||||
write: (TaskEntity) -> TaskEntity,
|
||||
) {
|
||||
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("$what instance $taskId")
|
||||
// Not a series master — an override, or a plain task the caller handed an
|
||||
// anchor for. Either way this row *is* the occurrence.
|
||||
private fun closeOccurrence(taskId: Long, occurrenceStart: Instant, closing: Boolean, close: (TaskEntity) -> TaskEntity) {
|
||||
val (row, master) = rowAndMaster(taskId)
|
||||
if (master.recurrenceSpec() == null) {
|
||||
tasks.update(write(master))
|
||||
writes.onWritten(setOf(master.listId))
|
||||
// A plain task, or the copy a ticked occurrence left: this row is the occurrence.
|
||||
saveRow(row, close(row))
|
||||
writes.onWritten(setOf(row.listId))
|
||||
return
|
||||
}
|
||||
val override = if (row.masterId != null) row else tasks.override(master.id, occurrenceStart)
|
||||
val anchor = override?.recurrenceId ?: occurrenceStart
|
||||
val seriesStart = master.recurrenceAnchor ?: return
|
||||
if (anchor < seriesStart && !sameSecond(anchor, seriesStart)) {
|
||||
// The series has moved past it, so a tick has nothing left to close;
|
||||
// a reopen goes to the copy that occurrence left.
|
||||
when {
|
||||
override != null -> writeInstance(master, override, anchor, close)
|
||||
!closing -> copyAt(master, anchor)?.let { copy ->
|
||||
saveRow(copy, close(copy))
|
||||
writes.onWritten(setOf(copy.listId))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (master.isClosed) {
|
||||
// A series ends with its last occurrence; reopening that one reopens the
|
||||
// series. An override reopens alone while other occurrences are closed
|
||||
// only through the master, which reopening it would reopen too.
|
||||
val reopensSeries = !closing && (override == null || !closedThroughMaster(master, anchor))
|
||||
if (reopensSeries) closeSeries(master, override, close) else writeInstance(master, override, anchor, close)
|
||||
return
|
||||
}
|
||||
if (!closing || !advance(master, anchor, override, close)) writeInstance(master, override, anchor, close)
|
||||
}
|
||||
|
||||
tasks.override(taskId, occurrenceStart)?.let {
|
||||
tasks.update(write(it))
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return
|
||||
/**
|
||||
* Writes [edited] over [current]. A copy whose only change is that it
|
||||
* reopens moves its series back instead; any other edit, [changedBesides]
|
||||
* included, makes it a task of its own, and it no longer stands for the
|
||||
* series' occurrence. So does any edit to an occurrence detached with it.
|
||||
*/
|
||||
private fun saveRow(current: TaskEntity, edited: TaskEntity, changedBesides: Boolean = false) {
|
||||
advanced.unlinkDetached(current.id)
|
||||
val record = advanced.forCopy(current.id)?.takeIf { it.undoable }
|
||||
if (record != null) {
|
||||
val unchanged = !changedBesides && sameApartFromState(current, edited)
|
||||
if (unchanged && current.isClosed && !edited.isClosed && reopenCopy(current, edited, record)) return
|
||||
if (!unchanged || !edited.isClosed) advanced.keepAsOwnTask(current.id)
|
||||
}
|
||||
tasks.update(edited)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing a user sees changed but its state — what saving a reopened copy
|
||||
* from the editor amounts to. Compared as the editor writes them back:
|
||||
* trimmed, blank as none, tags normalised.
|
||||
*/
|
||||
private fun sameApartFromState(a: TaskEntity, b: TaskEntity): Boolean {
|
||||
fun text(value: String?) = value?.trim()?.ifEmpty { null }
|
||||
return text(a.title) == text(b.title) && text(a.description) == text(b.description) &&
|
||||
text(a.location) == text(b.location) && text(a.url) == text(b.url) &&
|
||||
a.priority == b.priority && a.dtstart == b.dtstart && a.due == b.due && a.isAllDay == b.isAllDay &&
|
||||
a.listId == b.listId && a.parentId == b.parentId && a.rrule == b.rrule && a.rdate == b.rdate &&
|
||||
normalizeTags(a.categories) == normalizeTags(b.categories) && a.color == b.color
|
||||
}
|
||||
|
||||
/** Whether an occurrence of [master] other than [except] has no override, so its state is the master's. */
|
||||
private fun closedThroughMaster(master: TaskEntity, except: Instant): Boolean {
|
||||
val spec = master.recurrenceSpec() ?: return false
|
||||
val overridden = tasks.overrides(master.id).mapNotNull { it.recurrenceId?.let(::wholeSecond) }.toSet()
|
||||
return expandFrom(spec, maxOccurrences = overridden.size + 2)
|
||||
.any { !sameSecond(it, except) && wholeSecond(it) !in overridden }
|
||||
}
|
||||
|
||||
/** [close] applied to the master — and to [override], its last occurrence — which ends the series. */
|
||||
private fun closeSeries(master: TaskEntity, override: TaskEntity?, close: (TaskEntity) -> TaskEntity) {
|
||||
tasks.update(close(master))
|
||||
override?.let { tasks.update(close(it)) }
|
||||
writes.onWritten(setOf(master.listId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves [master] past [anchor] when that is its first open occurrence; false
|
||||
* when it is not, so the caller writes an override. Series with `RDATE`s keep
|
||||
* the override model (anchoring on one would re-align the `RRULE`), and so do
|
||||
* recurring subtasks, whose copies would pile up under the parent.
|
||||
*/
|
||||
private fun advance(master: TaskEntity, anchor: Instant, override: TaskEntity?, close: (TaskEntity) -> TaskEntity): Boolean {
|
||||
val rule = master.rrule?.takeIf { it.isNotBlank() } ?: return false
|
||||
if (!master.rdate.isNullOrBlank() || master.parentId != null) return false
|
||||
val spec = master.recurrenceSpec() ?: return false
|
||||
val overrides = tasks.overrides(master.id).associateBy { it.recurrenceId?.epochSeconds }
|
||||
fun closedAt(at: Instant) = overrides[at.epochSeconds]?.isClosed == true
|
||||
val leading = expandFrom(spec, maxOccurrences = overrides.values.count { it.isClosed } + 2)
|
||||
val index = leading.indexOfFirst { sameSecond(it, anchor) }
|
||||
if (index < 0 || leading.take(index).any { !closedAt(it) }) return false
|
||||
var nextIndex = index + 1
|
||||
while (nextIndex < leading.size && closedAt(leading[nextIndex])) nextIndex++
|
||||
val next = leading.getOrNull(nextIndex) ?: return true.also { closeSeries(master, override, close) }
|
||||
|
||||
val now = clock.now()
|
||||
val zone = master.timezone ?: zone()
|
||||
val detached = (leading.take(index) + leading.subList(index + 1, nextIndex)).map { at ->
|
||||
at to detach(overrides.getValue(at.epochSeconds), now)
|
||||
}
|
||||
// Expansion drops the anchor's sub-second part; the series keeps it.
|
||||
val newAnchor = next + (spec.anchor - wholeSecond(spec.anchor))
|
||||
val spent = spentBefore(spec, next)
|
||||
val (start, due) = occurrenceTimes(master, newAnchor)
|
||||
val ruleAfter = TaskFormWriter.remainingCount(rule, spent)
|
||||
tasks.update(
|
||||
master.copy(
|
||||
dtstart = start,
|
||||
due = due,
|
||||
rrule = ruleAfter,
|
||||
exdate = TaskFormWriter.valuesFrom(master.exdate, newAnchor, zone),
|
||||
status = TaskStatus.NEEDS_ACTION,
|
||||
percentComplete = null,
|
||||
completedAt = null,
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
),
|
||||
)
|
||||
val copyId = if (override != null) {
|
||||
detach(override, now, close)
|
||||
} else {
|
||||
val (copyStart, copyDue) = occurrenceTimes(master, anchor)
|
||||
val id = tasks.insert(close(asCopy(master, now).copy(dtstart = copyStart, due = copyDue)))
|
||||
alarms.copyToTask(master.id, id, sameResource = false)
|
||||
id
|
||||
}
|
||||
advanced.at(master.id, wholeSecond(anchor))?.let { advanced.delete(it.copyId) }
|
||||
advanced.pruneTickedBefore(master.id, now - RECORD_HORIZON)
|
||||
advanced.insert(
|
||||
AdvancedOccurrenceEntity(
|
||||
copyId = copyId,
|
||||
masterId = master.id,
|
||||
anchor = anchor,
|
||||
advancedTo = newAnchor,
|
||||
priorDtstart = master.dtstart,
|
||||
priorDue = master.due,
|
||||
priorStatus = master.status,
|
||||
priorPercentComplete = master.percentComplete,
|
||||
priorCompletedAt = master.completedAt,
|
||||
spent = spent,
|
||||
ruleAfter = ruleAfter,
|
||||
droppedExdates = AdvancedOccurrenceEntity.encode(TaskFormWriter.instantsBefore(master.exdate, newAnchor, zone)),
|
||||
fromOverride = override != null,
|
||||
),
|
||||
)
|
||||
advanced.insertDetached(detached.map { (at, id) -> AdvancedDetachedEntity(copyId = copyId, anchor = at, taskId = id) })
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return true
|
||||
}
|
||||
|
||||
/** Occurrences `COUNT` has spent before [cut]: counted before `EXDATE` (RFC 5545 §3.8.5.3). */
|
||||
private fun spentBefore(spec: RecurrenceSpec, cut: Instant): Int = RecurrenceExpander.expand(
|
||||
spec.copy(exdate = null),
|
||||
ExpansionWindow(from = wholeSecond(spec.anchor), until = cut, maxOccurrences = SPLIT_COUNT_CEILING),
|
||||
).size
|
||||
|
||||
/**
|
||||
* Turns the override [row] into a task of its own, in place so its id keeps
|
||||
* working: a new UID, unlinked from the series, a resource of its own — and
|
||||
* so its alarms are fresh copies, not the series' ones.
|
||||
*/
|
||||
private fun detach(row: TaskEntity, now: Instant, close: (TaskEntity) -> TaskEntity = { it }): Long {
|
||||
tasks.update(close(asCopy(row, now).copy(id = row.id)))
|
||||
refreshAlarms(row.id)
|
||||
return row.id
|
||||
}
|
||||
|
||||
private fun refreshAlarms(taskId: Long) {
|
||||
val current = alarms.forTask(taskId)
|
||||
alarms.deleteForTask(taskId)
|
||||
current.forEach { alarms.insert(it.freshCopy(taskId)) }
|
||||
}
|
||||
|
||||
/** [source] as a standalone task: no series fields, never uploaded. */
|
||||
private fun asCopy(source: TaskEntity, now: Instant): TaskEntity = source.copy(
|
||||
id = 0,
|
||||
uid = UUID.randomUUID().toString(),
|
||||
masterId = null,
|
||||
recurrenceId = null,
|
||||
href = null,
|
||||
etag = null,
|
||||
rrule = null,
|
||||
rdate = null,
|
||||
exdate = null,
|
||||
createdAt = now,
|
||||
sequence = 0,
|
||||
isDeleted = false,
|
||||
isDirty = true,
|
||||
unknownProperties = seriesFreeText(source.unknownProperties),
|
||||
)
|
||||
|
||||
/**
|
||||
* Undoes [advance] for the copy [row] while the series still sits where it
|
||||
* moved it under the same rule, and the copy is still just that — same list,
|
||||
* nothing filed under it. The anchor goes back, `COUNT` gets back what it
|
||||
* spent, and the dropped `EXDATE`s return in the list's own shape; any other
|
||||
* edit made since stays. The occurrence takes [reopened]'s state: as a plain
|
||||
* occurrence again, or as an override where that state is its own. The
|
||||
* occurrences detached with it become overrides again, or are excluded where
|
||||
* their row is gone or has a life of its own. False when it cannot, so the
|
||||
* caller reopens the copy as a task of its own.
|
||||
*/
|
||||
private fun reopenCopy(row: TaskEntity, reopened: TaskEntity, record: AdvancedOccurrenceEntity): Boolean {
|
||||
val master = tasks.entity(record.masterId)?.takeIf {
|
||||
!it.isDeleted && !it.isClosed && row.listId == it.listId && sameRule(it.rrule, record.ruleAfter) &&
|
||||
it.recurrenceAnchor?.let { a -> sameSecond(a, record.advancedTo) } == true
|
||||
}
|
||||
if (master == null || tasks.subtasks(row.id).isNotEmpty()) return false
|
||||
val now = clock.now()
|
||||
val zone = master.timezone ?: zone()
|
||||
val synced = lists.entity(master.listId)?.accountId != null
|
||||
val (start, due) = occurrenceTimes(master, record.priorDtstart ?: record.priorDue ?: record.anchor)
|
||||
var back = master.copy(
|
||||
dtstart = start,
|
||||
due = due,
|
||||
rrule = master.rrule?.let { TaskFormWriter.remainingCount(it, -record.spent) },
|
||||
status = if (master.status == TaskStatus.NEEDS_ACTION) record.priorStatus else master.status,
|
||||
percentComplete = master.percentComplete ?: record.priorPercentComplete,
|
||||
completedAt = master.completedAt ?: record.priorCompletedAt,
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
)
|
||||
record.dropped.forEach { back = TaskFormWriter.excepting(back, it, now, zone, dirty = synced) }
|
||||
// A detached row given a parent or subtasks since has a life of its own, and stays out.
|
||||
fun returns(task: TaskEntity?) = task != null && !task.isDeleted && task.listId == master.listId &&
|
||||
task.parentId == master.parentId && tasks.subtasks(task.id).isEmpty()
|
||||
val detached = advanced.detachedFor(row.id).map { it.anchor to it.taskId?.let(tasks::entity) }
|
||||
detached.forEach { (at, task) -> if (!returns(task)) back = TaskFormWriter.excepting(back, at, now, zone, dirty = synced) }
|
||||
tasks.update(back)
|
||||
detached.forEach { (at, task) -> if (returns(task)) toOverride(task!!, back, at, now) }
|
||||
advanced.delete(row.id)
|
||||
val plain = reopened.status == TaskStatus.NEEDS_ACTION && reopened.percentComplete == null
|
||||
if (record.fromOverride || !plain) toOverride(reopened, back, record.anchor, now)
|
||||
else deleteTask(row.id)
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return true
|
||||
}
|
||||
|
||||
/** The same `RRULE` however a server re-serialised it: parts in any order and case. */
|
||||
private fun sameRule(a: String?, b: String?): Boolean {
|
||||
fun parts(rule: String?) = rule?.trim()?.removePrefix("RRULE:")?.split(';')
|
||||
?.map { it.trim().uppercase() }?.filter { it.isNotEmpty() }?.toSet()
|
||||
return parts(a) == parts(b)
|
||||
}
|
||||
|
||||
/** [row], a detached occurrence, back as [master]'s override for [anchor]. */
|
||||
private fun toOverride(row: TaskEntity, master: TaskEntity, anchor: Instant, now: Instant) {
|
||||
val back = row.copy(
|
||||
uid = master.uid,
|
||||
masterId = master.id,
|
||||
recurrenceId = anchor,
|
||||
href = null,
|
||||
etag = null,
|
||||
isDirty = true,
|
||||
lastModified = now,
|
||||
unknownProperties = seriesFreeText(row.unknownProperties),
|
||||
)
|
||||
if (row.href == null) {
|
||||
tasks.update(back)
|
||||
refreshAlarms(row.id)
|
||||
} else {
|
||||
// Already uploaded as a resource of its own: that one goes, a fresh override comes.
|
||||
val id = tasks.insert(back.copy(id = 0))
|
||||
alarms.copyToTask(row.id, id, sameResource = false)
|
||||
deleteTask(row.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** The copy [advance] left for [master]'s occurrence at [anchor], if it is still there. */
|
||||
private fun copyAt(master: TaskEntity, anchor: Instant): TaskEntity? =
|
||||
advanced.at(master.id, wholeSecond(anchor))?.let { tasks.entity(it.copyId) }?.takeUnless { it.isDeleted }
|
||||
|
||||
/** The series' occurrences from its anchor on, at most [maxOccurrences]. */
|
||||
private fun expandFrom(spec: RecurrenceSpec, maxOccurrences: Int): List<Instant> {
|
||||
val from = wholeSecond(spec.anchor)
|
||||
return RecurrenceExpander.expand(spec, ExpansionWindow(from = from, until = from + SERIES_LOOKAHEAD, maxOccurrences = maxOccurrences))
|
||||
}
|
||||
|
||||
/**
|
||||
* The residue without the series' own lines, as a new resource must not
|
||||
* carry them. Lines inside a nested component (a `VALARM`'s `UID`) are its
|
||||
* own and stay.
|
||||
*/
|
||||
private fun seriesFreeText(unknown: String?): String? {
|
||||
var depth = 0
|
||||
return unknown?.lines()?.filter { line ->
|
||||
when {
|
||||
line.startsWith("BEGIN:", ignoreCase = true) -> { depth++; true }
|
||||
line.startsWith("END:", ignoreCase = true) -> { depth--; true }
|
||||
else -> depth > 0 || (line.isNotBlank() && SERIES_LINES.none { line.startsWith(it, ignoreCase = true) })
|
||||
}
|
||||
}?.joinToString("\r\n")?.ifBlank { null }
|
||||
}
|
||||
|
||||
/** Applies [write] to [override], or to a new one forked from [master] for [occurrenceStart]. */
|
||||
private fun writeInstance(master: TaskEntity, override: TaskEntity?, occurrenceStart: Instant, write: (TaskEntity) -> TaskEntity) {
|
||||
if (override != null) {
|
||||
tasks.update(write(override))
|
||||
} else {
|
||||
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||
val id = tasks.insert(write(newOverride(master, master.id, occurrenceStart, start, due)))
|
||||
alarms.copyToTask(master.id, id, sameResource = true)
|
||||
}
|
||||
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||
val id = tasks.insert(write(newOverride(master, taskId, occurrenceStart, start, due)))
|
||||
alarms.copyToTask(taskId, id, sameResource = true)
|
||||
writes.onWritten(setOf(master.listId))
|
||||
}
|
||||
|
||||
@@ -607,7 +915,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
||||
move(current, edited)
|
||||
}
|
||||
else -> tasks.update(edited)
|
||||
else -> saveRow(current, edited)
|
||||
}
|
||||
}
|
||||
writes.onWritten(setOf(current.listId, edited.listId))
|
||||
@@ -650,8 +958,12 @@ class RoomTasksDataSource @Inject constructor(
|
||||
|
||||
// --- observation ----------------------------------------------------------
|
||||
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||
val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) {
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable = observe(OBSERVED_TABLES, onChange)
|
||||
|
||||
override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = observe(TASK_TABLES, onChange)
|
||||
|
||||
private fun observe(tables: Array<String>, onChange: () -> Unit): AutoCloseable {
|
||||
val observer = object : InvalidationTracker.Observer(tables) {
|
||||
override fun onInvalidated(tables: Set<String>) = onChange()
|
||||
}
|
||||
database.invalidationTracker.addObserver(observer)
|
||||
|
||||
@@ -199,4 +199,8 @@ interface TaskDao {
|
||||
*/
|
||||
@Query("UPDATE tasks SET href = :href, etag = :etag, is_dirty = 0 WHERE id IN (:taskIds)")
|
||||
fun markSynced(taskIds: List<Long>, href: String?, etag: String?): Int
|
||||
|
||||
/** Sets `is_dirty` again on [taskId] when it was written after [seen], its `last_modified` when uploaded. */
|
||||
@Query("UPDATE tasks SET is_dirty = 1 WHERE id = :taskId AND last_modified IS NOT :seen")
|
||||
fun dirtyIfChangedSince(taskId: Long, seen: Instant?)
|
||||
}
|
||||
@@ -120,18 +120,25 @@ object TaskFormWriter {
|
||||
fun valuesBefore(values: String?, cut: Instant, zone: String): String? =
|
||||
mapValues(values, zone) { it.takeIf { at -> at < cut } }
|
||||
|
||||
/** The instants of an `EXDATE`/`RDATE` list before [cut]; values it cannot read are left out. */
|
||||
fun instantsBefore(values: String?, cut: Instant, zone: String): List<Instant> =
|
||||
parsed(values, zone).mapNotNull { it.second }.filter { it < cut }
|
||||
|
||||
/** The values of an `EXDATE`/`RDATE` list at or after [cut]. */
|
||||
fun valuesFrom(values: String?, cut: Instant, zone: String): String? =
|
||||
mapValues(values, zone) { it.takeIf { at -> at >= cut } }
|
||||
|
||||
private fun mapValues(values: String?, zone: String, map: (Instant) -> Instant?): String? {
|
||||
val list = values?.split(',')?.map(String::trim)?.filter(String::isNotEmpty) ?: return values
|
||||
return list.mapNotNull { raw ->
|
||||
val at = readValue(raw, zone) ?: return@mapNotNull raw
|
||||
map(at)?.let { writeValueLike(raw, it, zone) }
|
||||
if (values == null) return null
|
||||
return parsed(values, zone).mapNotNull { (raw, at) ->
|
||||
if (at == null) raw else map(at)?.let { writeValueLike(raw, it, zone) }
|
||||
}.joinToString(",").ifEmpty { null }
|
||||
}
|
||||
|
||||
/** Each value of an `EXDATE`/`RDATE` list with its instant, or null where it cannot be read. */
|
||||
private fun parsed(values: String?, zone: String): List<Pair<String, Instant?>> =
|
||||
values?.split(',')?.map(String::trim)?.filter(String::isNotEmpty)?.map { it to readValue(it, zone) }.orEmpty()
|
||||
|
||||
private fun readValue(raw: String, zone: String): Instant? = runCatching {
|
||||
val z = java.time.ZoneId.of(zone)
|
||||
val instant = when {
|
||||
|
||||
@@ -18,7 +18,7 @@ interface TaskListDao {
|
||||
"""
|
||||
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label
|
||||
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
||||
ORDER BY a.display_name, l.sort_order, l.name
|
||||
ORDER BY a.display_name COLLATE NOCASE, l.sort_order, l.name COLLATE NOCASE, l.name
|
||||
"""
|
||||
)
|
||||
fun lists(): List<TaskListRow>
|
||||
@@ -32,6 +32,10 @@ interface TaskListDao {
|
||||
)
|
||||
fun list(listId: Long): TaskListRow?
|
||||
|
||||
/** The highest position among device-only lists, -1 when there are none. */
|
||||
@Query("SELECT COALESCE(MAX(sort_order), -1) FROM task_lists WHERE account_id IS NULL")
|
||||
fun lastLocalSortOrder(): Int
|
||||
|
||||
@Query("SELECT * FROM task_lists WHERE id = :listId")
|
||||
fun entity(listId: Long): TaskListEntity?
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import java.util.UUID
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Agendula's own task store. Four tables, designed from
|
||||
* Agendula's own task store. Six tables, designed from
|
||||
* what the app actually reads and writes plus RFC 5545's `VTODO`.
|
||||
*
|
||||
* Schemas are exported to `app/schemas/` and committed, so a future version can
|
||||
@@ -21,8 +21,10 @@ import kotlin.time.Instant
|
||||
TaskListEntity::class,
|
||||
TaskEntity::class,
|
||||
TaskAlarmEntity::class,
|
||||
AdvancedOccurrenceEntity::class,
|
||||
AdvancedDetachedEntity::class,
|
||||
],
|
||||
version = 4,
|
||||
version = 5,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -31,6 +33,7 @@ abstract class TasksDatabase : RoomDatabase() {
|
||||
abstract fun tasks(): TaskDao
|
||||
abstract fun alarms(): TaskAlarmDao
|
||||
abstract fun accounts(): AccountDao
|
||||
abstract fun advancedOccurrences(): AdvancedOccurrenceDao
|
||||
|
||||
companion object {
|
||||
const val NAME = "agendula-tasks.db"
|
||||
@@ -180,5 +183,26 @@ abstract class TasksDatabase : RoomDatabase() {
|
||||
db.execSQL("ALTER TABLE accounts ADD COLUMN label TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
/** v5 remembers how ticking an occurrence moved its series, so reopening it can move it back. */
|
||||
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `advanced_occurrences` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `rule_after` TEXT, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, `undoable` INTEGER NOT NULL DEFAULT 1, `settled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` ON `advanced_occurrences` (`master_id`, `anchor`)",
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `advanced_detached` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `copy_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `task_id` INTEGER, FOREIGN KEY(`copy_id`) REFERENCES `advanced_occurrences`(`copy_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_advanced_detached_copy_id` ON `advanced_detached` (`copy_id`)",
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_advanced_detached_task_id` ON `advanced_detached` (`task_id`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,9 @@ class ExternalImport @Inject constructor(
|
||||
val taskDao = database.tasks()
|
||||
val alarmDao = database.alarms()
|
||||
|
||||
val listIds = snapshot.lists.associate { list ->
|
||||
// After the device-only lists already there, which the copies join.
|
||||
val after = listDao.lastLocalSortOrder() + 1
|
||||
val listIds = snapshot.lists.withIndex().associate { (position, list) ->
|
||||
list.id to listDao.insert(
|
||||
TaskListEntity(
|
||||
name = list.name,
|
||||
@@ -168,6 +170,7 @@ class ExternalImport @Inject constructor(
|
||||
isVisible = list.isVisible,
|
||||
isSynced = false,
|
||||
owner = list.owner,
|
||||
sortOrder = after + position,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ data class TaskList(
|
||||
|
||||
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
||||
|
||||
/** Completed or cancelled. */
|
||||
val TaskStatus.isClosed: Boolean get() = this == TaskStatus.COMPLETED || this == TaskStatus.CANCELLED
|
||||
|
||||
/** iCalendar priority, bucketed for the UI. */
|
||||
enum class Priority { NONE, LOW, MEDIUM, HIGH }
|
||||
|
||||
@@ -111,7 +114,7 @@ data class Task(
|
||||
val isOccurrence: Boolean get() = seriesId != null && occurrenceStart != null
|
||||
|
||||
val isCompleted: Boolean get() = status == TaskStatus.COMPLETED
|
||||
val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED
|
||||
val isClosed: Boolean get() = status.isClosed
|
||||
val isSubtask: Boolean get() = parentId != null && parentId > 0
|
||||
/** The task's own colour if set, else the list colour. */
|
||||
val effectiveColor: Int get() = taskColor ?: listColor
|
||||
|
||||
@@ -59,7 +59,7 @@ fun dueWindowOf(
|
||||
todayEnd: Instant,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
): DueWindow {
|
||||
val due = task.due?.let { if (task.isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
|
||||
val due = task.wallClock(task.due, zone)
|
||||
return when {
|
||||
due == null -> DueWindow.NO_DATE
|
||||
due < todayStart -> DueWindow.OVERDUE
|
||||
@@ -68,6 +68,10 @@ fun dueWindowOf(
|
||||
}
|
||||
}
|
||||
|
||||
/** [at], one of this task's dates, as a local instant: an all-day date is read as its calendar date. */
|
||||
fun Task.wallClock(at: Instant?, zone: ZoneId = ZoneId.systemDefault()): Instant? =
|
||||
at?.let { if (isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
|
||||
|
||||
/** What a task created from a [SavedFilter] starts with, so it lands in that filter. */
|
||||
data class NewTaskPreset(
|
||||
val listIds: Set<Long> = emptySet(),
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** The built-in "smart" lists, computed from due dates rather than membership. */
|
||||
/**
|
||||
* The built-in "smart" lists, computed from dates rather than membership. All
|
||||
* but [COMPLETED] hold open tasks, except [TODAY], which also keeps the ones
|
||||
* completed today (see [TaskFiltering.isToday]).
|
||||
*/
|
||||
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
|
||||
|
||||
/** What a task screen is showing: one real list, a smart list, or a tag. */
|
||||
@@ -40,6 +45,20 @@ object TaskFiltering {
|
||||
is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [task] belongs in Today. An open task does when it is due today, or
|
||||
* starts today without being overdue; a completed one when it was completed today.
|
||||
*/
|
||||
fun isToday(task: Task, todayStart: Instant, todayEnd: Instant, zone: ZoneId = ZoneId.systemDefault()): Boolean {
|
||||
fun inToday(at: Instant?) = at != null && at >= todayStart && at < todayEnd
|
||||
if (task.isClosed) return task.isCompleted && inToday(task.completedAt)
|
||||
return when (dueWindowOf(task, todayStart, todayEnd, zone)) {
|
||||
DueWindow.TODAY -> true
|
||||
DueWindow.OVERDUE -> false
|
||||
DueWindow.UPCOMING, DueWindow.NO_DATE -> inToday(task.wallClock(task.start, zone))
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether [task] carries every one of [tags]; an empty set matches everything. */
|
||||
fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags)
|
||||
|
||||
@@ -49,7 +68,7 @@ object TaskFiltering {
|
||||
SmartList.ALL -> !task.isClosed
|
||||
SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE
|
||||
SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE
|
||||
SmartList.TODAY -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.TODAY
|
||||
SmartList.TODAY -> isToday(task, todayStart, todayEnd)
|
||||
SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,22 @@ data class SectionedTasks(val section: TaskSection, val tasks: List<Task>)
|
||||
*
|
||||
* [todayStart] is local midnight today and [todayEnd] local midnight tomorrow
|
||||
* (compute with [DayWindow]). Only non-empty sections are returned, in the
|
||||
* fixed order Overdue → Today → Upcoming → No date → Completed.
|
||||
* fixed order Overdue → Today → Upcoming → No date → Completed. In the Today
|
||||
* list ([todayList]) every open task sits under Today, as it may be there by its
|
||||
* start date alone.
|
||||
*/
|
||||
object TaskSections {
|
||||
|
||||
fun of(tasks: List<Task>, todayStart: Instant, todayEnd: Instant): List<SectionedTasks> {
|
||||
val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd) }
|
||||
fun of(tasks: List<Task>, todayStart: Instant, todayEnd: Instant, todayList: Boolean = false): List<SectionedTasks> {
|
||||
val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd, todayList) }
|
||||
return TaskSection.entries.mapNotNull { section ->
|
||||
bySection[section]?.takeIf { it.isNotEmpty() }?.let { SectionedTasks(section, it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
|
||||
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant, todayList: Boolean = false): TaskSection {
|
||||
if (task.isCompleted) return TaskSection.COMPLETED
|
||||
if (todayList) return TaskSection.TODAY
|
||||
return when (dueWindowOf(task, todayStart, todayEnd)) {
|
||||
DueWindow.OVERDUE -> TaskSection.OVERDUE
|
||||
DueWindow.TODAY -> TaskSection.TODAY
|
||||
|
||||
@@ -6,7 +6,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
|
||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
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
|
||||
@@ -109,7 +108,6 @@ data class TaskEditUiState(
|
||||
class TaskEditViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val reminderScheduler: ReminderScheduler,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(TaskEditUiState())
|
||||
@@ -149,6 +147,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
private var loadedReminders: List<TaskReminder> = emptyList()
|
||||
private var loadedStart: Instant? = null
|
||||
private var loadedDue: Instant? = null
|
||||
private var loadedAllDay = false
|
||||
|
||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||
fun bindNew(
|
||||
@@ -157,6 +156,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
initialTitle: String? = null,
|
||||
presetTag: String? = null,
|
||||
presetFilterId: Long? = null,
|
||||
presetDueToday: Boolean = false,
|
||||
) {
|
||||
if (bound) return
|
||||
bound = true
|
||||
@@ -169,6 +169,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
|
||||
?.newTaskPreset(LocalDate.now())
|
||||
val presetDue = preset?.dueDate ?: LocalDate.now().takeIf { presetDueToday }
|
||||
// A read-only or vanished default — or a widget's preset list, if it was
|
||||
// since deleted or turned read-only — would give the task a list it
|
||||
// cannot be saved to.
|
||||
@@ -190,12 +191,12 @@ class TaskEditViewModel @Inject constructor(
|
||||
// today's start would land after a due of today.
|
||||
start = when {
|
||||
!settings.defaultStartToday -> null
|
||||
preset?.dueDate != null -> allDayInstantOf(LocalDate.now())
|
||||
presetDue != null -> allDayInstantOf(LocalDate.now())
|
||||
else -> Clock.System.now()
|
||||
},
|
||||
// The same all-day value picking the date in the date field gives.
|
||||
due = preset?.dueDate?.let(::allDayInstantOf),
|
||||
isAllDay = preset?.dueDate != null,
|
||||
due = presetDue?.let(::allDayInstantOf),
|
||||
isAllDay = presetDue != null,
|
||||
priority = preset?.priority ?: Priority.NONE,
|
||||
parentId = parentId,
|
||||
lists = lists,
|
||||
@@ -228,6 +229,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
storedReminders = loadedReminders.toSet()
|
||||
loadedStart = task.start
|
||||
loadedDue = task.due
|
||||
loadedAllDay = task.isAllDay
|
||||
_state.value = withFields(
|
||||
TaskEditUiState(
|
||||
loading = false,
|
||||
@@ -362,6 +364,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
toForm().copy(reminders = loadedReminders),
|
||||
loadedStart,
|
||||
loadedDue,
|
||||
loadedAllDay,
|
||||
settings,
|
||||
nowMillis(),
|
||||
ZoneId.systemDefault(),
|
||||
@@ -446,13 +449,6 @@ class TaskEditViewModel @Inject constructor(
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
// ⚠️ Outside the write's own result. The task is saved by this
|
||||
// point, and reporting a scheduling failure as a *save* failure
|
||||
// sends the user back to tap Save again — on a screen whose
|
||||
// editingTaskId is still null, which creates a second task. The
|
||||
// reminder is re-synced on the next data change and on launch;
|
||||
// the duplicate is forever.
|
||||
runCatching { reminderScheduler.sync() }
|
||||
}.onSuccess {
|
||||
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
|
||||
}.onFailure { error ->
|
||||
|
||||
@@ -172,14 +172,12 @@ class ListsViewModel @Inject constructor(
|
||||
SmartCount(SmartList.ALL, smartTopLevel.size),
|
||||
)
|
||||
|
||||
// Today ring: completed vs. total tasks *due today*. The numerator is the
|
||||
// top-level completed tasks whose due date falls in today's window; the
|
||||
// denominator adds the still-open ones (the Today smart count above).
|
||||
// Today ring: the completed rows of the Today list against all of its rows.
|
||||
val openToday = count(SmartList.TODAY)
|
||||
val completedDueToday = completedTasks.count {
|
||||
!it.isSubtask && it.listId !in hidden && it.due != null && it.due >= todayStart && it.due < todayEnd
|
||||
val completedToday = completedTasks.count {
|
||||
!it.isSubtask && it.listId !in hidden && TaskFiltering.isToday(it, todayStart, todayEnd)
|
||||
}
|
||||
val todayTotal = openToday + completedDueToday
|
||||
val todayTotal = openToday + completedToday
|
||||
|
||||
// Upcoming preview: the next handful of open tasks due tomorrow onward,
|
||||
// already sorted by the repository's default ordering.
|
||||
@@ -214,7 +212,7 @@ class ListsViewModel @Inject constructor(
|
||||
smartCounts = smartCounts,
|
||||
filters = filters,
|
||||
groups = groups,
|
||||
todayDone = completedDueToday,
|
||||
todayDone = completedToday,
|
||||
todayTotal = todayTotal,
|
||||
upcoming = upcoming,
|
||||
allTasks = allTasks,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.ui.navigation
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -17,6 +18,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import kotlin.time.Instant
|
||||
import androidx.navigation.compose.NavHost
|
||||
@@ -56,6 +58,8 @@ fun AgendulaNavHost(
|
||||
val nav = rememberNavController()
|
||||
// The entry a widget, tile, shortcut or share opened; closing it leaves the app (#93).
|
||||
var returnHomeEntry by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
// No public API exposes the whole back stack, and visibleEntries drops an entry covered by another.
|
||||
@SuppressLint("RestrictedApi")
|
||||
val backStack by nav.currentBackStack.collectAsState()
|
||||
LaunchedEffect(backStack, returnHomeEntry) {
|
||||
val id = returnHomeEntry ?: return@LaunchedEffect
|
||||
@@ -75,7 +79,9 @@ fun AgendulaNavHost(
|
||||
is NavRequest.OpenAccount ->
|
||||
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
|
||||
is NavRequest.NewTask ->
|
||||
nav.navigate(Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title))
|
||||
nav.navigate(
|
||||
Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title, dueToday = navRequest.dueToday),
|
||||
)
|
||||
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
@@ -160,6 +166,7 @@ fun AgendulaNavHost(
|
||||
presetListId = (filter as? TaskFilter.OfList)?.listId,
|
||||
tag = (filter as? TaskFilter.Tagged)?.tag,
|
||||
filterId = (filter as? TaskFilter.Saved)?.id,
|
||||
dueToday = filter == TaskFilter.Smart(SmartList.TODAY),
|
||||
),
|
||||
)
|
||||
},
|
||||
@@ -231,6 +238,7 @@ fun AgendulaNavHost(
|
||||
val title = args?.getString(Dest.TaskEdit.ARG_TITLE)
|
||||
val tag = args?.getString(Dest.TaskEdit.ARG_TAG)
|
||||
val filterId = args?.getLong(Dest.TaskEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
|
||||
val dueToday = args?.getBoolean(Dest.TaskEdit.ARG_DUE_TODAY) ?: false
|
||||
val vm: TaskEditViewModel = hiltViewModel()
|
||||
LaunchedEffect(taskId, presetListId, parentId) {
|
||||
if (taskId != Dest.NO_ID) {
|
||||
@@ -242,6 +250,7 @@ fun AgendulaNavHost(
|
||||
initialTitle = title,
|
||||
presetTag = tag,
|
||||
presetFilterId = filterId,
|
||||
presetDueToday = dueToday,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ object Dest {
|
||||
/**
|
||||
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
|
||||
* task, `presetListId` / `parentId` optionally seed the form, and `filterId`
|
||||
* fills it so the task lands in that saved filter.
|
||||
* (or `dueToday`, for the Today list) fills it so the task lands in that view.
|
||||
*/
|
||||
object TaskEdit {
|
||||
const val ARG_TASK_ID = "taskId"
|
||||
@@ -129,6 +129,7 @@ object Dest {
|
||||
const val ARG_TITLE = "title"
|
||||
const val ARG_TAG = "tag"
|
||||
const val ARG_FILTER_ID = "filterId"
|
||||
const val ARG_DUE_TODAY = "dueToday"
|
||||
const val route =
|
||||
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
|
||||
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
|
||||
@@ -136,7 +137,8 @@ object Dest {
|
||||
"&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" +
|
||||
"&$ARG_TITLE={$ARG_TITLE}" +
|
||||
"&$ARG_TAG={$ARG_TAG}" +
|
||||
"&$ARG_FILTER_ID={$ARG_FILTER_ID}"
|
||||
"&$ARG_FILTER_ID={$ARG_FILTER_ID}" +
|
||||
"&$ARG_DUE_TODAY={$ARG_DUE_TODAY}"
|
||||
|
||||
val arguments = listOf(
|
||||
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
@@ -146,6 +148,7 @@ object Dest {
|
||||
navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
navArgument(ARG_DUE_TODAY) { type = NavType.BoolType; defaultValue = false },
|
||||
)
|
||||
|
||||
fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
|
||||
@@ -158,6 +161,7 @@ object Dest {
|
||||
title: String? = null,
|
||||
tag: String? = null,
|
||||
filterId: Long? = null,
|
||||
dueToday: Boolean = false,
|
||||
): String {
|
||||
val params = buildList {
|
||||
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
|
||||
@@ -165,6 +169,7 @@ object Dest {
|
||||
if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}")
|
||||
if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}")
|
||||
if (filterId != null) add("$ARG_FILTER_ID=$filterId")
|
||||
if (dueToday) add("$ARG_DUE_TODAY=true")
|
||||
}
|
||||
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
|
||||
}
|
||||
|
||||
@@ -27,12 +27,14 @@ sealed interface NavRequest {
|
||||
|
||||
/**
|
||||
* A new task, optionally titled with text shared from another app, or
|
||||
* preset to a list — a widget's own add button, when it shows one list.
|
||||
* preset to a list — a widget's own add button, when it shows one list —
|
||||
* or due today, from a Today widget.
|
||||
* [returnHome] leaves the app once the form is closed (#93).
|
||||
*/
|
||||
data class NewTask(
|
||||
val title: String? = null,
|
||||
val listId: Long? = null,
|
||||
val dueToday: Boolean = false,
|
||||
val returnHome: Boolean = false,
|
||||
) : NavRequest
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -23,8 +25,10 @@ import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.LauncherMark
|
||||
import de.jeanlucmakiola.floret.components.LauncherNameChooser
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
@@ -106,15 +110,25 @@ internal fun AppearanceScreen(
|
||||
}
|
||||
|
||||
if (showAppName) {
|
||||
OptionPicker(
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_app_name),
|
||||
options = LauncherName.entries,
|
||||
selected = launcherName,
|
||||
label = { launcherNameLabel(it) },
|
||||
header = { GroupedHint(stringResource(R.string.settings_app_name_hint)) },
|
||||
onSelect = viewModel::setLauncherName,
|
||||
onDismiss = { showAppName = false },
|
||||
)
|
||||
predictiveBack = true,
|
||||
) {
|
||||
LauncherNameChooser(
|
||||
summary = stringResource(R.string.settings_app_name_hint),
|
||||
options = LauncherName.entries,
|
||||
selected = launcherName,
|
||||
label = { launcherNameLabel(it) },
|
||||
onSelect = viewModel::setLauncherName,
|
||||
mark = {
|
||||
LauncherMark(
|
||||
background = colorResource(R.color.ic_launcher_background),
|
||||
foreground = painterResource(R.drawable.ic_launcher_foreground),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showTimeFormat) {
|
||||
|
||||
@@ -188,7 +188,6 @@ class SettingsViewModel @Inject constructor(
|
||||
transferState.update { it.copy(confirming = false, running = true, outcome = null) }
|
||||
viewModelScope.launch {
|
||||
val result = externalImport.run()
|
||||
if (result is TransferResult.Copied) runCatching { reminderScheduler.sync() }
|
||||
transferState.update {
|
||||
it.copy(
|
||||
running = false,
|
||||
@@ -212,7 +211,6 @@ class SettingsViewModel @Inject constructor(
|
||||
retryingLegacyImport.value = true
|
||||
viewModelScope.launch {
|
||||
runCatching { oneShotImport.reimportFromArchive() }
|
||||
runCatching { reminderScheduler.sync() }
|
||||
retryingLegacyImport.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +367,7 @@ private fun TaskListBody(
|
||||
viewModel: TaskListViewModel,
|
||||
onClearCompleted: (() -> Unit)? = null,
|
||||
) {
|
||||
val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
|
||||
val (todayStart, todayEnd) = remember(state.tasks) { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
|
||||
// A parent represents its children via a progress chip (counts come from the
|
||||
// repository, over the full set), so drop children from the flat list — unless
|
||||
// their parent isn't in this view (can happen in smart lists), where we keep
|
||||
@@ -376,7 +376,9 @@ private fun TaskListBody(
|
||||
val present = state.tasks.mapTo(HashSet()) { it.taskId }
|
||||
state.tasks.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
|
||||
}
|
||||
val sections = remember(displayTasks) { TaskSections.of(displayTasks, todayStart, todayEnd) }
|
||||
val sections = remember(displayTasks, todayStart) {
|
||||
TaskSections.of(displayTasks, todayStart, todayEnd, todayList = filter == TaskFilter.Smart(SmartList.TODAY))
|
||||
}
|
||||
val showHeaders = sections.size > 1
|
||||
val listId = (filter as? TaskFilter.OfList)?.listId
|
||||
// In a mixed view (a smart list) the list colour bar + name disambiguate
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.agendula.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.UiModeManager
|
||||
import android.os.Build
|
||||
import androidx.annotation.StyleRes
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
|
||||
/**
|
||||
* Hands the app's theme setting to the system, which draws the launch window
|
||||
* and splash before any of the app runs (#119). The system keeps both across
|
||||
* launches, so the next start already matches.
|
||||
*/
|
||||
object SystemTheme {
|
||||
|
||||
/** The light or dark the app's resources follow, as [UiModeManager.setApplicationNightMode] takes it. */
|
||||
fun nightModeOf(mode: ThemeMode): Int = when (mode) {
|
||||
ThemeMode.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO
|
||||
ThemeMode.LIGHT -> UiModeManager.MODE_NIGHT_NO
|
||||
ThemeMode.DARK, ThemeMode.BLACK -> UiModeManager.MODE_NIGHT_YES
|
||||
}
|
||||
|
||||
/** The splash theme whose background is the one the app opens on. */
|
||||
@StyleRes
|
||||
fun splashThemeOf(mode: ThemeMode, dynamicColor: Boolean): Int = when {
|
||||
mode.isPureBlack -> R.style.Theme_Agendula_Black
|
||||
dynamicColor -> R.style.Theme_Agendula
|
||||
else -> R.style.Theme_Agendula_Fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies [mode] and [dynamicColor]. A night mode the activity does not
|
||||
* have yet recreates it, once; the same one again changes nothing.
|
||||
*/
|
||||
fun apply(activity: Activity, mode: ThemeMode, dynamicColor: Boolean) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
activity.getSystemService(UiModeManager::class.java)?.setApplicationNightMode(nightModeOf(mode))
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activity.splashScreen.setSplashScreenTheme(splashThemeOf(mode, dynamicColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,10 +194,12 @@ private fun TaskWidgetBody(
|
||||
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
|
||||
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
|
||||
} else {
|
||||
val todayList = filter == TaskFilter.Smart(SmartList.TODAY)
|
||||
val allSections = widgetSections(
|
||||
data.rows,
|
||||
data.groupByList,
|
||||
data.listNames,
|
||||
todayList,
|
||||
) { kind -> context.getString(dueBucketLabel(kind)) }
|
||||
// A lone group whose bucket repeats the widget's own smart list
|
||||
// (the "Today" smart list showing one "Today" bucket) earns no
|
||||
@@ -248,7 +250,8 @@ private fun TaskWidgetBody(
|
||||
// (Today / Tomorrow) whenever one is shown — and when
|
||||
// it isn't, that's only because it would have repeated
|
||||
// the title, which names the same day just as well.
|
||||
suppressDayLabel = !data.groupByList,
|
||||
suppressDayLabel = !data.groupByList &&
|
||||
(!todayList || section.row.dueKind == DueKind.TODAY),
|
||||
)
|
||||
Spacer(GlanceModifier.height(8.dp))
|
||||
}
|
||||
@@ -304,7 +307,9 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)),
|
||||
action = actionStartActivity(
|
||||
MainActivity.newTaskIntent(context, singleListId, dueToday = filter == TaskFilter.Smart(SmartList.TODAY)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,11 @@ package de.jeanlucmakiola.agendula.widget
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -26,16 +23,10 @@ class TaskWidgetUpdater @Inject constructor(
|
||||
private val dataSource: TasksDataSource,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||
private var observer: AutoCloseable? = null
|
||||
private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerObserver, ::redraw)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
fun start() {
|
||||
if (observer != null) return
|
||||
observer = runCatching { dataSource.registerObserver { requests.trySend(Unit) } }.getOrNull()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() }
|
||||
}
|
||||
if (!changes.start()) return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) }
|
||||
}
|
||||
@@ -44,9 +35,7 @@ class TaskWidgetUpdater @Inject constructor(
|
||||
}
|
||||
|
||||
/** Redraw soon; bursts collapse into one pass. */
|
||||
fun requestRefresh() {
|
||||
requests.trySend(Unit)
|
||||
}
|
||||
fun requestRefresh() = changes.request()
|
||||
|
||||
private suspend fun redraw() {
|
||||
runCatching {
|
||||
|
||||
@@ -100,7 +100,7 @@ private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
action = actionStartActivity(MainActivity.newTaskIntent(context)),
|
||||
action = actionStartActivity(MainActivity.newTaskIntent(context, dueToday = true)),
|
||||
)
|
||||
}
|
||||
Spacer(GlanceModifier.height(HeaderGap))
|
||||
|
||||
@@ -200,12 +200,14 @@ private fun headedRuns(runs: List<WidgetRun>): List<WidgetSection> =
|
||||
* (already sorted that way by [widgetRows]), otherwise one per [DueKind]
|
||||
* bucket — "Overdue" / "Today" / "Tomorrow" / "Later" / "No date" — in that
|
||||
* fixed order, since [rows] isn't due-sorted. Each run becomes one header
|
||||
* followed by its own standalone, independently coloured task pills.
|
||||
* followed by its own standalone, independently coloured task pills. The Today
|
||||
* list ([todayList]) is one "Today" bucket, as a row may be there by its start.
|
||||
*/
|
||||
internal fun widgetSections(
|
||||
rows: List<WidgetTaskRow>,
|
||||
groupByList: Boolean,
|
||||
listNames: Map<Long, String>,
|
||||
todayList: Boolean = false,
|
||||
dueBucketLabel: (DueKind?) -> String,
|
||||
): List<WidgetSection> {
|
||||
if (groupByList) {
|
||||
@@ -220,7 +222,7 @@ internal fun widgetSections(
|
||||
}
|
||||
return headedRuns(runs)
|
||||
}
|
||||
val buckets = rows.groupBy { it.dueKind }
|
||||
val buckets = rows.groupBy { if (todayList) DueKind.TODAY else it.dueKind }
|
||||
val runs = DueKind.entries.plus(null).mapNotNull { kind ->
|
||||
buckets[kind]?.takeIf { it.isNotEmpty() }
|
||||
?.let { WidgetRun("due:${kind?.name.orEmpty()}", dueBucketLabel(kind), it) }
|
||||
|
||||
@@ -7,9 +7,11 @@ import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
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.calendarDate
|
||||
import de.jeanlucmakiola.agendula.domain.comparator
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -45,10 +47,15 @@ internal sealed interface WidgetStatsData {
|
||||
|
||||
private const val MAX_TODAY_ROWS = 20
|
||||
|
||||
/** Whether this open task is on the Today widget's plate: in the app's Today, or overdue. */
|
||||
private fun Task.isDueToday(now: Instant, today: LocalDate, todayStart: Instant, todayEnd: Instant, zone: ZoneId): Boolean =
|
||||
TaskFiltering.isToday(this, todayStart, todayEnd, zone) ||
|
||||
due?.let { isOverdue(it, isAllDay, now, today, zone) } == true
|
||||
|
||||
/**
|
||||
* How far through today a set of [open] and [completed] tasks says you are:
|
||||
* tasks done today (by [Task.completedAt]'s local date), tasks still open that
|
||||
* are overdue or due today (in [comparator] order), and those tasks as rows.
|
||||
* the app's Today tasks done and still open ([TaskFiltering.isToday]) plus the
|
||||
* overdue ones, the open ones in [comparator] order, and those tasks as rows.
|
||||
*/
|
||||
internal fun todayProgress(
|
||||
open: List<Task>,
|
||||
@@ -58,14 +65,9 @@ internal fun todayProgress(
|
||||
today: LocalDate,
|
||||
zone: ZoneId,
|
||||
): Triple<Int, Int, List<WidgetTaskRow>> {
|
||||
val done = completed.count { task ->
|
||||
val at = task.completedAt ?: return@count false
|
||||
at.toLocalDate(zone) == today
|
||||
}
|
||||
val remaining = open.filter { task ->
|
||||
val due = task.due ?: return@filter false
|
||||
isOverdue(due, task.isAllDay, now, today, zone) || dueKind(due, task.isAllDay, today, zone) == DueKind.TODAY
|
||||
}.sortedWith(comparator)
|
||||
val (todayStart, todayEnd) = DayWindow.today(now, zone)
|
||||
val done = completed.count { TaskFiltering.isToday(it, todayStart, todayEnd, zone) }
|
||||
val remaining = open.filter { it.isDueToday(now, today, todayStart, todayEnd, zone) }.sortedWith(comparator)
|
||||
return Triple(done, remaining.size, remaining.take(MAX_TODAY_ROWS).map { it.toWidgetRow(now, today, zone) })
|
||||
}
|
||||
|
||||
@@ -76,29 +78,29 @@ internal fun upcoming(open: List<Task>, now: Instant, today: LocalDate, zone: Zo
|
||||
private const val MAX_UPCOMING_ROWS = 200
|
||||
|
||||
/**
|
||||
* Seven [WeekDay]s starting at [today]. Today's bucket folds in every overdue
|
||||
* task regardless of its own due day, since that's where the app's own
|
||||
* Overdue/Today smart lists put it; every other day matches its own due date.
|
||||
* Seven [WeekDay]s starting at [today]. Today's bucket holds what the Today
|
||||
* widget counts — the app's Today tasks plus every overdue one; every other day
|
||||
* holds the rest by their own due date.
|
||||
*/
|
||||
internal fun weekDays(open: List<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> =
|
||||
(0..6L).map { offset ->
|
||||
internal fun weekDays(open: List<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> {
|
||||
val (todayStart, todayEnd) = DayWindow.today(now, zone)
|
||||
val (todays, later) = open.partition { it.isDueToday(now, today, todayStart, todayEnd, zone) }
|
||||
return (0..6L).map { offset ->
|
||||
val date = today.plusDays(offset)
|
||||
val dayTasks = open.filter { task ->
|
||||
val due = task.due ?: return@filter false
|
||||
if (offset == 0L) {
|
||||
isOverdue(due, task.isAllDay, now, today, zone) || due.calendarDate(task.isAllDay, zone) == date
|
||||
} else {
|
||||
due.calendarDate(task.isAllDay, zone) == date
|
||||
}
|
||||
val dayTasks = if (offset == 0L) {
|
||||
todays
|
||||
} else {
|
||||
later.filter { task -> task.due?.calendarDate(task.isAllDay, zone) == date }
|
||||
}
|
||||
val overdueCount = if (offset == 0L) {
|
||||
dayTasks.count { isOverdue(it.due!!, it.isAllDay, now, today, zone) }
|
||||
dayTasks.count { task -> task.due?.let { isOverdue(it, task.isAllDay, now, today, zone) } == true }
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val first = dayTasks.sortedWith(comparator).firstOrNull()?.toWidgetRow(now, today, zone)
|
||||
WeekDay(date, dayTasks.size, overdueCount, first)
|
||||
}
|
||||
}
|
||||
|
||||
/** One tile per visible list: its open, non-subtask count — a subtask whose parent is also open is left out. */
|
||||
internal fun listTiles(open: List<Task>, lists: List<TaskList>): List<ListTile> {
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
<string name="settings_theme">السمة</string>
|
||||
<string name="settings_theme_light">فاتح</string>
|
||||
<string name="settings_theme_dark">داكن</string>
|
||||
<string name="settings_section_reminders">التذكيرات</string>
|
||||
<string name="settings_list_notifications">الإشعارات</string>
|
||||
<string name="settings_section_reminders">الإشعارات</string>
|
||||
<string name="settings_list_notifications">الصوت والاهتزاز</string>
|
||||
<string name="settings_group_data">البيانات</string>
|
||||
<string name="settings_group_app">التطبيق</string>
|
||||
<string name="settings_group_about">حول</string>
|
||||
@@ -65,7 +65,7 @@
|
||||
<string name="settings_section_storage">التخزين</string>
|
||||
<string name="settings_section_accounts">الحسابات</string>
|
||||
<string name="settings_list_reminder_inherits">الافتراضي (%1$s)</string>
|
||||
<string name="settings_section_task_form">نموذج مهمة</string>
|
||||
<string name="settings_section_task_form">نموذج مهمة جديدة</string>
|
||||
<string name="settings_about_support">دَعم التطوير</string>
|
||||
<string name="settings_about_version">الإصدار %1$s</string>
|
||||
<string name="settings_language">لغة التطبيق</string>
|
||||
@@ -73,7 +73,7 @@
|
||||
<string name="settings_translate">المساعدة في الترجمة</string>
|
||||
<string name="settings_theme_system">اتباع النظام</string>
|
||||
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
||||
<string name="settings_reminders">التذكيرات المستحقة</string>
|
||||
<string name="settings_reminders">تذكيرات المهام</string>
|
||||
<string name="settings_exact_alarms">التوقيت الدقيق</string>
|
||||
<string name="settings_default_list">القائمة الافتراضية</string>
|
||||
<string name="settings_time_format">تنسيق الوقت</string>
|
||||
@@ -90,7 +90,7 @@
|
||||
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
|
||||
<string name="settings_about_logo_desc">رمز تطبيق Agendula</string>
|
||||
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
|
||||
<string name="settings_default_reminder">متى لتذكير</string>
|
||||
<string name="settings_default_reminder">التذكير الافتراضي</string>
|
||||
<string name="settings_group_look">المظهر و السلوك</string>
|
||||
<string name="settings_bottom_add_bar">شريط الإضافة السريع السفلي</string>
|
||||
<string name="settings_time_format_12h">١٢-ساعة (٢:٠٠ م)</string>
|
||||
@@ -168,7 +168,7 @@
|
||||
<string name="reminder_unit_days">أيام</string>
|
||||
<string name="reminder_unit_weeks">أسابيع</string>
|
||||
<string name="settings_list_reminders_title">التذكيرات لكل قائمة</string>
|
||||
<string name="settings_list_reminders_hint">تجاوز التذكير الافتراضي للقوائم الفردية</string>
|
||||
<string name="settings_list_reminders_hint">تجاوز الافتراضي لكل قائمة — بشكل منفصل للمحدد بوقت ومهام طوال اليوم. يمكن لقائمة الاحتفاظ بالافتراضي، إسقاطه أو تعيين خاصّته.</string>
|
||||
<string name="lists_reorder_handle">اسحب لإعادة الترتيب</string>
|
||||
<string name="settings_list_notifications_hint">الصوت، الاهتزاز و الأهمية لتذكيرات هذه القائمة</string>
|
||||
<string name="smart_today">اليوم</string>
|
||||
@@ -446,8 +446,8 @@
|
||||
<string name="filter_missing">تم حذف عامل التصفية</string>
|
||||
<string name="view_filter_tags">تصفية حسب الوسم</string>
|
||||
<string name="filter_section_lists">القوائم</string>
|
||||
<string name="settings_appearance_subtitle">السمة، اللون و تنسيق الوقت</string>
|
||||
<string name="settings_task_form_subtitle">الحقول، القائمة والمهام الفرعية الافتراضية</string>
|
||||
<string name="settings_appearance_subtitle">السمة، الألوان، تنسيق الوقت</string>
|
||||
<string name="settings_task_form_subtitle">الخانات الافتراضية و السلوك</string>
|
||||
<string name="recurrence_on_days">%1$s في %2$s</string>
|
||||
<string name="recurrence_with_until">%1$s حتى %2$s</string>
|
||||
<plurals name="recurrence_with_count">
|
||||
@@ -521,7 +521,7 @@
|
||||
<string name="add_account_server_title">أين مهامك؟</string>
|
||||
<string name="add_account_working_reading_lists">جاري قراءة قوائم المهام خاصّتك</string>
|
||||
<string name="add_account_browser_title">جاري الانتظار لمتصفحك</string>
|
||||
<string name="add_account_browser_use_password">استخدم كلمة مرور بدلاً</string>
|
||||
<string name="add_account_browser_use_password">استخدم كلمة مرور بدلاً من ذلك</string>
|
||||
<string name="add_account_browser_open">فتح صفحة تسجيل الدخول</string>
|
||||
<string name="add_account_lists_title">أيّ القوائم يجب المزامنة؟</string>
|
||||
<string name="add_account_provider_unsupported">لا دعم للمهام عبر CalDAV</string>
|
||||
@@ -569,7 +569,7 @@
|
||||
<string name="add_account_browser_error_expired">تم إغلاق نافذة الموافقة قبل أن يرد الخادم.</string>
|
||||
<string name="add_account_browser_error_rate_limited">يرفض الخادم المحاولات المتكررة. انتظر بضع دقائق وحاول مرة أخرى.</string>
|
||||
<string name="add_account_browser_error_maintenance">الخادم في وضع الصيانة. حاول مرة أخرى بمجرد عودته.</string>
|
||||
<string name="add_account_browser_error_unavailable">لا يحتوي هذا الجهاز على متصفح يمكنه فتح صفحة تسجيل الدخول. استخدم كلمة مرور بدلاً.</string>
|
||||
<string name="add_account_browser_error_unavailable">لا يحتوي هذا الجهاز على متصفح يمكنه فتح صفحة تسجيل الدخول. استخدم كلمة مرور بدلاً من ذلك.</string>
|
||||
<string name="add_account_quirk_hint_fastmail">يحتاج Fastmail كلمة مرور تطبيق، ليس كلمة مرور حسابك — وCalDAV ليس على الخطة الأساسية.</string>
|
||||
<string name="add_account_quirk_hint_icloud">يحتاج iCloud كلمة مرور خاصة بتطبيق، التي يمكنك إنشاؤها في appleid.apple.com مع تفعيل المصادقة الثنائية.</string>
|
||||
<string name="add_account_setup_body">ستقوم بذلك في متصفحك، ثم العودة هنا لإكمال.</string>
|
||||
@@ -580,7 +580,7 @@
|
||||
<string name="add_account_browser_body">وافق على Agendula في المتصفح، ثم ارجع هنا. لا يتم إرسال كلمة مرورك مطلقًا إلى هذا التطبيق — يصدر الخادم كلمة مرور تطبيق منفصلة يمكنك إلغاؤها في أي وقت.</string>
|
||||
<string name="add_account_browser_insecure">صفحة تسجيل الدخول هذه غير مشفرة (http). أي شيء تكتبه هناك، بما في ذلك كلمة مرور حسابك، ينتقل بشكل غير مشفر.</string>
|
||||
<string name="add_account_browser_host_mismatch">الخادم أرسلنا إلى %1$s، لكنك كتبت %2$s. يعني ذلك عادةً أن إعداد overwrite.cli.url خاصّته غير صحيح.</string>
|
||||
<string name="add_account_origin_mismatch">أبلغ الخادم عن عنوانه كـ %1$s، الذي لا يستطيع هذا الجهاز الوصول، لذلك تم استخدام %2$s بدلاً. من المحتمل أن يكون إعداد overwrite.cli.url أو trusted_proxies خاصّته غير صحيح.</string>
|
||||
<string name="add_account_origin_mismatch">أبلغ الخادم عن عنوانه كـ %1$s، الذي لا يستطيع هذا الجهاز الوصول، لذلك تم استخدام %2$s بدلاً من ذلك. من المحتمل أن يكون إعداد overwrite.cli.url أو trusted_proxies خاصّته غير صحيح.</string>
|
||||
<string name="settings_licences_subtitle">المشاريع التي بنيت Agendula عليها</string>
|
||||
<string name="licences_intro">يتضمن Agendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به.</string>
|
||||
<string name="licences_footer">يتم تضمين بعض من هذا الكود في شكل معدل. أين يكون هذا هو الحال، يتم إدراج كل تغيير في مستودع المصدر المرتبط أعلاه.</string>
|
||||
@@ -661,4 +661,197 @@
|
||||
<string name="list_save_failed_server">الخادم لن يقبل هذا التغيير.</string>
|
||||
<string name="list_delete_confirm_message">سيتم حذف ”%1$s“ وجميع مهامه. هذا لا يمكن التراجع عنه.</string>
|
||||
<string name="lists_empty_action">إنشاء قائمة</string>
|
||||
<string name="settings_dynamic_color_unavailable">متوفر على أندوريد ١٢ و أحدث</string>
|
||||
<string name="settings_reminders_subtitle">التذكيرات و التسليم</string>
|
||||
<string name="settings_form_fields_hint">الخانات المعروضة افتراضيًا على مهمة جديدة. الباقية تقع ضمن \"المزيد من الخانات\".</string>
|
||||
<string name="settings_dynamic_color_hint">استخدام الألوان من خلفيتك</string>
|
||||
<string name="settings_export_hint">حفظ قوائمك كملفات iCalendar</string>
|
||||
<string name="settings_import_hint">إحضار المهام من ملف iCalendar</string>
|
||||
<string name="settings_autofocus_title">تركيز العنوان على مهمة جديدة</string>
|
||||
<string name="settings_autofocus_title_hint">عند بدء مهمة جديدة، ضع المؤشر في حقل العنوان وافتح لوحة المفاتيح على الفور.</string>
|
||||
<string name="settings_storage_subtitle">أين مهامك تُبقى</string>
|
||||
<string name="settings_allday_reminder_time">وقت تذكير اليوم الكامل</string>
|
||||
<string name="settings_reliable_delivery_hint">أندرويد قد يؤخر التذكيرات لتوفير البطارية. استثنِ Agendula لكي تصل في الوقت المحدد.</string>
|
||||
<string name="settings_reliable_delivery_exempt">معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدّد.</string>
|
||||
<string name="settings_translate_hint">إضافة أو تحسين لغة على Weblate</string>
|
||||
<string name="settings_reminders_hint">أعلمني عندما تكون مهمة مستحقة</string>
|
||||
<string name="settings_exact_alarms_allowed">تنطلق التذكيرات في الوقت المحدّد</string>
|
||||
<string name="settings_notifications_blocked_hint">التذكيرات لا يمكنها الوصول لك. اضغط للسماح بإشعارات لـ Agendula.</string>
|
||||
<string name="settings_exact_alarms_blocked">محظور — اضغط للسماح بالتذكيرات الدقيقة</string>
|
||||
<string name="settings_default_list_first">أول قائمة متاحة</string>
|
||||
<string name="settings_add_subtask_row">إظهار صف \"أضف مهمة فرعية\"</string>
|
||||
<string name="settings_add_subtask_row_hint">خانة إضافة في نهاية المهام الفرعية للمهمة الموسعة</string>
|
||||
<string name="settings_default_start_today">البداية الافتراضية إلى اليوم</string>
|
||||
<string name="settings_default_start_today_hint">ملأ مسبق بداية مهمة جديدة بتاريخ اليوم</string>
|
||||
<string name="settings_bottom_add_bar_hint">إضافة مهام من شريط مثبت إلى أسفل لقائمة، بدلاً من الزر العائم</string>
|
||||
<string name="settings_task_store_hint">كل مخزن يحتفظ بمهامه الخاصة. التبديل لا يؤدي لنقلهم — انسخهم أولاً، أو صدِّرهم من النسخ الاحتياطي والاستعادة.</string>
|
||||
<string name="settings_store_own_hint">التخزين خاصّة Agendula. لا شيء آخر للتثبيت.</string>
|
||||
<string name="settings_store_external_hint">مشاركة المهام مع التطبيق الذي يزامنهم</string>
|
||||
<string name="settings_store_external_missing">لا تطبيق مهام متوافق مثبّت</string>
|
||||
<string name="accounts_remove_wipe">إزالة و حذف المهام</string>
|
||||
<string name="list_delete_confirm_message_synced">”%1$s“ وجميع مهامه ستتم حذفها من الخادم، من كل جهاز آخر سُجِّل دخوله لهذا الحساب. هذا لا يمكن التراجع عنه.</string>
|
||||
<plurals name="home_today_progress_count">
|
||||
<item quantity="zero">تم %1$d من %2$d</item>
|
||||
<item quantity="one">تم %1$d من %2$d</item>
|
||||
<item quantity="two">تم %1$d من %2$d</item>
|
||||
<item quantity="few">تم %1$d من %2$d</item>
|
||||
<item quantity="many">تم %1$d من %2$d</item>
|
||||
<item quantity="other">تم %1$d من %2$d</item>
|
||||
</plurals>
|
||||
<plurals name="home_today_remaining_count">
|
||||
<item quantity="zero">تبقي %1$d</item>
|
||||
<item quantity="one">تبقي %1$d</item>
|
||||
<item quantity="two">تبقي %1$d</item>
|
||||
<item quantity="few">تبقي %1$d</item>
|
||||
<item quantity="many">تبقي %1$d</item>
|
||||
<item quantity="other">تبقي %1$d</item>
|
||||
</plurals>
|
||||
<string name="list_where_device">على هذا الجهاز فقط</string>
|
||||
<string name="list_read_only">تتم مشاركة هذه القائمة معك للقراءة فقط، لذلك فإن اسمها ولونها للمالك للتغيير.</string>
|
||||
<string name="onboarding_no_provider_title">مُحتاج تطبيق مهام</string>
|
||||
<string name="onboarding_permission_title">السماح بالوصول إلى مهامك</string>
|
||||
<string name="onboarding_permission_body">Agendula يحتاج إذن لقراءة و كتابة مهامك. هذا هو الشيء الوحيد الذي يطلب له دائمًا.</string>
|
||||
<string name="onboarding_permission_button">منح الوصول إلى المهام</string>
|
||||
<string name="onboarding_welcome_sync_body">المزامنة عبر CalDAV مع الخادم أنت تختاره، أو الاحتفاظ بكل شيء على هذا الجهاز.</string>
|
||||
<string name="onboarding_welcome_offline_title">لا إنترنت، بالتصميم</string>
|
||||
<string name="onboarding_welcome_offline_body">هذا الإصدار من Agendula ليس لديه الوصول إلى الشبكة، لذلك فإنه لا يمكنه المزامنة أو الاتصال لأي شيء نفسه.</string>
|
||||
<string name="onboarding_sync_title">مزامنة مع خادمك الخاص؟</string>
|
||||
<string name="onboarding_sync_body">Agendula يدعم CalDAV — Nextcloud, Radicale, Posteo, Fastmail و أي شيء آخر يدعمه. لا حساب من خاصّتنا، أبدًا.</string>
|
||||
<plurals name="widget_today_progress_cd">
|
||||
<item quantity="zero">تم %1$d من %2$d</item>
|
||||
<item quantity="one">تم %1$d من %2$d</item>
|
||||
<item quantity="two">تم %1$d من %2$d</item>
|
||||
<item quantity="few">تم %1$d من %2$d</item>
|
||||
<item quantity="many">تم %1$d من %2$d</item>
|
||||
<item quantity="other">تم %1$d من %2$d</item>
|
||||
</plurals>
|
||||
<plurals name="sync_notice_more">
|
||||
<item quantity="zero">و (%1$d) لا أخرى</item>
|
||||
<item quantity="one">و %1$d آخر</item>
|
||||
<item quantity="two">و %1$d آخران</item>
|
||||
<item quantity="few">و %1$d أخرى</item>
|
||||
<item quantity="many">و %1$d آخر</item>
|
||||
<item quantity="other">و %1$d آخر</item>
|
||||
</plurals>
|
||||
<string name="onboarding_no_provider_body">أنت اخترت للاحتفاظ بالمهام في تطبيق آخر، لكن لا واحد متوافق مثبّت. قم بتثبيت OpenTasks أو tasks.org، أو قم بالتبديل عودتًا إلى التخزين خاصّة Agendula.</string>
|
||||
<string name="onboarding_sync_connect">ربط حساب</string>
|
||||
<string name="onboarding_sync_devices_title">كل جهاز في وقت واحد</string>
|
||||
<string name="onboarding_sync_devices_body">ضع علامة صح لشيء ما هنا و سيتم وضع العلامة عليه في كل مكان.</string>
|
||||
<string name="onboarding_sync_copy_title">نسخة تبقَى بعد الهاتف</string>
|
||||
<string name="onboarding_sync_copy_body">تعيش مهامك على الخادم أيضًا، لذلك جهاز مفقود لا يعني فقدان العمل.</string>
|
||||
<string name="onboarding_sync_yours_title">لك الاختيار</string>
|
||||
<string name="onboarding_sync_yours_body">مستضاف ذاتيًا (Self-hosted) أو مزود أنت تدفع له بالفعل — Agendula فقط يخزن كلمة المرور.</string>
|
||||
<string name="onboarding_list_title">قائمتك الأولى</string>
|
||||
<string name="onboarding_list_body">تحافظ القوائم على فصل الأشياء. ابدأ بواحدة — يمكنك إضافة المزيد، وإعادة تسمية هذه القائمة، أيًّا كان الوقت.</string>
|
||||
<string name="onboarding_backup_title">احتفظ بنسخة</string>
|
||||
<string name="settings_section_backup">النسخ الاحتياطي والاستعادة</string>
|
||||
<string name="onboarding_backup_body_later">دون حساب، قوائمك تعيش فقط على هذا الجهاز. احفظهم كملفات iCalendar أي تطبيق مهام أو تقويم آخر يمكنه قراءتها، أيّا كان الوقت من الإعدادات ← النسخ الاحتياطي والاستعادة ← تصدير.</string>
|
||||
<string name="onboarding_quick_add_title">كيف ترغب في إضافة المهام؟</string>
|
||||
<string name="onboarding_quick_add_bar">شريط الإضافة السريع</string>
|
||||
<string name="onboarding_quick_add_bar_hint">خانة في أسفل كل قائمة: اكتب، اضغط إدخال، إلى التالي.</string>
|
||||
<string name="onboarding_quick_add_button">زر مهمة جديدة</string>
|
||||
<string name="onboarding_quick_add_button_hint">زر عائم يفتح النموذج الكامل.</string>
|
||||
<string name="onboarding_exact_alarms_title">صحيح على الوقت</string>
|
||||
<string name="onboarding_exact_alarms_body">أندوريد يسأل قبل أن يتمكن تطبيق من إعلامك في دقيقة محددة. اسمح به والتذكيرات تصل في موعد الاستحقاق خاصّتهم — بدونه قد يتأخروا بضع دقائق.</string>
|
||||
<string name="onboarding_exact_alarms_allow">السماح بالتوقيت الدقيق</string>
|
||||
<string name="onboarding_use_own_store">استخدم تخزين هذا الجهاز بدلاً من ذلك</string>
|
||||
<string name="widget_config_theme_follow_app">اتبع التطبيق</string>
|
||||
<string name="widget_config_theme_light">فاتح</string>
|
||||
<string name="widget_config_theme_dark">داكن</string>
|
||||
<string name="widget_config_row_color">ألوان خام</string>
|
||||
<string name="widget_config_row_color_neutral">محايد</string>
|
||||
<string name="widget_config_background">الخلفية</string>
|
||||
<string name="widget_config_background_solid">سادة</string>
|
||||
<string name="widget_config_theme">السمة</string>
|
||||
<string name="app_name_tasks_alias">مهام</string>
|
||||
<string name="list_where">أين</string>
|
||||
<string name="settings_backup_subtitle">التصدير و الاستيراد</string>
|
||||
<string name="error_reminder_without_date">اضبط تاريخ بدء أو استحقاق لاستخدام تذكير.</string>
|
||||
<string name="reminder_at_start">في وقت البدء</string>
|
||||
<string name="reminder_at_due_time">في وقت الاستحقاق</string>
|
||||
<string name="reminder_on_start_date">عند موعد البدء</string>
|
||||
<string name="reminder_on_due_date">عند موعد الاستحقاق</string>
|
||||
<string name="reminder_at_time">%1$s عند %2$s</string>
|
||||
<string name="reminder_anchor_start">من البدء</string>
|
||||
<string name="reminder_anchor_due">من تاريخ الاستحقاق</string>
|
||||
<string name="reminder_time">الوقت</string>
|
||||
<string name="reminder_benefit_delivery_title">تنبيه عندما يكون الأمر مهمًا</string>
|
||||
<string name="reminder_benefit_reversible_title">يمكنك تغييره في أي وقت</string>
|
||||
<string name="settings_app_name_hint">اعرض Agendula كـ ”مهام“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد. يُغلَق Agendula عندما تقوم بالتبديل، لذا أعِد فتحه من المشغّل.</string>
|
||||
<string name="settings_allday_reminder_time_summary">العد التنازلي لمهام التي تستمر طوال اليوم عند %1$s</string>
|
||||
<string name="settings_store_permission_denied_hint">مهام التطبيق الآخر تبقى غير قابلة للوصول حتى تسمح بالوصول. اضغط لفتح إعدادات التطبيق.</string>
|
||||
<string name="settings_copy_counting">جارٍ حساب ما هناك لنسخه…</string>
|
||||
<string name="settings_copy_confirm_empty">%1$s لا يحتوي على مهام لنسخها.</string>
|
||||
<string name="settings_copy_nothing">لم يكن هناك شيء للنسخ</string>
|
||||
<string name="export_hint">ملف .ics واحد لكل قائمة، قابل للقراءة بواسطة تطبيقات المهام والتقويم الأخرى. القوائم الموضوع عليها بعلامة صح تذهب إلى مجلد تختاره أنت، أو إلى ملف مضغوط (zip) واحد.</string>
|
||||
<string name="export_no_lists">لا توجد قوائم للتصدير</string>
|
||||
<string name="export_to_folder">الحفظ إلى مجلد</string>
|
||||
<string name="export_to_zip">حفظ كملف مضغوط (zip)</string>
|
||||
<plurals name="export_done">
|
||||
<item quantity="zero">تم تصدير (%1$d) لا قوائم</item>
|
||||
<item quantity="one">تم تصدير قائمة واحدة (%1$d)</item>
|
||||
<item quantity="two">تم تصدير قائمتان (%1$d)</item>
|
||||
<item quantity="few">تم تصدير %1$d قوائم</item>
|
||||
<item quantity="many">تم تصدير %1$d قائمة</item>
|
||||
<item quantity="other">تم تصدير %1$d قائمة</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_minutes_before_start">
|
||||
<item quantity="zero">(%1$d) لا دقائق قبل البدء</item>
|
||||
<item quantity="one">%1$d دقيقة واحده قبل البدء</item>
|
||||
<item quantity="two">%1$d دقيقتان قبل البدء</item>
|
||||
<item quantity="few">%1$d دقائق قبل البدء</item>
|
||||
<item quantity="many">%1$d دقيقة قبل البدء</item>
|
||||
<item quantity="other">%1$d دقيقة قبل البدء</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours_before_start">
|
||||
<item quantity="zero">(%1$d) لا ساعات قبل البدء</item>
|
||||
<item quantity="one">%1$d ساعة واحده قبل البدء</item>
|
||||
<item quantity="two">%1$d ساعتان قبل البدء</item>
|
||||
<item quantity="few">%1$d ساعات قبل البدء</item>
|
||||
<item quantity="many">%1$d ساعة قبل البدء</item>
|
||||
<item quantity="other">%1$d ساعة قبل البدء</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days_before_start">
|
||||
<item quantity="zero">(%1$d) لا أيام قبل البدء</item>
|
||||
<item quantity="one">%1$d يوم واحد قبل البدء</item>
|
||||
<item quantity="two">%1$d يومان قبل البدء</item>
|
||||
<item quantity="few">%1$d أيام قبل البدء</item>
|
||||
<item quantity="many">%1$d يوم قبل البدء</item>
|
||||
<item quantity="other">%1$d يوم قبل البدء</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks_before_start">
|
||||
<item quantity="zero">(%1$d) لا أسابيع قبل البدء</item>
|
||||
<item quantity="one">%1$d أسبوع واحد قبل البدء</item>
|
||||
<item quantity="two">%1$d أسبوعان قبل البدء</item>
|
||||
<item quantity="few">%1$d أسابيع قبل البدء</item>
|
||||
<item quantity="many">%1$d أسبوع قبل البدء</item>
|
||||
<item quantity="other">%1$d أسبوع قبل البدء</item>
|
||||
</plurals>
|
||||
<string name="settings_accounts_subtitle">مزامنة مهامك مع خادم CalDAV</string>
|
||||
<string name="accounts_add">إضافة حسابًا</string>
|
||||
<string name="accounts_sync_interval">المزامنة تلقائيًا</string>
|
||||
<string name="accounts_sync_interval_manual">فقط عند تقوم بالمزامنة</string>
|
||||
<string name="accounts_push_via">تصل التغييرات فورًا، عبر %1$s</string>
|
||||
<string name="widget_unavailable">افتح Agendula لإنهاء الإعداد</string>
|
||||
<string name="widget_today_all_done">انتهى كل شيء</string>
|
||||
<string name="widget_config_title">إظهار في الأداة المصغرة</string>
|
||||
<string name="widget_config_group_by_list">تجميع حسب القائمة</string>
|
||||
<string name="widget_config_group_by_list_hint">إظهار مهام كل قائمة ضمن عنوانها الخاص</string>
|
||||
<string name="widget_config_background_solid_hint">يملأ الأداة المصغّرة بلون سطح السمة</string>
|
||||
<string name="widget_config_background_tinted_hint">أكثر نعومة، تعبئة لونية متناغمة</string>
|
||||
<string name="widget_config_background_transparent">شفافة</string>
|
||||
<string name="widget_config_background_transparent_hint">لا تعبئة — تبقى المهام قابلة للقراءة في حاوياتها الخاصة</string>
|
||||
<string name="widget_config_row_color_neutral_hint">يستخدم كل صف نفس التعبئة اللونية المتناغمة</string>
|
||||
<string name="widget_config_row_color_by_list">حسب القائمة</string>
|
||||
<string name="widget_config_row_color_by_list_hint">كل صف متناغم باللون الخاص بقائمته</string>
|
||||
<string name="widget_today_description">حلقة تقدم لمهام اليوم</string>
|
||||
<string name="widget_up_next_description">مهمتك الوحيدة الأقرب لموعد الاستحقاق</string>
|
||||
<string name="widget_lists_label">قوائم</string>
|
||||
<string name="widget_lists_description">مربع اختصار لكل قائمة، مع عدد مرّات فتحه</string>
|
||||
<string name="widget_week_label">أسبوع</string>
|
||||
<string name="widget_week_description">شريط من سبعة أيام لعدد المهام</string>
|
||||
<string name="widget_config_background_tinted">ملونة</string>
|
||||
<string name="settings_theme_black">أسود</string>
|
||||
<string name="settings_theme_black_summary">داكن مع خلفيات سوداء نقية، لشاشات AMOLED</string>
|
||||
<string name="settings_app_name">اسم التطبيق</string>
|
||||
</resources>
|
||||
@@ -68,7 +68,7 @@
|
||||
<string name="settings_translate_hint">Přidejte nový jazyk nebo vylepšete překlad na Weblate</string>
|
||||
<string name="settings_translate">Pomozte s překladem</string>
|
||||
<string name="onboarding_no_provider_body">Rozhodli jste se ukládat úkoly v jiné aplikaci, ale nemáte nainstalovanou žádnou kompatibilní aplikaci. Nainstalujte OpenTasks nebo tasks.org, případně přepněte zpět na vlastní úložiště aplikace Agendula.</string>
|
||||
<string name="onboarding_backup_body_later">Pokud nemáte účet, vaše seznamy jsou uložené pouze v tomto zařízení. Kdykoli je můžete exportovat jako soubory iCalendar, které lze otevřít v jiných aplikacích pro úkoly nebo kalendář. Stačí přejít do Nastavení → Úložiště → Exportovat úkoly.</string>
|
||||
<string name="onboarding_backup_body_later">Pokud nemáte účet, vaše seznamy jsou uložené pouze v tomto zařízení. Kdykoli je můžete uložit jako soubory iCalendar, které lze otevřít v jiných aplikacích pro úkoly nebo kalendář. Stačí přejít do Nastavení → Zálohování a obnovení → Export.</string>
|
||||
<string name="onboarding_use_own_store">Používat úložiště tohoto zařízení</string>
|
||||
<string name="settings_section_storage">Úložiště</string>
|
||||
<string name="settings_storage_subtitle">Kde jsou úkoly uložené a jak je exportovat</string>
|
||||
@@ -76,9 +76,9 @@
|
||||
<string name="export_to_folder">Uložit do adresáře</string>
|
||||
<string name="export_to_zip">Uložit jako ZIP soubor</string>
|
||||
<string name="settings_export">Exportovat úkoly</string>
|
||||
<string name="settings_about_support">Podpora vývoje</string>
|
||||
<string name="settings_about_support">Podpořit vývoj</string>
|
||||
<string name="settings_section_appearance">Vzhled</string>
|
||||
<string name="settings_appearance_subtitle">Motiv, barva a formát času</string>
|
||||
<string name="settings_appearance_subtitle">Motiv, barvy, formát času</string>
|
||||
<string name="settings_theme">Motiv</string>
|
||||
<string name="settings_theme_system">Dle systému</string>
|
||||
<string name="settings_theme_light">Světlý</string>
|
||||
@@ -94,7 +94,7 @@
|
||||
<string name="settings_time_format_auto">Automaticky</string>
|
||||
<string name="settings_time_format_auto_summary">Podle systému: %1$s</string>
|
||||
<string name="settings_time_format_12h">12hodinový (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24hodinový formát (14:00)</string>
|
||||
<string name="settings_time_format_24h">24hodinový (14:00)</string>
|
||||
<string name="settings_week_start">Víkend začíná v</string>
|
||||
<string name="settings_week_start_auto">Automaticky</string>
|
||||
<string name="settings_group_look">Vzhled a chování</string>
|
||||
@@ -115,4 +115,223 @@
|
||||
<string name="list_color">Barva</string>
|
||||
<string name="widget_config_row_color">Barva řádků</string>
|
||||
<string name="widget_config_row_color_by_list_hint">Každý řádek je lehce zabarven barvou svého seznamu</string>
|
||||
<string name="settings_section_task_form">Formulář úkolu</string>
|
||||
<string name="settings_task_form_subtitle">Výchozí pole, seznam a podúkoly</string>
|
||||
<string name="recurrence_title">Opakování</string>
|
||||
<string name="home_today_all_done">Vše hotovo 🎉</string>
|
||||
<string name="edit_start_label">Začátek</string>
|
||||
<string name="detail_due">Termín splnění</string>
|
||||
<string name="edit_due_label">Termín splnění</string>
|
||||
<string name="error_due_before_start">Termín splnění nemůže předcházet začátku.</string>
|
||||
<string name="error_recurrence_without_date">Opakující se úkol vyžaduje datum začátku nebo termín splnění.</string>
|
||||
<string name="filter_section_due">Termín splnění</string>
|
||||
<string name="filter_any_due">Jakýkoli termín splnění</string>
|
||||
<string name="home_due_today">Dnes</string>
|
||||
<string name="home_due_tomorrow">Zítra</string>
|
||||
<string name="reminder_onboarding_body">Agendula vás upozorní, když nastane čas něco splnit, takže si nemusíte všechno pamatovat.</string>
|
||||
<string name="onboarding_exact_alarms_body">Android se předem zeptá, zda aplikaci povolíte posílat upozornění v přesně stanovený čas. Pokud to povolíte, připomínky dorazí včas. Bez tohoto oprávnění se mohou o několik minut opozdit.</string>
|
||||
<string name="settings_week_start_auto_summary">Aktuálně %1$s</string>
|
||||
<string name="settings_group_about">O aplikaci</string>
|
||||
<string name="settings_about_author">od Jean-Luc Makiola</string>
|
||||
<string name="settings_about_version">Verze %1$s</string>
|
||||
<string name="settings_about_logo_desc">Ikona aplikace Agendula</string>
|
||||
<string name="settings_group_app">Aplikace</string>
|
||||
<string name="settings_section_backup">Zálohování a obnovení</string>
|
||||
<string name="settings_backup_subtitle">Export a import</string>
|
||||
<string name="import_title">Importovat úkoly</string>
|
||||
<plurals name="import_found">
|
||||
<item quantity="one">%1$d úkol</item>
|
||||
<item quantity="few">%1$d úkolů</item>
|
||||
<item quantity="many">%1$d úkolů</item>
|
||||
<item quantity="other">%1$d úkolů</item>
|
||||
</plurals>
|
||||
<string name="settings_export_hint">Uložit seznamy jako soubory iCalendar</string>
|
||||
<string name="settings_import_hint">Importovat úkoly ze souboru iCalendar</string>
|
||||
<string name="import_pick">Vybrat soubor</string>
|
||||
<string name="import_pick_other">Vybrat jiný soubor</string>
|
||||
<string name="import_hint">Vyberte soubor .ics nebo archiv ZIP obsahující více takových souborů, jaký vytváří export. Zachovají se opakování, podúkoly i všechny ostatní údaje úkolů. Úkoly, které už ve vybraném seznamu jsou, budou přeskočeny.</string>
|
||||
<string name="import_pick_another">Importovat jiný soubor</string>
|
||||
<string name="add_account_provider_body">Vyberte službu, ve které máte uložené seznamy úkolů. Agendula se synchronizuje se všemi službami podporujícími protokol CalDAV.</string>
|
||||
<string name="add_account_lists_body">Vyberte seznamy úkolů, které chcete synchronizovat. Toto nastavení můžete později změnit.</string>
|
||||
<string name="settings_copy_from_external">Kopírovat úkoly z %1$s</string>
|
||||
<string name="onboarding_welcome_sync_body">Úkoly můžete synchronizovat přes CalDAV s vlastním serverem, nebo je uchovávat pouze v tomto zařízení.</string>
|
||||
<string name="settings_store_own">Na tomto zařízení</string>
|
||||
<string name="import_new_list_hint">Nový seznam na tomto zařizení</string>
|
||||
<string name="settings_task_store_hint">Každé úložiště uchovává vlastní úkoly. Přepnutím mezi úložišti se úkoly nepřesunou. Nejprve je zkopírujte do druhého úložiště nebo je exportujte v sekci Zálohování a obnovení.</string>
|
||||
<string name="settings_store_permission_denied_hint">K úkolům v jiné aplikaci nebude možné přistupovat, dokud nepovolíte přístup. Klepnutím otevřete nastavení aplikace.</string>
|
||||
<string name="settings_section_accounts">Účty</string>
|
||||
<string name="accounts_sync_interval">Synchronizovat automaticky</string>
|
||||
<string name="accounts_sync_interval_hint">Jak často se jednotlivé účty synchronizují na pozadí. Změny, které provedete, se v každém případě odešlou do jedné minuty. Android může interval prodloužit, aby šetřil baterii.</string>
|
||||
<string name="reminder_unit_minutes">Minuty</string>
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="one">%d minuta</item>
|
||||
<item quantity="few">%d minut</item>
|
||||
<item quantity="many">%d minut</item>
|
||||
<item quantity="other">%d minut</item>
|
||||
</plurals>
|
||||
<plurals name="accounts_sync_every_minutes">
|
||||
<item quantity="one">Každou %d minutu</item>
|
||||
<item quantity="few">Každé %d minuty</item>
|
||||
<item quantity="many">Každých %d minut</item>
|
||||
<item quantity="other">Každých %d minut</item>
|
||||
</plurals>
|
||||
<string name="add_account_browser_error_rate_limited">Server dočasně odmítá další pokusy. Počkejte několik minut a zkuste to znovu.</string>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d hodina</item>
|
||||
<item quantity="few">%d hodin</item>
|
||||
<item quantity="many">%d hodin</item>
|
||||
<item quantity="other">%d hodin</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="one">Před %1$d hodinou</item>
|
||||
<item quantity="few">Před %1$d hodinami</item>
|
||||
<item quantity="many">Před %1$d hodinami</item>
|
||||
<item quantity="other">Před %1$d hodinami</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours_before_start">
|
||||
<item quantity="one">%1$d hodina před začátkem</item>
|
||||
<item quantity="few">%1$d hodiny před začátkem</item>
|
||||
<item quantity="many">%1$d hodin před začátkem</item>
|
||||
<item quantity="other">%1$d hodin před začátkem</item>
|
||||
</plurals>
|
||||
<plurals name="accounts_sync_every_hours">
|
||||
<item quantity="one">Každou hodinu</item>
|
||||
<item quantity="few">Každé %d hodiny</item>
|
||||
<item quantity="many">Každých %d hodin</item>
|
||||
<item quantity="other">Každých %d hodin</item>
|
||||
</plurals>
|
||||
<string name="accounts_sync_interval_manual">Jen při synchronizaci</string>
|
||||
<string name="accounts_push">Okamžité doručování</string>
|
||||
<string name="accounts_push_off">Vypnuto</string>
|
||||
<string name="accounts_push_unsupported">Váš server zatím nepodporuje okamžité doručování</string>
|
||||
<string name="accounts_push_no_distributor">Nainstalujte si aplikaci podporující UnifiedPush, abyste dostávali změny okamžitě</string>
|
||||
<string name="accounts_push_hint">Pokud máte nainstalovanou aplikaci podporující UnifiedPush, například ntfy, změny provedené na ostatních zařízeních se projeví během několika sekund, místo aby se zobrazily až při další synchronizaci. Váš server musí podporovat WebDAV-Push. V případě Nextcloudu tuto funkci zajišťuje aplikace WebDAV Push.</string>
|
||||
<string name="accounts_push_none_installed">Nemáte nainstalovanou žádnou aplikaci podporující UnifiedPush. Nainstalujte si například ntfy, NextPush nebo Sunup a její nabídka se zobrazí zde.</string>
|
||||
<string name="onboarding_sync_body">Agendula komunikuje přes CalDAV – podporuje Nextcloud, Radicale, Posteo, Fastmail i jakoukoli jinou službu, která tento protokol používá. U nás si nikdy nemusíte zakládat účet.</string>
|
||||
<string name="onboarding_sync_connect">Připojit účet</string>
|
||||
<string name="accounts_empty_body">Přidejte účet CalDAV a Agendula bude synchronizovat vaše seznamy úkolů s tímto účtem. Podporuje Nextcloud, Radicale, Baïkal i jakoukoli jinou službu, která používá tento protokol.</string>
|
||||
<string name="accounts_add">Přidat účet</string>
|
||||
<string name="accounts_failed_misconfigured">Synchronizace selhala – účet není kompletní. Odeberte ho a přidejte znovu</string>
|
||||
<string name="add_account_summary_body">Agendula bude odteď tyto seznamy synchronizovat. Které seznamy se synchronizují, můžete změnit v Nastavení.</string>
|
||||
<string name="add_account_server_body">Zadejte adresu svého serveru CalDAV. Pokud server používá jako svou adresu vlastní doménu, můžete zadat také e-mailovou adresu.</string>
|
||||
<string name="add_account_credentials_body">Pokud váš poskytovatel tuto možnost nabízí, použijte heslo pro aplikaci. Lze ho kdykoli zrušit, aniž byste museli měnit heslo ke svému účtu.</string>
|
||||
<string name="add_account_error_not_dav">Zadaná adresa odpověděla, ale nejedná se o server CalDAV. Pokud jste vložili adresu webové stránky, zkuste použít adresu serveru, kterou váš poskytovatel uvádí pro kalendáře.</string>
|
||||
<string name="add_account_error_unreachable">K serveru se nepodařilo připojit. Zkontrolujte adresu a připojení k internetu.</string>
|
||||
<string name="add_account_error_server">Na straně serveru došlo k chybě. Zkuste to za chvíli znovu.</string>
|
||||
<string name="add_account_error_google_unsupported">Kalendář Google nepodporuje synchronizaci úkolů přes CalDAV, jak uvádí i jeho vlastní dokumentace. Agendula s ním proto nemůže synchronizovat úkoly.</string>
|
||||
<string name="add_account_error_cross_domain">Tento server uchovává některé seznamy úkolů na adrese %1$s, ke kterým se Agendula zatím nedokáže přihlásit.</string>
|
||||
<string name="add_account_error_no_usable_lists">Přihlášení proběhlo úspěšně, ale tento účet neobsahuje žádné seznamy úkolů, které může Agendula používat.</string>
|
||||
<string name="add_account_browser_error_expired">Časový limit pro schválení vypršel dříve, než server odpověděl.</string>
|
||||
<string name="add_account_browser_error_maintenance">Server je v režimu údržby. Zkuste to znovu, až bude údržba dokončena.</string>
|
||||
<string name="add_account_browser_error_unavailable">V tomto zařízení není prohlížeč, který by dokázal otevřít přihlašovací stránku. Použijte místo toho heslo.</string>
|
||||
<string name="add_account_quirk_hint_fastmail">Fastmail vyžaduje heslo pro aplikaci, nikoli heslo k vašemu účtu. Protokol CalDAV navíc není součástí tarifu Basic.</string>
|
||||
<string name="add_account_quirk_hint_icloud">iCloud vyžaduje heslo pro konkrétní aplikaci. Vytvoříte ho na adrese appleid.apple.com, pokud máte zapnuté dvoufaktorové ověřování.</string>
|
||||
<string name="add_account_setup_icloud_title">iCloud vyžaduje heslo pro konkrétní aplikaci</string>
|
||||
<string name="add_account_setup_fastmail_title">Fastmail vyžaduje heslo pro aplikaci</string>
|
||||
<string name="add_account_setup_fastmail_footnote">CalDAV není součástí tarifu Fastmail Basic.</string>
|
||||
<string name="add_account_password_hint">Pokud váš server tuto možnost nabízí, použijte heslo pro aplikaci – můžete ho zrušit, aniž byste museli měnit heslo ke svému účtu.</string>
|
||||
<string name="add_account_browser_body">Schvalte Agendulu v prohlížeči a pak se vraťte sem. Vaše heslo se této aplikaci nikdy neodesílá – server vytvoří samostatné heslo pro aplikaci, které můžete kdykoli zrušit.</string>
|
||||
<string name="add_account_browser_insecure">Přihlašovací stránka není zabezpečená šifrováním (HTTP). Vše, co na ní zadáte, včetně hesla k účtu, se přenáší nešifrovaně.</string>
|
||||
<string name="add_account_browser_host_mismatch">Server nás přesměroval na adresu %1$s, ale vy jste zadali adresu %2$s. To obvykle znamená, že nastavení `overwrite.cli.url` není správné.</string>
|
||||
<string name="add_account_origin_mismatch">Server oznámil svou adresu jako %1$s, na kterou se toto zařízení nemůže připojit, proto byla použita adresa %2$s. Nastavení `overwrite.cli.url` nebo `trusted_proxies` serveru je pravděpodobně nesprávné.</string>
|
||||
<string name="cd_expand_subtasks">Ukázat podúkoly</string>
|
||||
<string name="cd_collapse_subtasks">Skrýt podúkoly</string>
|
||||
<string name="detail_subtasks">Podúkoly</string>
|
||||
<plurals name="detail_delete_body_subtasks">
|
||||
<item quantity="one">Tento úkol a jeho podúkol budou smazány.</item>
|
||||
<item quantity="few">Tento úkol a jeho %1$d podúkoly budou smazány.</item>
|
||||
<item quantity="many">Tento úkol a jeho %1$d podúkolů budou smazány.</item>
|
||||
<item quantity="other">Tento úkol a jeho %1$d podúkolů budou smazány.</item>
|
||||
</plurals>
|
||||
<string name="recurrence_none">Neopakovat</string>
|
||||
<string name="edit_recurrence_label">Opakování</string>
|
||||
<string name="edit_recurrence_none">Neopakovat</string>
|
||||
<string name="reminder_custom">Vlastní</string>
|
||||
<string name="reminder_custom_with_value">Vlastní (%1$s)</string>
|
||||
<string name="recurrence_weekly">Každý týden</string>
|
||||
<plurals name="recurrence_every_n_weeks">
|
||||
<item quantity="one">Každý %1$d týden</item>
|
||||
<item quantity="few">Každé %1$d týdny</item>
|
||||
<item quantity="many">Každých %1$d týdnů</item>
|
||||
<item quantity="other">Každých %1$d týdnů</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks_before_start">
|
||||
<item quantity="one">%1$d týden před začátkem</item>
|
||||
<item quantity="few">%1$d týdny před začátkem</item>
|
||||
<item quantity="many">%1$d týdnů před začátkem</item>
|
||||
<item quantity="other">%1$d týdnů před začátkem</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks_before_due">
|
||||
<item quantity="one">%1$d týden před termínem splnění</item>
|
||||
<item quantity="few">%1$d týdny před termínem splnění</item>
|
||||
<item quantity="many">%1$d týdnů před termínem splnění</item>
|
||||
<item quantity="other">%1$d týdnů před termínem splnění</item>
|
||||
</plurals>
|
||||
<string name="recurrence_incomplete">Zadejte číslo od 1 do 999</string>
|
||||
<string name="recurrence_end_count">Po určitém počtu opakování</string>
|
||||
<string name="recurrence_end_until">V konkrétní den</string>
|
||||
<string name="error_reminder_without_date">Pro nastavení připomínky zadejte datum začátku nebo termín splnění.</string>
|
||||
<string name="reminder_on_start_date">V den začátku</string>
|
||||
<string name="reminder_on_due_date">V den termínu splnění</string>
|
||||
<string name="reminder_anchor_due">Od termínu splnění</string>
|
||||
<string name="filters_empty_hint">Kombinujte termíny splnění, seznamy, štítky a priority do vlastního přehledu</string>
|
||||
<string name="settings_default_start_today_hint">Předvyplnit datum začátku nového úkolu dnešním datem</string>
|
||||
<plurals name="reminder_minutes_before_due">
|
||||
<item quantity="one">%1$d minutu před termínem splnění</item>
|
||||
<item quantity="few">%1$d minut před termínem splnění</item>
|
||||
<item quantity="many">%1$d minut před termínem splnění</item>
|
||||
<item quantity="other">%1$d minut před termínem splnění</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours_before_due">
|
||||
<item quantity="one">%1$d hodinu před termínem splnění</item>
|
||||
<item quantity="few">%1$d hodin před termínem splnění</item>
|
||||
<item quantity="many">%1$d hodin před termínem splnění</item>
|
||||
<item quantity="other">%1$d hodin před termínem splnění</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days_before_due">
|
||||
<item quantity="one">%1$d den před termínem splnění</item>
|
||||
<item quantity="few">%1$d dny před termínem splnění</item>
|
||||
<item quantity="many">%1$d dní před termínem splnění</item>
|
||||
<item quantity="other">%1$d dní před termínem splnění</item>
|
||||
</plurals>
|
||||
<string name="clear_completed_confirm">Smazat</string>
|
||||
<string name="detail_delete_title">Smazat úkol?</string>
|
||||
<string name="detail_delete_body">Tento úkol bude smazán.</string>
|
||||
<string name="filter_delete_title">Smazat “%1$s”?</string>
|
||||
<string name="list_delete_confirm_title">Smazat seznam?</string>
|
||||
<string name="list_delete_confirm_message_synced">„%1$s“ a všechny jeho úkoly budou smazány ze serveru i ze všech ostatních zařízení přihlášených k tomuto účtu. Tuto akci nelze vrátit zpět.</string>
|
||||
<string name="list_delete_confirm_message">„%1$s“ a všechny jeho úkoly budou smazány. Tuto akci nelze vrátit zpět.</string>
|
||||
<string name="accounts_remove_body">%1$s se přestane synchronizovat s tímto zařízením. Na serveru se nic nesmaže.</string>
|
||||
<string name="accounts_remove_wipe_body">Z tohoto zařízení se zároveň smažou všechny seznamy a úkoly patřící k tomuto účtu.</string>
|
||||
<string name="accounts_lists_drop_body">U seznamů, u kterých jste zrušili zaškrtnutí, se zastaví synchronizace s tímto zařízením. Na serveru se nic nesmaže.</string>
|
||||
<string name="accounts_lists_drop_wipe_body">Z tohoto zařízení se zároveň smažou vybrané seznamy i všechny jejich úkoly.</string>
|
||||
<string name="sync_notice_cause_delete_lost">Tady jste ho smazali, ale na serveru byl mezitím změněn</string>
|
||||
<string name="permission_write_tasks_description">Umožňuje aplikaci přidávat, upravovat, označovat jako dokončené a mazat úkoly uložené v Agendule, včetně jejich připomínek a štítků. Seznamy úkolů upravovat ani mazat nemůže.</string>
|
||||
<string name="edit_set">Nastavit</string>
|
||||
<string name="reminder_custom_set">Nastavit</string>
|
||||
<string name="settings_list_reminders_hint">Přepíše výchozí nastavení pro každý seznam zvlášť, a to samostatně pro úkoly s konkrétním časem a celodenní úkoly. Seznam může převzít výchozí nastavení, nepoužívat žádné nebo mít vlastní nastavení.</string>
|
||||
<string name="settings_list_notifications">Zvuk a vibrace</string>
|
||||
<string name="settings_list_notifications_hint">Zvuk, vibrace a důležitost připomínek pro tento seznam</string>
|
||||
<string name="detail_duplicate">Vytvořit kopii</string>
|
||||
<string name="detail_status_cancelled">Zrušeno</string>
|
||||
<string name="detail_cancel_task">Zrušit úkol</string>
|
||||
<string name="detail_restore">Obnovit úkol</string>
|
||||
<string name="edit_reminder_label">Upozornění</string>
|
||||
<string name="reminder_at_due">V čase úkolu</string>
|
||||
<string name="reminder_on_the_day">V daný den</string>
|
||||
<string name="settings_list_reminders_title">Upozornění pro jednotlivé seznamy</string>
|
||||
<string name="shortcut_new_task">Nový úkol</string>
|
||||
<string name="shortcut_today">Dnes</string>
|
||||
<string name="qs_tile_new_task">Nový úkol</string>
|
||||
<string name="sort_title">Seřadit podle</string>
|
||||
<string name="view_options_title">Řazení a filtrování</string>
|
||||
<string name="view_filter_tags">Filtrovat podle štítku</string>
|
||||
<string name="view_filter_clear">Zrušit filtr</string>
|
||||
<string name="sort_due">Termín splnění</string>
|
||||
<string name="sort_title_alpha">Název</string>
|
||||
<string name="sort_created">Nedávno přidané</string>
|
||||
<string name="clear_completed_action">Zrušit</string>
|
||||
<string name="undo">Zpět</string>
|
||||
<string name="section_today">Dnes</string>
|
||||
<string name="section_upcoming">Nadcházející</string>
|
||||
</resources>
|
||||
@@ -442,4 +442,19 @@
|
||||
<string name="recurring_scope_series">Alle Termine der Serie</string>
|
||||
<string name="edit_recurrence_label">Wiederholung</string>
|
||||
<string name="edit_recurrence_none">Wiederholt sich nicht</string>
|
||||
<string name="reminder_time">Zeit</string>
|
||||
<string name="filters_header">Filter</string>
|
||||
<string name="filter_new_title">Neuer Filter</string>
|
||||
<string name="filter_edit_title">Filter bearbeiten</string>
|
||||
<string name="filter_name_hint">Name des Filters</string>
|
||||
<string name="filter_section_due">Fällig</string>
|
||||
<string name="filter_section_priority">Priorität</string>
|
||||
<string name="filter_section_lists">Listen</string>
|
||||
<string name="filter_priority_low">Niedriger und höher</string>
|
||||
<string name="filter_priority_medium">Mittel und höher</string>
|
||||
<string name="filter_priority_high">Nur hoch</string>
|
||||
<string name="filter_delete">Filter löschen</string>
|
||||
<string name="filter_delete_title">“%1$s” löschen?</string>
|
||||
<string name="filter_delete_confirm">Löschen</string>
|
||||
<string name="filter_missing">Filter gelöscht</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<!-- Nearest to the dynamic scheme's background, tone 6 of the neutral variant. -->
|
||||
<color name="window_background">@android:color/system_neutral2_900</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="window_background">@android:color/system_background_dark</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<bool name="light_system_bars">false</bool>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="window_background_fallback">#FF161113</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Agendula</string>
|
||||
<string name="task_untitled">Задача без имени</string>
|
||||
<string name="back">Назад</string>
|
||||
<string name="save">Сохранить</string>
|
||||
<string name="delete">Удалить</string>
|
||||
<string name="tasks_title">Задачи</string>
|
||||
<string name="task_list_empty">Здесь пока нет задач.</string>
|
||||
<string name="task_list_failure">Невозможно загрузить эти задачи.</string>
|
||||
<string name="smart_no_date">Без даты</string>
|
||||
<string name="smart_completed">Завершено</string>
|
||||
<string name="task_detail_title">Задача</string>
|
||||
<string name="task_detail_not_found">Задачи больше не существует.</string>
|
||||
<string name="edit">Редактировать</string>
|
||||
<string name="field_description">Описание</string>
|
||||
<string name="edit_save_failed">Не удалось сохранить. Попробуйте снова.</string>
|
||||
<string name="add_task_hint">Добавить задачу</string>
|
||||
<string name="add_subtask_hint">Добавить подзадачу</string>
|
||||
<string name="cd_add_task">Добавить задачу</string>
|
||||
<string name="cd_complete">Завершить</string>
|
||||
<string name="cd_reopen">Открыть заново</string>
|
||||
<string name="task_deleted">Удалено</string>
|
||||
<string name="task_completed">Завершено</string>
|
||||
<string name="cd_repeats">Повторить</string>
|
||||
<string name="shortcut_new_task">Новая задача</string>
|
||||
<string name="shortcut_today">Сегодня</string>
|
||||
<string name="qs_tile_new_task">Новая задача</string>
|
||||
<string name="lists_reorder">Переопределить список</string>
|
||||
<string name="lists_reorder_done">Завершить переопределение</string>
|
||||
<string name="app_name_tasks_alias">Задачи</string>
|
||||
<string name="lists_reorder_handle">Перетащите, чтобы изменить порядок</string>
|
||||
<string name="sort_title">Сортировать по</string>
|
||||
<string name="view_options_title">Сортировать и фильтровать</string>
|
||||
<string name="view_filter_tags">Фильтр по тегу</string>
|
||||
<string name="view_filter_clear">Очистить фильтр</string>
|
||||
<string name="sort_due">Срок выполнения</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<!-- Nearest to the dynamic scheme's background, tone 98 of the neutral variant. -->
|
||||
<color name="window_background">@android:color/system_neutral2_10</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="window_background">@android:color/system_background_light</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<bool name="light_system_bars">true</bool>
|
||||
</resources>
|
||||
@@ -2,4 +2,8 @@
|
||||
<!-- Seed color: warm mauve. Material 3 derives Light & Dark schemes from this. -->
|
||||
<!-- Adaptive icon background -->
|
||||
<color name="ic_launcher_background">#FF7A5C6B</color>
|
||||
<!-- The Compose fallback schemes' background, kept equal by WindowColorsTest. -->
|
||||
<color name="window_background_fallback">#FFFEFBFC</color>
|
||||
<!-- The launch window and splash: the dynamic background where there is one. -->
|
||||
<color name="window_background">@color/window_background_fallback</color>
|
||||
</resources>
|
||||
@@ -1,7 +1,19 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme.Agendula" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<!-- The launch window and splash; Compose draws the rest. Its light or dark
|
||||
follows the app's theme setting, handed to the system by SystemTheme. -->
|
||||
<style name="Theme.Agendula" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowLightStatusBar">@bool/light_system_bars</item>
|
||||
</style>
|
||||
|
||||
<!-- Splash themes picked at runtime on API 33+, see SystemTheme. -->
|
||||
<style name="Theme.Agendula.Fallback">
|
||||
<item name="android:windowBackground">@color/window_background_fallback</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Agendula.Black">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -10,4 +10,8 @@
|
||||
<locale android:name="en" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="pt-BR" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="ar" />
|
||||
<locale android:name="cs" />
|
||||
<locale android:name="ru" />
|
||||
</locale-config>
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.testTask
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.lang.reflect.Proxy
|
||||
import kotlin.time.Instant
|
||||
|
||||
class OccurrenceLookupTest {
|
||||
|
||||
private val yesterday = Instant.fromEpochMilliseconds(1_800_000_000_000)
|
||||
private val today = Instant.fromEpochMilliseconds(1_800_000_000_000 + 86_400_000)
|
||||
private val current = testTask(id = 10).copy(seriesId = 10, occurrenceStart = today)
|
||||
|
||||
/** Answers only the two lookups [occurrence] makes. */
|
||||
private fun source(byAnchor: (Instant?) -> Task?, list: List<Task> = emptyList()): TasksDataSource =
|
||||
Proxy.newProxyInstance(TasksDataSource::class.java.classLoader, arrayOf(TasksDataSource::class.java)) { _, method, args ->
|
||||
when {
|
||||
method.name == "task" && args?.size == 2 -> byAnchor(args[1] as Instant?)
|
||||
method.name == "tasks" -> list
|
||||
else -> throw UnsupportedOperationException(method.name)
|
||||
}
|
||||
} as TasksDataSource
|
||||
|
||||
@Test
|
||||
fun `a ticked occurrence is found although it is a row of its own`() {
|
||||
val ticked = testTask(id = 57, status = TaskStatus.COMPLETED, completedAt = today)
|
||||
.copy(seriesId = 10, occurrenceStart = yesterday)
|
||||
val found = source({ if (it == yesterday) ticked else current }).occurrence(10, yesterday.toEpochMilliseconds())
|
||||
assertThat(found).isEqualTo(ticked)
|
||||
assertThat(reminderIsStale(yesterday.toEpochMilliseconds(), found, missingMeansGone = true)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exception row the anchored lookup misses is found in the list`() {
|
||||
val exception = testTask(id = 99, status = TaskStatus.COMPLETED, completedAt = today)
|
||||
.copy(seriesId = 10, occurrenceStart = yesterday)
|
||||
val found = source({ current }, listOf(current, exception)).occurrence(10, yesterday.toEpochMilliseconds())
|
||||
assertThat(found).isEqualTo(exception)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,14 @@ class ReminderDiffTest {
|
||||
assertThat(diff.keep).containsExactly(a)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reopened task within the grace does not ring again`() {
|
||||
// Ticked, so the store dropped it; undone, so it is wanted again.
|
||||
val diff = ReminderDiff.of(previous = emptySet(), desired = setOf(a), fired = setOf(a)) { false }
|
||||
assertThat(diff.arm).isEmpty()
|
||||
assertThat(diff.keep).containsExactly(a)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store entries round-trip, and junk is skipped`() {
|
||||
val encoded = ScheduledReminderStore.encode(setOf(a, b))
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.testTask
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class ReminderIsStaleTest {
|
||||
|
||||
private val none = ScheduledReminder.NO_OCCURRENCE
|
||||
private val anchor = Instant.fromEpochMilliseconds(1_800_000_000_000)
|
||||
|
||||
@Test
|
||||
fun `a deleted task's reminder comes down`() {
|
||||
assertThat(reminderIsStale(none, null, missingMeansGone = true)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a completed task's reminder comes down`() {
|
||||
val done = testTask(status = TaskStatus.COMPLETED, completedAt = anchor)
|
||||
assertThat(reminderIsStale(none, done, missingMeansGone = true)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an open task's reminder stays`() {
|
||||
assertThat(reminderIsStale(none, testTask(), missingMeansGone = true)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a completed occurrence's reminder comes down`() {
|
||||
val done = testTask(status = TaskStatus.COMPLETED, completedAt = anchor).copy(occurrenceStart = anchor)
|
||||
assertThat(reminderIsStale(anchor.toEpochMilliseconds(), done, missingMeansGone = true)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lookup that fell back to another occurrence leaves the reminder up`() {
|
||||
val other = testTask(status = TaskStatus.COMPLETED, completedAt = anchor)
|
||||
.copy(occurrenceStart = Instant.fromEpochMilliseconds(anchor.toEpochMilliseconds() + 86_400_000))
|
||||
assertThat(reminderIsStale(anchor.toEpochMilliseconds(), other, missingMeansGone = true)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed provider read leaves the reminder up`() {
|
||||
assertThat(reminderIsStale(none, null, missingMeansGone = false)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder from before occurrences were recorded comes down only for a task that does not recur`() {
|
||||
val done = testTask(status = TaskStatus.COMPLETED, completedAt = anchor)
|
||||
assertThat(reminderIsStale(null, done, missingMeansGone = true)).isTrue()
|
||||
assertThat(reminderIsStale(null, done.copy(occurrenceStart = anchor), missingMeansGone = true)).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ListOrderTest {
|
||||
|
||||
private fun list(id: Long, name: String, account: String = "Radicale") = TaskList(
|
||||
id = id, name = name, color = 0, accountName = account, accountType = "bitfire.at.davdroid",
|
||||
isSynced = true, isVisible = true, owner = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an order survives encoding`() {
|
||||
val order = mapOf(3L to 0, 1L to 1, 2L to 2)
|
||||
assertThat(ListOrder.decode(ListOrder.encode(order))).isEqualTo(order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing stored and garbage both decode to no order`() {
|
||||
assertThat(ListOrder.decode(null)).isEmpty()
|
||||
assertThat(ListOrder.decode("x=1,2,=3,4=y")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lists follow the stored order within their account (#136)`() {
|
||||
val lists = listOf(list(1, "Groceries"), list(2, "Home"), list(3, "Work"), list(4, "Inbox", account = "Local"))
|
||||
val order = ListOrder.reordered(listOf(4, 1, 2, 3), listOf(3, 1, 2))
|
||||
|
||||
assertThat(ListOrder.sorted(lists, order).map { it.id }).containsExactly(4L, 3L, 1L, 2L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reordering one account leaves every other list in its slot`() {
|
||||
// 7 and 8 are another account; 5 joined this one after the screen was drawn.
|
||||
val order = ListOrder.reordered(listOf(7, 8, 1, 5, 2), listOf(2, 1))
|
||||
|
||||
assertThat(order).isEqualTo(mapOf(7L to 0, 8L to 1, 2L to 2, 5L to 3, 1L to 4))
|
||||
assertThat(order.values.toSet()).hasSize(order.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `names sort without regard to case`() {
|
||||
val lists = listOf(list(1, "Zeta"), list(2, "apple"))
|
||||
assertThat(ListOrder.sorted(lists, emptyMap()).map { it.id }).containsExactly(2L, 1L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a list without a position counts as the first, as in the own store`() {
|
||||
val lists = listOf(list(1, "Zeta"), list(2, "Alpha"), list(3, "Mid"))
|
||||
val order = mapOf(3L to 0, 1L to 1)
|
||||
|
||||
assertThat(ListOrder.sorted(lists, order).map { it.id }).containsExactly(2L, 3L, 1L).inOrder()
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,77 @@ class StoredRemindersTest {
|
||||
assertThat(stored(form.copy(reminders = listOf(hourBeforeDue)), due)).containsExactly(hourBeforeDue, dayBeforeStart)
|
||||
}
|
||||
|
||||
private fun moved(
|
||||
form: TaskForm,
|
||||
start: Instant?,
|
||||
due: Instant?,
|
||||
wasAllDay: Boolean = form.isAllDay,
|
||||
at: ZoneId = zone,
|
||||
) = StoredReminders.withDefaults(form, start, due, wasAllDay, settings, now.toEpochMilliseconds(), at)
|
||||
|
||||
@Test
|
||||
fun `a date moved after its defaults rang brings them`() {
|
||||
val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, reminders = listOf(hourBeforeDue))
|
||||
assertThat(moved(form, start = now, due = now + 5.days)).containsExactly(hourBeforeDue, dayBeforeStart)
|
||||
// Its date still ahead, but the lead already past.
|
||||
assertThat(moved(form, start = now + 2.hours, due = now + 5.days)).containsExactly(hourBeforeDue, dayBeforeStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a moved due brings its defaults the same way`() {
|
||||
val ownStart = TaskReminder(0, fromStart = true)
|
||||
val form = TaskForm(title = "t", listId = 1, start = now - 3.days, due = now + 5.days, reminders = listOf(ownStart))
|
||||
assertThat(moved(form, start = now - 3.days, due = now)).containsExactly(ownStart, dayBeforeDue, hourBeforeDue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a moved date keeps the form when its defaults had not rung or are named`() {
|
||||
val form = TaskForm(title = "t", listId = 1, start = now + 3.days, due = now + 5.days, reminders = listOf(hourBeforeDue))
|
||||
assertThat(moved(form, start = now + 2.days, due = now + 5.days)).containsExactly(hourBeforeDue)
|
||||
val ownStart = TaskReminder(30, fromStart = true)
|
||||
assertThat(moved(form.copy(reminders = listOf(ownStart)), start = now, due = now + 5.days)).containsExactly(ownStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task without reminders of its own keeps none when a past date moves`() {
|
||||
val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days)
|
||||
assertThat(moved(form, start = now, due = now + 5.days)).isEmpty()
|
||||
assertThat(moved(form.copy(rrule = "FREQ=DAILY", reminders = listOf(hourBeforeDue)), start = now, due = now + 5.days))
|
||||
.containsExactly(hourBeforeDue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turning all-day without moving the date is not a move`() {
|
||||
val form = TaskForm(
|
||||
title = "t",
|
||||
listId = 1,
|
||||
start = allDayInstantOf(LocalDate.of(2026, 10, 1)),
|
||||
due = allDayInstantOf(LocalDate.of(2026, 10, 6)),
|
||||
isAllDay = true,
|
||||
reminders = listOf(TaskReminder(1_440)),
|
||||
)
|
||||
assertThat(moved(form, start = now, due = now + 5.days, wasAllDay = false)).containsExactly(TaskReminder(1_440))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day date is past by its own calendar, not by UTC midnight`() {
|
||||
val ownStart = TaskReminder(0, fromStart = true)
|
||||
val newYork = ZoneId.of("America/New_York")
|
||||
// 20:00 on Sep 30 in New York: Oct 1 has not begun there.
|
||||
val form = TaskForm(
|
||||
title = "t",
|
||||
listId = 1,
|
||||
start = allDayInstantOf(LocalDate.of(2026, 9, 28)),
|
||||
due = allDayInstantOf(LocalDate.of(2026, 10, 2)),
|
||||
isAllDay = true,
|
||||
reminders = listOf(ownStart),
|
||||
)
|
||||
val due = allDayInstantOf(LocalDate.of(2026, 10, 1))
|
||||
assertThat(
|
||||
StoredReminders.withDefaults(form, form.start, due, true, settings, Instant.parse("2026-10-01T00:00:00Z").toEpochMilliseconds(), newYork),
|
||||
).containsExactly(ownStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a gained date the form already counts from keeps the form`() {
|
||||
val current = testTask(start = now + 2.days)
|
||||
|
||||
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class TaskMapperTest {
|
||||
|
||||
@@ -34,7 +35,7 @@ class TaskMapperTest {
|
||||
),
|
||||
)
|
||||
|
||||
val task = TaskMapper.task(reader)
|
||||
val task = mapTask(reader)
|
||||
|
||||
assertThat(task.taskId).isEqualTo(7L)
|
||||
assertThat(task.listId).isEqualTo(3L)
|
||||
@@ -52,7 +53,7 @@ class TaskMapperTest {
|
||||
@Test
|
||||
fun `an occurrence is identified by its recurrence-id anchor`() {
|
||||
fun occurrence(columns: Map<String, Any?>) =
|
||||
TaskMapper.task(MapColumnReader(columns + (Tasks.RRULE to "FREQ=DAILY")))
|
||||
mapTask(MapColumnReader(columns), SeriesRule(rrule = "FREQ=DAILY", rdate = null))
|
||||
|
||||
// instance_original_time is the provider's own RECURRENCE-ID and wins.
|
||||
val anchored = occurrence(
|
||||
@@ -76,7 +77,7 @@ class TaskMapperTest {
|
||||
|
||||
@Test
|
||||
fun `a non-recurring task has no occurrence anchor and keys by task id`() {
|
||||
val task = TaskMapper.task(
|
||||
val task = mapTask(
|
||||
MapColumnReader(mapOf(Tasks.ID to 4L, Instances.INSTANCE_START to 500L)),
|
||||
)
|
||||
assertThat(task.occurrenceStart).isNull()
|
||||
@@ -84,33 +85,81 @@ class TaskMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recurrence is detected from rrule when is_recurring is absent`() {
|
||||
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column;
|
||||
// reading it alone would report the series as one-off and send its edits
|
||||
// to the master row, re-anchoring the whole thing.
|
||||
val task = TaskMapper.task(
|
||||
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")),
|
||||
fun `a series is detected from its master rule when the view masks it (#135)`() {
|
||||
// tasks.org (DB 22): rule masked in the view, no `is_recurring`.
|
||||
val task = mapTask(
|
||||
MapColumnReader(
|
||||
mapOf(
|
||||
Tasks.ID to 90L,
|
||||
Instances.TASK_ID to 9L,
|
||||
Instances.INSTANCE_START to 1_000L,
|
||||
Instances.INSTANCE_ORIGINAL_TIME to 1_000L,
|
||||
),
|
||||
),
|
||||
SeriesRule(rrule = "FREQ=DAILY", rdate = null),
|
||||
)
|
||||
assertThat(task.isRecurring).isTrue()
|
||||
assertThat(task.seriesId).isEqualTo(9L)
|
||||
assertThat(task.occurrenceStart).isEqualTo(Instant.fromEpochMilliseconds(1_000L))
|
||||
assertThat(task.isOccurrence).isTrue()
|
||||
assertThat(task.recurrenceRule).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the master rule fills the editor even where is_recurring exists`() {
|
||||
// OpenTasks (DB 23) flags the row but masks the rule too.
|
||||
val task = mapTask(
|
||||
MapColumnReader(mapOf(Tasks.ID to 90L, Instances.TASK_ID to 9L, Instances.IS_RECURRING to 1)),
|
||||
SeriesRule(rrule = "FREQ=WEEKLY;BYDAY=MO", rdate = null),
|
||||
)
|
||||
assertThat(task.recurrenceRule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tasks row without a rule is no series`() {
|
||||
assertThat(TaskMapper.seriesRule(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "")))).isNull()
|
||||
assertThat(TaskMapper.seriesRule(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z"))))
|
||||
.isEqualTo(SeriesRule(rrule = null, rdate = "20260720T090000Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recurrence is detected from rdate alone`() {
|
||||
val task = TaskMapper.task(
|
||||
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")),
|
||||
val task = mapTask(
|
||||
MapColumnReader(mapOf(Tasks.ID to 1L)),
|
||||
SeriesRule(rrule = null, rdate = "20260720T090000Z"),
|
||||
)
|
||||
assertThat(task.isRecurring).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an override belongs to its master's series`() {
|
||||
// An edited occurrence is its own task row, without a rule of its own.
|
||||
val task = mapTask(
|
||||
MapColumnReader(
|
||||
mapOf(
|
||||
Tasks.ID to 95L,
|
||||
Instances.TASK_ID to 12L,
|
||||
Tasks.ORIGINAL_INSTANCE_ID to 9L,
|
||||
Instances.INSTANCE_ORIGINAL_TIME to 2_000L,
|
||||
),
|
||||
),
|
||||
SeriesRule(rrule = "FREQ=DAILY", rdate = null),
|
||||
)
|
||||
assertThat(task.taskId).isEqualTo(12L)
|
||||
assertThat(task.seriesId).isEqualTo(9L)
|
||||
assertThat(task.occurrenceStart).isEqualTo(Instant.fromEpochMilliseconds(2_000L))
|
||||
assertThat(task.recurrenceRule).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain task is not recurring`() {
|
||||
val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
|
||||
val task = mapTask(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
|
||||
assertThat(task.isRecurring).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to instance id when task_id missing, and list color when no task color`() {
|
||||
val task = TaskMapper.task(
|
||||
val task = mapTask(
|
||||
MapColumnReader(mapOf(Tasks.ID to 9L, Tasks.LIST_COLOR to 0x111111)),
|
||||
)
|
||||
assertThat(task.taskId).isEqualTo(9L)
|
||||
@@ -140,3 +189,5 @@ class TaskMapperTest {
|
||||
assertThat(list.isLocal).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapTask(r: ColumnReader, rule: SeriesRule? = null) = TaskMapper.withRule(TaskMapper.instance(r), rule)
|
||||
@@ -102,14 +102,15 @@ class TaskWriteMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed timestamps are written untouched`() {
|
||||
fun `timed timestamps are kept to the second`() {
|
||||
// The provider expands series in whole seconds.
|
||||
val at = Instant.fromEpochMilliseconds(1_784_412_345_678L)
|
||||
val values = TaskWriteMapper.taskValues(
|
||||
TaskForm(title = "Standup", listId = 1L, start = at, due = at),
|
||||
tzId = "Europe/Berlin",
|
||||
)
|
||||
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L)
|
||||
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L)
|
||||
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_000L)
|
||||
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -45,11 +45,36 @@ class TaskFilteringTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completed only matches COMPLETED, never the open lists`() {
|
||||
fun `completed matches COMPLETED, never the open lists`() {
|
||||
val task = testTask(status = TaskStatus.COMPLETED, due = at(1000 * dayMs + 1))
|
||||
assertThat(matches(task, SmartList.COMPLETED)).isTrue()
|
||||
assertThat(matches(task, SmartList.ALL)).isFalse()
|
||||
assertThat(matches(task, SmartList.UPCOMING)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `today takes a task that starts today`() {
|
||||
assertThat(matches(testTask(start = at(1000 * dayMs + 1)), SmartList.TODAY)).isTrue()
|
||||
assertThat(matches(testTask(start = at(1000 * dayMs + 1), due = at(1003 * dayMs)), SmartList.TODAY)).isTrue()
|
||||
assertThat(matches(testTask(start = at(1001 * dayMs + 1)), SmartList.TODAY)).isFalse()
|
||||
assertThat(matches(testTask(start = at(999 * dayMs)), SmartList.TODAY)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task starting today but already overdue stays in Overdue only`() {
|
||||
val task = testTask(start = at(1000 * dayMs + 1), due = at(999 * dayMs))
|
||||
assertThat(matches(task, SmartList.TODAY)).isFalse()
|
||||
assertThat(matches(task, SmartList.OVERDUE)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `today takes a task completed today, whatever its dates`() {
|
||||
val done = testTask(status = TaskStatus.COMPLETED, due = at(990 * dayMs), completedAt = at(1000 * dayMs + 5))
|
||||
assertThat(matches(done, SmartList.TODAY)).isTrue()
|
||||
val dueTodayDoneEarlier = done.copy(due = at(1000 * dayMs + 1), completedAt = at(999 * dayMs))
|
||||
assertThat(matches(dueTodayDoneEarlier, SmartList.TODAY)).isFalse()
|
||||
val cancelled = testTask(status = TaskStatus.CANCELLED, due = at(1000 * dayMs + 1))
|
||||
assertThat(matches(cancelled, SmartList.TODAY)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -40,6 +40,14 @@ class TaskSectionsTest {
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the Today list keeps open tasks under Today`() {
|
||||
fun inToday(task: Task) = TaskSections.sectionOf(task, todayStart, todayEnd, todayList = true)
|
||||
assertThat(inToday(testTask(start = at(1000 * dayMs + 1)))).isEqualTo(TaskSection.TODAY)
|
||||
assertThat(inToday(testTask(start = at(1000 * dayMs + 1), due = at(1003 * dayMs)))).isEqualTo(TaskSection.TODAY)
|
||||
assertThat(inToday(testTask(status = TaskStatus.COMPLETED))).isEqualTo(TaskSection.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty input yields no sections`() {
|
||||
assertThat(TaskSections.of(emptyList(), todayStart, todayEnd)).isEmpty()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.jeanlucmakiola.agendula.ui.theme
|
||||
|
||||
import android.app.UiModeManager
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SystemThemeTest {
|
||||
|
||||
@Test
|
||||
fun `the system follows the app's light or dark`() {
|
||||
assertThat(SystemTheme.nightModeOf(ThemeMode.SYSTEM)).isEqualTo(UiModeManager.MODE_NIGHT_AUTO)
|
||||
assertThat(SystemTheme.nightModeOf(ThemeMode.LIGHT)).isEqualTo(UiModeManager.MODE_NIGHT_NO)
|
||||
assertThat(SystemTheme.nightModeOf(ThemeMode.DARK)).isEqualTo(UiModeManager.MODE_NIGHT_YES)
|
||||
assertThat(SystemTheme.nightModeOf(ThemeMode.BLACK)).isEqualTo(UiModeManager.MODE_NIGHT_YES)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the splash opens on the background the app draws`() {
|
||||
assertThat(SystemTheme.splashThemeOf(ThemeMode.SYSTEM, dynamicColor = true)).isEqualTo(R.style.Theme_Agendula)
|
||||
assertThat(SystemTheme.splashThemeOf(ThemeMode.DARK, dynamicColor = false)).isEqualTo(R.style.Theme_Agendula_Fallback)
|
||||
assertThat(SystemTheme.splashThemeOf(ThemeMode.BLACK, dynamicColor = true)).isEqualTo(R.style.Theme_Agendula_Black)
|
||||
assertThat(SystemTheme.splashThemeOf(ThemeMode.BLACK, dynamicColor = false)).isEqualTo(R.style.Theme_Agendula_Black)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.agendula.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.File
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
/** The launch window's colour has to be the background Compose then draws, or the splash flashes. */
|
||||
class WindowColorsTest {
|
||||
|
||||
private fun fallbackIn(qualifier: String): Int {
|
||||
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
|
||||
.parse(File("src/main/res/$qualifier/colors.xml"))
|
||||
val colors = doc.getElementsByTagName("color")
|
||||
val hex = (0 until colors.length).map { colors.item(it) }
|
||||
.single { it.attributes.getNamedItem("name").nodeValue == "window_background_fallback" }
|
||||
.textContent.trim().removePrefix("#")
|
||||
return hex.toLong(16).toInt()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the window matches the fallback schemes`() {
|
||||
assertThat(fallbackIn("values")).isEqualTo(AgendulaLightFallback.background.toArgb())
|
||||
assertThat(fallbackIn("values-night")).isEqualTo(AgendulaDarkFallback.background.toArgb())
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,13 @@ class WidgetDataTest {
|
||||
assertThat(sections.filterIsInstance<WidgetSection.TaskItem>().map { it.row.taskId }).containsExactly(1L, 2L, 3L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the Today list is one Today bucket whatever the rows' due days`() {
|
||||
val rows = listOf(row(1, dueKind = DueKind.TODAY), row(2, dueKind = DueKind.LATER), row(3, dueKind = null))
|
||||
val sections = widgetSections(rows, groupByList = false, listNames = emptyMap(), todayList = true) { it?.name ?: "none" }
|
||||
assertThat(sections.filterIsInstance<WidgetSection.Header>().map { it.key }).containsExactly("due:TODAY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grouping by list keeps the widgetRows order and headers each run once`() {
|
||||
val rows = listOf(row(1, listId = 1), row(2, listId = 1), row(3, listId = 2))
|
||||
|
||||
@@ -47,6 +47,37 @@ class WidgetStatsTest {
|
||||
assertThat(remaining).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task starting today counts as remaining`() {
|
||||
val open = listOf(
|
||||
testTask(id = 1, start = at("2026-07-20T16:00:00")),
|
||||
testTask(id = 2, start = at("2026-07-20T09:00:00"), due = at("2026-07-23T09:00:00")),
|
||||
testTask(id = 3, start = at("2026-07-21T09:00:00")),
|
||||
)
|
||||
val (_, remaining, _) = todayProgress(open, emptyList(), comparator, now, today, newYork)
|
||||
assertThat(remaining).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task due today but completed yesterday is not done today`() {
|
||||
val completed = listOf(
|
||||
testTask(id = 1, status = TaskStatus.COMPLETED, due = at("2026-07-20T18:00:00"), completedAt = at("2026-07-19T20:00:00")),
|
||||
)
|
||||
val (done, _, _) = todayProgress(emptyList(), completed, comparator, now, today, newYork)
|
||||
assertThat(done).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the week's today holds a task starting today, and not again on its due day`() {
|
||||
val open = listOf(
|
||||
testTask(id = 1, start = at("2026-07-20T09:00:00"), due = at("2026-07-22T09:00:00")),
|
||||
testTask(id = 2, start = at("2026-07-20T16:00:00")),
|
||||
)
|
||||
val week = weekDays(open, comparator, now, today, newYork)
|
||||
assertThat(week[0].count).isEqualTo(2)
|
||||
assertThat(week[2].count).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an earlier-due remaining task sorts ahead of a later one`() {
|
||||
val open = listOf(
|
||||
|
||||
@@ -76,11 +76,12 @@ class FakeStore(rows: List<TaskEntity> = emptyList()) : SyncStore {
|
||||
return id
|
||||
}
|
||||
|
||||
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
|
||||
taskIds.forEach { id ->
|
||||
val index = rows.indexOfFirst { it.id == id }
|
||||
override fun markSynced(rows: List<TaskEntity>, href: String?, eTag: String?) {
|
||||
rows.forEach { synced ->
|
||||
val index = this.rows.indexOfFirst { it.id == synced.id }
|
||||
if (index >= 0) {
|
||||
rows[index] = rows[index].copy(href = href, etag = eTag, isDirty = false)
|
||||
val current = this.rows[index]
|
||||
this.rows[index] = current.copy(href = href, etag = eTag, isDirty = current.lastModified != synced.lastModified)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
### جديد
|
||||
- خوادم ذاتية الاستضافة بشهادة موقّعة ذاتيًا أو عبر http.
|
||||
- تسمية الحسابات، والسحب للمزامنة، وطيّ أقسام القائمة.
|
||||
- سمة سوداء خالصة لشاشات AMOLED.
|
||||
- يمكن أن يظهر Agendula باسم "Tasks" في المشغّل.
|
||||
- تعرض التذكيرات وصف المهمة.
|
||||
|
||||
### تغييرات
|
||||
- يعرض "اليوم" أيضًا المهام التي تبدأ اليوم ويُبقي ما أُنجز اليوم.
|
||||
|
||||
### إصلاحات
|
||||
- إنجاز المهمة من أي مكان يلغي تذكيرها.
|
||||
- شاشة البداية تتبع سمة التطبيق.
|
||||
@@ -0,0 +1,6 @@
|
||||
### تغييرات
|
||||
- إنجاز مهمة متكررة ينقلها إلى موعدها التالي ويحتفظ بنسخة منجزة، فتراها التطبيقات الأخرى وواجهات الويب للخوادم منجزة.
|
||||
|
||||
### إصلاحات
|
||||
- مع tasks.org تحتفظ المهام المتكررة بقاعدتها: تعديلها لم يعد يحذفها وإنجازها لم يعد ينهي السلسلة كلها.
|
||||
- مع tasks.org وOpenTasks يعرض المحرر التكرار ويُحفظ ترتيب القوائم.
|
||||
@@ -0,0 +1,13 @@
|
||||
### Nové
|
||||
- Vlastní servery s certifikátem podepsaným sebou samým nebo přes http.
|
||||
- Pojmenování účtů, synchronizace tažením, sbalitelné sekce seznamu.
|
||||
- Čistě černý motiv pro displeje AMOLED.
|
||||
- Agendula se může v launcheru jmenovat „Tasks“.
|
||||
- Připomínky zobrazují popis úkolu.
|
||||
|
||||
### Změny
|
||||
- Dnes ukazuje i úkoly začínající dnes a ponechá dnes dokončené.
|
||||
|
||||
### Opravy
|
||||
- Dokončení úkolu kdekoli zruší jeho připomínku.
|
||||
- Úvodní obrazovka sleduje motiv aplikace.
|
||||
@@ -0,0 +1,6 @@
|
||||
### Změny
|
||||
- Odškrtnutí opakovaného úkolu jej posune na další termín a ponechá dokončenou kopii, takže jiné aplikace a webová rozhraní serverů jej vidí jako hotový.
|
||||
|
||||
### Opravy
|
||||
- S tasks.org si opakované úkoly drží své pravidlo: úprava ho už neodstraní a odškrtnutí už neukončí celou sérii.
|
||||
- S tasks.org a OpenTasks editor ukazuje opakování a pořadí seznamů zůstává zachováno.
|
||||
@@ -0,0 +1,13 @@
|
||||
### Neu
|
||||
- Eigene Server mit selbstsigniertem Zertifikat oder über http.
|
||||
- Konten benennen, zum Synchronisieren ziehen, Listenabschnitte einklappen.
|
||||
- Ein reines Schwarz-Design für AMOLED-Displays.
|
||||
- Agendula kann im Launcher als „Tasks“ erscheinen.
|
||||
- Erinnerungen zeigen die Beschreibung der Aufgabe.
|
||||
|
||||
### Geändert
|
||||
- Heute zeigt auch heute beginnende und heute erledigte Aufgaben.
|
||||
|
||||
### Behoben
|
||||
- Abhaken, egal wo, beendet die Erinnerung der Aufgabe.
|
||||
- Der Startbildschirm folgt dem App-Design.
|
||||
@@ -0,0 +1,6 @@
|
||||
### Geändert
|
||||
- Eine wiederkehrende Aufgabe abzuhaken rückt sie zum nächsten Termin vor und behält eine erledigte Kopie, sodass andere Apps und Server-Weboberflächen sie als erledigt sehen.
|
||||
|
||||
### Behoben
|
||||
- Mit tasks.org behalten wiederkehrende Aufgaben ihre Regel: Bearbeiten entfernt sie nicht mehr, Abhaken beendet nicht mehr die ganze Serie.
|
||||
- Mit tasks.org und OpenTasks zeigt der Editor die Wiederholung, und die Reihenfolge der Listen bleibt erhalten.
|
||||
@@ -0,0 +1,13 @@
|
||||
### Added
|
||||
- Self-hosted servers with a self-signed certificate or plain http.
|
||||
- Name your accounts, pull to sync and collapse list sections.
|
||||
- A pure black theme for AMOLED screens.
|
||||
- Agendula can show up as "Tasks" in your launcher.
|
||||
- Reminders show the task's description.
|
||||
|
||||
### Changed
|
||||
- Today also lists tasks starting today and keeps today's completions.
|
||||
|
||||
### Fixed
|
||||
- Completing a task anywhere cancels its reminder.
|
||||
- The splash screen follows the app theme.
|
||||
@@ -0,0 +1,6 @@
|
||||
### Changed
|
||||
- Ticking off a repeating task moves it on to its next date and keeps a completed copy, so other apps and server web views see it as done.
|
||||
|
||||
### Fixed
|
||||
- With tasks.org, repeating tasks keep their rule: editing one no longer removes it and ticking one off no longer ends the series.
|
||||
- With tasks.org and OpenTasks, the editor shows the repeat rule and your list order is kept.
|
||||
@@ -0,0 +1,13 @@
|
||||
### Added
|
||||
- Self-hosted servers with a self-signed certificate or plain http.
|
||||
- Name your accounts, pull to sync and collapse list sections.
|
||||
- A pure black theme for AMOLED screens.
|
||||
- Agendula can show up as "Tasks" in your launcher.
|
||||
- Reminders show the task's description.
|
||||
|
||||
### Changed
|
||||
- Today also lists tasks starting today and keeps today's completions.
|
||||
|
||||
### Fixed
|
||||
- Completing a task anywhere cancels its reminder.
|
||||
- The splash screen follows the app theme.
|
||||
@@ -0,0 +1,6 @@
|
||||
### Changed
|
||||
- Ticking off a repeating task moves it on to its next date and keeps a completed copy, so other apps and server web views see it as done.
|
||||
|
||||
### Fixed
|
||||
- With tasks.org, repeating tasks keep their rule: editing one no longer removes it and ticking one off no longer ends the series.
|
||||
- With tasks.org and OpenTasks, the editor shows the repeat rule and your list order is kept.
|
||||
@@ -0,0 +1,13 @@
|
||||
### Nouveautés
|
||||
- Serveurs perso avec certificat auto-signé ou en http.
|
||||
- Nommer les comptes, tirer pour synchroniser, replier les sections.
|
||||
- Un thème noir pur pour les écrans AMOLED.
|
||||
- Agendula peut s'afficher « Tasks » dans le lanceur.
|
||||
- Les rappels affichent la description de la tâche.
|
||||
|
||||
### Modifications
|
||||
- Aujourd'hui montre aussi les tâches débutées ou finies du jour.
|
||||
|
||||
### Corrections
|
||||
- Cocher une tâche, où que ce soit, annule son rappel.
|
||||
- L'écran de démarrage suit le thème de l'appli.
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user