Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89775e44a7 | ||
|
|
c0eb03e5df | ||
|
|
7bfda26de9 | ||
|
|
f65aed4fe2 | ||
|
|
84bb1116ec | ||
|
|
7fde7ae290 | ||
|
|
5d273e05c7 | ||
|
|
59582b3641 | ||
|
|
3540d2fb7c | ||
|
|
3812e950ca | ||
|
|
c6d62c36f4 | ||
|
|
a4fbbf43c6 | ||
|
|
d81e99084e | ||
|
|
9a87837917 | ||
|
|
9630bd104d | ||
|
|
7c3b62eb0d |
@@ -27,7 +27,7 @@ jobs:
|
|||||||
detect:
|
detect:
|
||||||
# Gitea only; see the same guard in release.yaml.
|
# Gitea only; see the same guard in release.yaml.
|
||||||
if: github.repository_owner == 'makiolaj'
|
if: github.repository_owner == 'makiolaj'
|
||||||
runs-on: light
|
runs-on: docker
|
||||||
outputs:
|
outputs:
|
||||||
is_beta: ${{ steps.v.outputs.is_beta }}
|
is_beta: ${{ steps.v.outputs.is_beta }}
|
||||||
version: ${{ steps.v.outputs.version }}
|
version: ${{ steps.v.outputs.version }}
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
beta:
|
beta:
|
||||||
needs: detect
|
needs: detect
|
||||||
if: needs.detect.outputs.is_beta == 'true'
|
if: needs.detect.outputs.is_beta == 'true'
|
||||||
runs-on: gradle
|
runs-on: docker
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
ANDROID_HOME: /opt/android-sdk
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
# secrets. repository_owner differs between the two forges regardless of
|
# secrets. repository_owner differs between the two forges regardless of
|
||||||
# URL, proxy or instance rename, so this closes it permanently.
|
# URL, proxy or instance rename, so this closes it permanently.
|
||||||
if: github.repository_owner == 'makiolaj'
|
if: github.repository_owner == 'makiolaj'
|
||||||
runs-on: light
|
runs-on: docker
|
||||||
outputs:
|
outputs:
|
||||||
is_release: ${{ steps.v.outputs.is_release }}
|
is_release: ${{ steps.v.outputs.is_release }}
|
||||||
version: ${{ steps.v.outputs.version }}
|
version: ${{ steps.v.outputs.version }}
|
||||||
@@ -105,7 +105,7 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
needs: detect
|
needs: detect
|
||||||
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
runs-on: gradle
|
runs-on: docker
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
ANDROID_HOME: /opt/android-sdk
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
|||||||
@@ -7,22 +7,6 @@ All notable changes to this project are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Reminders now sync. A reminder set on a task is uploaded as a standard alarm
|
|
||||||
(`VALARM`), so it also notifies in macOS and iOS Reminders, Thunderbird,
|
|
||||||
tasks.org and jtx Board, and alarms set in those apps now fire in Agendula
|
|
||||||
instead of the default reminder. Removing a task's alarm in another app
|
|
||||||
turns it off here too, rather than falling back to the default (#52).
|
|
||||||
- A new task with a due date, or one that gets its first due date, takes the
|
|
||||||
default reminder as its own, so it travels with the task to the server.
|
|
||||||
Changing the default later no longer changes those tasks; tasks that rely on
|
|
||||||
the default today are left as they are.
|
|
||||||
- On upgrade, reminders on open, synced tasks that so far stayed on this
|
|
||||||
device are uploaded once.
|
|
||||||
- Alarms imported from an `.ics` file become reminders on the task.
|
|
||||||
|
|
||||||
## [1.1.0] - 2026-10-10
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Four new home-screen widgets: Today, Up next, Lists and Week.
|
- Four new home-screen widgets: Today, Up next, Lists and Week.
|
||||||
- A widget can show tasks from several lists at once, optionally grouped by
|
- A widget can show tasks from several lists at once, optionally grouped by
|
||||||
@@ -30,13 +14,6 @@ All notable changes to this project are documented here. The format follows
|
|||||||
- A setting under Settings → Task form pre-fills a new task's start with today.
|
- A setting under Settings → Task form pre-fills a new task's start with today.
|
||||||
- An offline version of Agendula with no sync and no network access at all,
|
- An offline version of Agendula with no sync and no network access at all,
|
||||||
published as a separate APK on Codeberg (#39).
|
published as a separate APK on Codeberg (#39).
|
||||||
- Tags. Add them while editing a task, see them on the task and in lists,
|
|
||||||
browse them from the home screen and filter any list by them. They are saved
|
|
||||||
as `CATEGORIES`, so they sync with Nextcloud Tasks, tasks.org and DAVx⁵ (#35).
|
|
||||||
- Filters. Save your own view of the open tasks by due date (overdue, today,
|
|
||||||
upcoming, no date, in any combination), lists, tags and minimum priority.
|
|
||||||
They live on the home screen and can be shown in the Tasks widget, so today
|
|
||||||
and overdue tasks can now share one widget (#47).
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- The Tasks widget has agenda-style rows, an optional row colour and an "All
|
- The Tasks widget has agenda-style rows, an optional row colour and an "All
|
||||||
@@ -46,17 +23,9 @@ All notable changes to this project are documented here. The format follows
|
|||||||
showed.
|
showed.
|
||||||
- Synced lists can be renamed and deleted, not only device-only ones. A
|
- Synced lists can be renamed and deleted, not only device-only ones. A
|
||||||
read-only share shows the edit button greyed out and explains why on tap.
|
read-only share shows the edit button greyed out and explains why on tap.
|
||||||
- The sort button in task lists now opens "Sort and filter", which can also
|
|
||||||
filter by tag.
|
|
||||||
- While editing a task, its title moves into the top bar once you scroll past
|
|
||||||
it.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- The list editor's Where and smart-lists rows no longer have double padding.
|
- The list editor's Where and smart-lists rows no longer have double padding.
|
||||||
- A field you tap now scrolls fully above the keyboard, and the "add subtask"
|
|
||||||
row in task lists no longer ends up hidden behind it.
|
|
||||||
- All-day tasks west of UTC no longer show up a day early in Overdue, Today
|
|
||||||
and Upcoming.
|
|
||||||
|
|
||||||
## [1.0.0] - 2026-09-21
|
## [1.0.0] - 2026-09-21
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ required.</p>
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://f-droid.org/packages/de.jeanlucmakiola.agendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
|
||||||
|
|
||||||
<a href="https://play.google.com/store/apps/details?id=de.jeanlucmakiola.agendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
|
|
||||||
|
|
||||||
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
|
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
|
||||||
|
|
||||||
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||||
@@ -54,8 +50,7 @@ many small *florets* — so the two apps are florets of one bloom.)
|
|||||||
this and the following ones, or the whole series.
|
this and the following ones, or the whole series.
|
||||||
- **Reminders** that fire at the exact time: several per task, separate defaults
|
- **Reminders** that fire at the exact time: several per task, separate defaults
|
||||||
for timed and all-day tasks (globally and per list), Done and Snooze right on
|
for timed and all-day tasks (globally and per list), Done and Snooze right on
|
||||||
the notification, and they survive reboots. They sync as standard `VALARM`s,
|
the notification, and they survive reboots.
|
||||||
so they also ring in other CalDAV clients, and theirs ring here.
|
|
||||||
- **iCalendar import and export** — open an `.ics` or a zip of them, or write any
|
- **iCalendar import and export** — open an `.ics` or a zip of them, or write any
|
||||||
list out as standard `.ics` files.
|
list out as standard `.ics` files.
|
||||||
- A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and
|
- A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and
|
||||||
@@ -81,16 +76,6 @@ back to a full comparison where it does not. Everything the store does not model
|
|||||||
is kept verbatim and sent back unchanged, so passing your tasks through Agendula
|
is kept verbatim and sent back unchanged, so passing your tasks through Agendula
|
||||||
does not quietly lose fields another client wrote.
|
does not quietly lose fields another client wrote.
|
||||||
|
|
||||||
### Tested With
|
|
||||||
|
|
||||||
The following CalDAV Services have been Tested
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Service | Version | Date | Status | User |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| **Nextcloud** | 25.0.0 | Oct 9th 2026 | ✅ | [@jlmakiola](https://codeberg.org/jlmakiola) [@Schanbro](https://codeberg.org/Schanbro)
|
|
||||||
|
|
||||||
## Where your tasks live
|
## Where your tasks live
|
||||||
|
|
||||||
| | Where | Sync | Needs |
|
| | Where | Sync | Needs |
|
||||||
@@ -112,20 +97,10 @@ proprietary, and open standards — CalDAV and iCalendar — are the lane.
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
### F-Droid (recommended)
|
### F-Droid repository
|
||||||
|
|
||||||
Agendula is in the official [F-Droid](https://f-droid.org) repository. Search
|
Every release is built, signed and published to a self-hosted F-Droid
|
||||||
for Agendula in any F-Droid client, or
|
repository. Add it once and your F-Droid client handles updates from then on:
|
||||||
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.agendula/).
|
|
||||||
|
|
||||||
F-Droid builds from source on its own schedule, so a new version usually shows
|
|
||||||
up there a few days after release.
|
|
||||||
|
|
||||||
### Self-hosted F-Droid repo (fastest updates)
|
|
||||||
|
|
||||||
The release pipeline builds, signs and publishes every release to a
|
|
||||||
self-hosted F-Droid repository, so new versions land there first. Add it once
|
|
||||||
and your F-Droid client handles updates from then on:
|
|
||||||
|
|
||||||
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||||
link below on your phone):
|
link below on your phone):
|
||||||
@@ -150,15 +125,6 @@ app.
|
|||||||
Betas of upcoming versions are published there too, as pre-releases; to test
|
Betas of upcoming versions are published there too, as pre-releases; to test
|
||||||
them, switch on *Include prereleases* for Agendula in Obtainium.
|
them, switch on *Include prereleases* for Agendula in Obtainium.
|
||||||
|
|
||||||
### Google Play
|
|
||||||
|
|
||||||
Agendula is on
|
|
||||||
[Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.agendula).
|
|
||||||
|
|
||||||
Play builds are signed with Google's key rather than mine, so switching between
|
|
||||||
Play and any other channel means uninstalling first. Tasks kept only in
|
|
||||||
Agendula's own database go with it, so sync them to a CalDAV account first.
|
|
||||||
|
|
||||||
### Offline version
|
### Offline version
|
||||||
|
|
||||||
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
|
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
|
||||||
@@ -182,9 +148,8 @@ JDK 17 and the Android SDK are all it needs; see
|
|||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
Translations are managed on a self-hosted **Weblate**, and partial ones are
|
Translations are managed on a self-hosted **Weblate**, and partial ones are
|
||||||
fine — an untranslated string simply falls back to English.
|
fine — an untranslated string simply falls back to English. Agendula ships in
|
||||||
|
English, German and Brazilian Portuguese so far.
|
||||||
<a href="https://weblate.dev.jeanlucmakiola.de/engage/agendula/"><img src="https://weblate.dev.jeanlucmakiola.de/widget/agendula/multi-auto.svg" alt="Translation status" width="600"></a>
|
|
||||||
|
|
||||||
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
|
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ android {
|
|||||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
// (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.
|
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||||
// See docs/RELEASING.md.
|
// See docs/RELEASING.md.
|
||||||
versionCode = 1010099
|
versionCode = 1010002
|
||||||
versionName = "1.1.0"
|
versionName = "1.1.0-beta.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -1,529 +0,0 @@
|
|||||||
{
|
|
||||||
"formatVersion": 1,
|
|
||||||
"database": {
|
|
||||||
"version": 2,
|
|
||||||
"identityHash": "14b159c51416ba27653f6320ac7f6f03",
|
|
||||||
"entities": [
|
|
||||||
{
|
|
||||||
"tableName": "accounts",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "displayName",
|
|
||||||
"columnName": "display_name",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "principalUrl",
|
|
||||||
"columnName": "principal_url",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "homeSetUrl",
|
|
||||||
"columnName": "home_set_url",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "username",
|
|
||||||
"columnName": "username",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastSyncAt",
|
|
||||||
"columnName": "last_sync_at",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastSyncError",
|
|
||||||
"columnName": "last_sync_error",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "task_lists",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "name",
|
|
||||||
"columnName": "name",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "color",
|
|
||||||
"columnName": "color",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "accountId",
|
|
||||||
"columnName": "account_id",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isVisible",
|
|
||||||
"columnName": "is_visible",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isSynced",
|
|
||||||
"columnName": "is_synced",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "owner",
|
|
||||||
"columnName": "owner",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isReadOnly",
|
|
||||||
"columnName": "is_read_only",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sortOrder",
|
|
||||||
"columnName": "sort_order",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "href",
|
|
||||||
"columnName": "href",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "ctag",
|
|
||||||
"columnName": "ctag",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "syncToken",
|
|
||||||
"columnName": "sync_token",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isDirty",
|
|
||||||
"columnName": "is_dirty",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_task_lists_account_id",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"account_id"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "accounts",
|
|
||||||
"onDelete": "SET NULL",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"account_id"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "tasks",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "listId",
|
|
||||||
"columnName": "list_id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "uid",
|
|
||||||
"columnName": "uid",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "href",
|
|
||||||
"columnName": "href",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "etag",
|
|
||||||
"columnName": "etag",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "title",
|
|
||||||
"columnName": "title",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "description",
|
|
||||||
"columnName": "description",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "location",
|
|
||||||
"columnName": "location",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "url",
|
|
||||||
"columnName": "url",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "color",
|
|
||||||
"columnName": "color",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "categories",
|
|
||||||
"columnName": "categories",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "''"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "status",
|
|
||||||
"columnName": "status",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "percentComplete",
|
|
||||||
"columnName": "percent_complete",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "completedAt",
|
|
||||||
"columnName": "completed_at",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "priority",
|
|
||||||
"columnName": "priority",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "classification",
|
|
||||||
"columnName": "classification",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "dtstart",
|
|
||||||
"columnName": "dtstart",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "due",
|
|
||||||
"columnName": "due",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "duration",
|
|
||||||
"columnName": "duration",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isAllDay",
|
|
||||||
"columnName": "is_all_day",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "timezone",
|
|
||||||
"columnName": "timezone",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "rrule",
|
|
||||||
"columnName": "rrule",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "rdate",
|
|
||||||
"columnName": "rdate",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "exdate",
|
|
||||||
"columnName": "exdate",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "recurrenceId",
|
|
||||||
"columnName": "recurrence_id",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "masterId",
|
|
||||||
"columnName": "master_id",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "parentId",
|
|
||||||
"columnName": "parent_id",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sortOrder",
|
|
||||||
"columnName": "sort_order",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "createdAt",
|
|
||||||
"columnName": "created_at",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastModified",
|
|
||||||
"columnName": "last_modified",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sequence",
|
|
||||||
"columnName": "sequence",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isDirty",
|
|
||||||
"columnName": "is_dirty",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isDeleted",
|
|
||||||
"columnName": "is_deleted",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "unknownProperties",
|
|
||||||
"columnName": "unknown_properties",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_tasks_list_id_is_deleted",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"list_id",
|
|
||||||
"is_deleted"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_tasks_parent_id",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"parent_id"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_tasks_master_id_recurrence_id",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"master_id",
|
|
||||||
"recurrence_id"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_tasks_is_dirty",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"is_dirty"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_tasks_list_id_uid_recurrence_id",
|
|
||||||
"unique": true,
|
|
||||||
"columnNames": [
|
|
||||||
"list_id",
|
|
||||||
"uid",
|
|
||||||
"recurrence_id"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "task_lists",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"list_id"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"table": "tasks",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"master_id"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"table": "tasks",
|
|
||||||
"onDelete": "SET NULL",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"parent_id"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "task_alarms",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "taskId",
|
|
||||||
"columnName": "task_id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "minutesBefore",
|
|
||||||
"columnName": "minutes_before",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "reference",
|
|
||||||
"columnName": "reference",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "'DUE'"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "message",
|
|
||||||
"columnName": "message",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_task_alarms_task_id",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"task_id"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "tasks",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"task_id"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"setupQueries": [
|
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
|
||||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '14b159c51416ba27653f6320ac7f6f03')"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
{
|
|
||||||
"formatVersion": 1,
|
|
||||||
"database": {
|
|
||||||
"version": 3,
|
|
||||||
"identityHash": "725292b38bce25ce9032c4650573ae85",
|
|
||||||
"entities": [
|
|
||||||
{
|
|
||||||
"tableName": "accounts",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "displayName",
|
|
||||||
"columnName": "display_name",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "principalUrl",
|
|
||||||
"columnName": "principal_url",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "homeSetUrl",
|
|
||||||
"columnName": "home_set_url",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "username",
|
|
||||||
"columnName": "username",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastSyncAt",
|
|
||||||
"columnName": "last_sync_at",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastSyncError",
|
|
||||||
"columnName": "last_sync_error",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "task_lists",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "name",
|
|
||||||
"columnName": "name",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "color",
|
|
||||||
"columnName": "color",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "accountId",
|
|
||||||
"columnName": "account_id",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isVisible",
|
|
||||||
"columnName": "is_visible",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isSynced",
|
|
||||||
"columnName": "is_synced",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "owner",
|
|
||||||
"columnName": "owner",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isReadOnly",
|
|
||||||
"columnName": "is_read_only",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sortOrder",
|
|
||||||
"columnName": "sort_order",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "href",
|
|
||||||
"columnName": "href",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "ctag",
|
|
||||||
"columnName": "ctag",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "syncToken",
|
|
||||||
"columnName": "sync_token",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isDirty",
|
|
||||||
"columnName": "is_dirty",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_task_lists_account_id",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"account_id"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "accounts",
|
|
||||||
"onDelete": "SET NULL",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"account_id"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "tasks",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, `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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"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, '725292b38bce25ce9032c4650573ae85')"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
-9
@@ -212,15 +212,6 @@ class OneShotImportTest {
|
|||||||
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
|
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun importsCategoryPropertiesAsTags() {
|
|
||||||
importer.importFrom(fixture())
|
|
||||||
val tasks = taskRows()
|
|
||||||
|
|
||||||
assertThat(tasks.getValue("Buy milk").categories).containsExactly("Errands")
|
|
||||||
assertThat(tasks.getValue("Ship the release").categories).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- running it -----------------------------------------------------------
|
// --- running it -----------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+1
-129
@@ -411,134 +411,6 @@ class RoomTasksDataSourceTest {
|
|||||||
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aSaveKeepsTheIdentityOfAReminderItDidNotChange() {
|
|
||||||
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(15)))
|
|
||||||
val original = db.alarms().forTask(id).single()
|
|
||||||
db.alarms().update(original.copy(raw = "BEGIN:VALARM\r\nX-VENDOR:keep\r\nEND:VALARM\r\n"))
|
|
||||||
|
|
||||||
source.updateTask(id, form(title = "renamed", due = now + 2.days), dueReminders = listOf(15, 60))
|
|
||||||
|
|
||||||
val rows = db.alarms().forTask(id)
|
|
||||||
val kept = rows.single { it.minutesBefore == 15 }
|
|
||||||
// Same row, same identity on the server, same original text.
|
|
||||||
assertThat(kept.id).isEqualTo(original.id)
|
|
||||||
assertThat(kept.uid).isEqualTo(original.uid)
|
|
||||||
assertThat(kept.raw).contains("X-VENDOR:keep")
|
|
||||||
assertThat(rows.single { it.minutesBefore == 60 }.uid).isNotEqualTo(original.uid)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aSaveKeepsABeforeStartReminderTheEditorNeverShows() {
|
|
||||||
val id = source.insertTask(form(due = now + 1.days))
|
|
||||||
source.setReminders(id, listOf(TaskReminder(10, fromStart = true), TaskReminder(30)))
|
|
||||||
|
|
||||||
source.updateTask(id, form(due = now + 1.days), dueReminders = listOf(45))
|
|
||||||
|
|
||||||
assertThat(source.reminders()[id]).containsExactly(TaskReminder(10, fromStart = true), TaskReminder(45))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aReminderForOneOccurrenceLeavesTheSeriesUntouched() {
|
|
||||||
val work = syncedList()
|
|
||||||
val id = source.insertTask(form(due = now + 1.days).copy(listId = work), listOf(TaskReminder(30)))
|
|
||||||
makeRecurring(id, now)
|
|
||||||
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
|
||||||
val target = source.tasks(TaskQuery(listId = work))
|
|
||||||
.filter { it.taskId == id }
|
|
||||||
.first { it.distanceFromCurrent == 1 }
|
|
||||||
|
|
||||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved").copy(listId = work), listOf(60))
|
|
||||||
|
|
||||||
// ⚠️ The master is never written, not even briefly: a sync landing in
|
|
||||||
// between would upload the occurrence's reminder for the whole series.
|
|
||||||
assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(30)
|
|
||||||
assertThat(db.tasks().entity(id)!!.isDirty).isFalse()
|
|
||||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
|
||||||
assertThat(db.alarms().forTask(override.id).map { it.minutesBefore }).containsExactly(60)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun splittingASeriesGivesTheNewSeriesItsOwnReminderIdentities() {
|
|
||||||
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(30)))
|
|
||||||
makeRecurring(id, now - 14.days)
|
|
||||||
val master = db.alarms().forTask(id).single()
|
|
||||||
val target = source.tasks(TaskQuery(listId = listId))
|
|
||||||
.filter { it.taskId == id }
|
|
||||||
.first { it.distanceFromCurrent == 1 }
|
|
||||||
|
|
||||||
val created = source.splitSeries(id, target.occurrenceStart!!, form(title = "later"), listOf(30))
|
|
||||||
|
|
||||||
assertThat(created).isNotEqualTo(id)
|
|
||||||
val copy = db.alarms().forTask(created).single()
|
|
||||||
assertThat(copy.minutesBefore).isEqualTo(30)
|
|
||||||
// A different UID on the server, so the alarm must not share one either.
|
|
||||||
assertThat(copy.uid).isNotEqualTo(master.uid)
|
|
||||||
assertThat(db.alarms().forTask(id).single().uid).isEqualTo(master.uid)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun changingOnlyTheRemindersOwesTheServerAPut() {
|
|
||||||
val work = syncedList()
|
|
||||||
val id = source.insertTask(form(due = now + 1.days).copy(listId = work))
|
|
||||||
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
|
||||||
|
|
||||||
source.setReminders(id, listOf(TaskReminder(15)))
|
|
||||||
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
|
|
||||||
|
|
||||||
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
|
||||||
source.setReminders(id, listOf(TaskReminder(15)))
|
|
||||||
// Nothing changed, so nothing is owed.
|
|
||||||
assertThat(db.tasks().entity(id)!!.isDirty).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aSeriesReminderEditReachesTheOccurrencesThatStillHadTheOldOne() {
|
|
||||||
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(15)))
|
|
||||||
makeRecurring(id, now)
|
|
||||||
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
|
||||||
val completed = occurrences.first { it.distanceFromCurrent == 1 }.occurrenceStart!!
|
|
||||||
val own = occurrences.first { it.distanceFromCurrent == 2 }.occurrenceStart!!
|
|
||||||
source.setCompletedInstance(id, completed, true)
|
|
||||||
source.updateInstance(id, own, form(title = "own"), listOf(5))
|
|
||||||
|
|
||||||
// Same times and rule, so the series is not reshaped — that drops its overrides.
|
|
||||||
val first = occurrences.first()
|
|
||||||
val unchanged = form().copy(start = first.start, due = first.due, rrule = "FREQ=WEEKLY")
|
|
||||||
source.updateSeries(id, first.occurrenceStart!!, unchanged, listOf(60))
|
|
||||||
|
|
||||||
fun leads(anchor: Instant) = db.alarms().forTask(db.tasks().override(id, anchor)!!.id).map { it.minutesBefore }
|
|
||||||
assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(60)
|
|
||||||
// A snapshot of the series' reminder follows it…
|
|
||||||
assertThat(leads(completed)).containsExactly(60)
|
|
||||||
// …one set for that occurrence alone does not.
|
|
||||||
assertThat(leads(own)).containsExactly(5)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aTaskWhoseAlarmsWereTurnedOffElsewhereReportsNone() {
|
|
||||||
val id = source.insertTask(form(due = now + 1.days))
|
|
||||||
db.alarms().setRemindersOff(id, true)
|
|
||||||
|
|
||||||
assertThat(source.reminders()[id]).isEmpty()
|
|
||||||
assertThat(source.reminders()).containsKey(id)
|
|
||||||
|
|
||||||
// A reminder of its own again: the flag goes.
|
|
||||||
source.setReminders(id, listOf(TaskReminder(15)))
|
|
||||||
assertThat(db.tasks().entity(id)!!.remindersOff).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun settingTheEditableReminderOwesTheServerAPut() {
|
|
||||||
val work = syncedList()
|
|
||||||
val id = source.insertTask(form(due = now + 1.days).copy(listId = work))
|
|
||||||
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
|
||||||
|
|
||||||
source.setAlarm(id, 30)
|
|
||||||
|
|
||||||
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun deletingATaskInALocalListRemovesItOutright() {
|
fun deletingATaskInALocalListRemovesItOutright() {
|
||||||
val id = source.insertTask(form())
|
val id = source.insertTask(form())
|
||||||
@@ -575,7 +447,7 @@ class RoomTasksDataSourceTest {
|
|||||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
||||||
|
|
||||||
// The fork copies the master's properties, which is what carries the
|
// The fork copies the master's properties, which is what carries the
|
||||||
// reminder across.
|
// reminder across — the repository is what puts the series' own back.
|
||||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||||
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-106
@@ -8,7 +8,27 @@ import org.junit.Rule
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
/** Migrations, proven against the committed schemas in `app/schemas/`. */
|
/**
|
||||||
|
* The migration harness, proven against the committed schema in `app/schemas/`.
|
||||||
|
*
|
||||||
|
* There is one schema version today, so all there is to assert is that the helper
|
||||||
|
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
|
||||||
|
* export, the assets wiring and the identity hash all line up. That is the point:
|
||||||
|
* the first real migration only has to add its own case.
|
||||||
|
*
|
||||||
|
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
|
||||||
|
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
|
||||||
|
* database, and add a test here shaped like this:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* helper.createDatabase(TEST_DB, 1).use { db ->
|
||||||
|
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
|
||||||
|
* }
|
||||||
|
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
|
||||||
|
* // read the seeded rows back — validation proves the shape, not the data
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class TasksDatabaseMigrationTest {
|
class TasksDatabaseMigrationTest {
|
||||||
|
|
||||||
@@ -42,111 +62,6 @@ class TasksDatabaseMigrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun liftsCategoriesOutOfTheResidueWithoutDirtyingTheTask() {
|
|
||||||
helper.createDatabase(TEST_DB, 1).use { db ->
|
|
||||||
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Work', 0)")
|
|
||||||
db.execSQL(
|
|
||||||
"INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)",
|
|
||||||
arrayOf<Any?>(1, "uid-1", "Claimed", "X-FOO:bar\r\nCATEGORIES:Work,Home\r\n"),
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)",
|
|
||||||
arrayOf<Any?>(2, "uid-2", "Split", "CATEGORIES:a\r\nCATEGORIES:b\r\n"),
|
|
||||||
)
|
|
||||||
db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (3, 1, 'uid-3', 'Untagged')")
|
|
||||||
}
|
|
||||||
|
|
||||||
helper.runMigrationsAndValidate(TEST_DB, 2, true, TasksDatabase.MIGRATION_1_2).use { db ->
|
|
||||||
db.query("SELECT categories, unknown_properties, is_dirty FROM tasks ORDER BY id").use { c ->
|
|
||||||
c.moveToNext()
|
|
||||||
assertThat(c.getString(0)).isEqualTo("Work,Home")
|
|
||||||
assertThat(c.getString(1)).doesNotContain("CATEGORIES")
|
|
||||||
assertThat(c.getString(1)).contains("X-FOO:bar")
|
|
||||||
assertThat(c.getInt(2)).isEqualTo(0)
|
|
||||||
|
|
||||||
c.moveToNext()
|
|
||||||
assertThat(c.getString(0)).isEqualTo("a,b")
|
|
||||||
assertThat(c.getString(1)).isEqualTo("CATEGORIES:a\r\nCATEGORIES:b\r\n")
|
|
||||||
|
|
||||||
c.moveToNext()
|
|
||||||
assertThat(c.getString(0)).isEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun liftsClaimableAlarmsAndPushesRemindersThatNeverLeftTheDevice() {
|
|
||||||
val future = System.currentTimeMillis() + 7L * 24 * 60 * 60 * 1000
|
|
||||||
val past = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000
|
|
||||||
val claimable = "X-FOO:bar\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nX-WR-ALARMUID:srv\r\n" +
|
|
||||||
"TRIGGER;RELATED=END:-PT15M\r\nEND:VALARM\r\n"
|
|
||||||
val appleDefault = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nX-APPLE-DEFAULT-ALARM:TRUE\r\n" +
|
|
||||||
"TRIGGER;RELATED=END:-PT30M\r\nEND:VALARM\r\n"
|
|
||||||
helper.createDatabase(TEST_DB, 2).use { db ->
|
|
||||||
db.execSQL("INSERT INTO accounts (id, display_name) VALUES (1, 'me')")
|
|
||||||
db.execSQL("INSERT INTO task_lists (id, name, color, account_id) VALUES (1, 'Synced', 0, 1)")
|
|
||||||
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (2, 'Local', 0)")
|
|
||||||
db.execSQL("INSERT INTO task_lists (id, name, color, account_id, is_read_only) VALUES (3, 'Shared', 0, 1, 1)")
|
|
||||||
fun task(id: Int, list: Int, due: Long, residue: String? = null) = db.execSQL(
|
|
||||||
"INSERT INTO tasks (id, list_id, uid, title, due, unknown_properties) VALUES (?, ?, ?, ?, ?, ?)",
|
|
||||||
arrayOf<Any?>(id, list, "uid-$id", "Task $id", due, residue),
|
|
||||||
)
|
|
||||||
fun alarm(task: Int, minutes: Int) =
|
|
||||||
db.execSQL("INSERT INTO task_alarms (task_id, minutes_before) VALUES (?, ?)", arrayOf<Any?>(task, minutes))
|
|
||||||
// A server alarm, and the same lead copied locally by a fork.
|
|
||||||
task(1, 1, future, claimable)
|
|
||||||
alarm(1, 15)
|
|
||||||
// Set in Agendula, never uploaded — still to fire, already past, device-only.
|
|
||||||
task(2, 1, future)
|
|
||||||
alarm(2, 30)
|
|
||||||
task(3, 1, past)
|
|
||||||
alarm(3, 30)
|
|
||||||
task(4, 2, future)
|
|
||||||
alarm(4, 30)
|
|
||||||
// Somebody else's default alarm: not ours to claim.
|
|
||||||
task(5, 1, future, appleDefault)
|
|
||||||
// A read-only share: dirty would freeze it, never sent and never refreshed.
|
|
||||||
task(6, 3, future)
|
|
||||||
alarm(6, 30)
|
|
||||||
}
|
|
||||||
|
|
||||||
helper.runMigrationsAndValidate(TEST_DB, 3, true, TasksDatabase.MIGRATION_2_3).use { db ->
|
|
||||||
db.query("SELECT task_id, minutes_before, uid, raw FROM task_alarms ORDER BY task_id").use { c ->
|
|
||||||
val rows = buildList { while (c.moveToNext()) add(listOf(c.getLong(0), c.getInt(1), c.getString(2), c.getString(3))) }
|
|
||||||
// One for task 1: the lifted copy won and the local duplicate went.
|
|
||||||
assertThat(rows.map { it[0] }).containsExactly(1L, 2L, 3L, 4L, 6L).inOrder()
|
|
||||||
assertThat(rows[0][2]).isEqualTo("srv")
|
|
||||||
assertThat(rows[0][3] as String).contains("TRIGGER;RELATED=END:-PT15M")
|
|
||||||
// Every pre-existing row got an identity of its own, shaped as a UUID.
|
|
||||||
assertThat(rows.drop(1).map { it[2] as String }.toSet()).hasSize(4)
|
|
||||||
rows.drop(1).forEach {
|
|
||||||
assertThat(it[2] as String).matches("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
|
|
||||||
}
|
|
||||||
assertThat(rows.drop(1).all { (it[2] as String).isNotEmpty() && it[3] == null }).isTrue()
|
|
||||||
}
|
|
||||||
db.query("SELECT id, unknown_properties, is_dirty, last_modified, reminders_off FROM tasks ORDER BY id").use { c ->
|
|
||||||
c.moveToNext()
|
|
||||||
assertThat(c.getString(1)).isEqualTo("X-FOO:bar\r\n")
|
|
||||||
assertThat(c.getInt(2)).isEqualTo(0)
|
|
||||||
assertThat(c.getInt(4)).isEqualTo(0)
|
|
||||||
c.moveToNext()
|
|
||||||
assertThat(c.getInt(2)).isEqualTo(1)
|
|
||||||
// The body changes, so its modification stamp does too.
|
|
||||||
assertThat(c.isNull(3)).isFalse()
|
|
||||||
c.moveToNext()
|
|
||||||
assertThat(c.getInt(2)).isEqualTo(0)
|
|
||||||
c.moveToNext()
|
|
||||||
assertThat(c.getInt(2)).isEqualTo(0)
|
|
||||||
c.moveToNext()
|
|
||||||
assertThat(c.getString(1)).contains("X-APPLE-DEFAULT-ALARM:TRUE")
|
|
||||||
assertThat(c.getInt(2)).isEqualTo(0)
|
|
||||||
c.moveToNext()
|
|
||||||
assertThat(c.getInt(2)).isEqualTo(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TEST_DB = "migration-test.db"
|
const val TEST_DB = "migration-test.db"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-21
@@ -107,17 +107,6 @@ class ExternalImportTest {
|
|||||||
assertThat(importer.hasRun.first()).isTrue()
|
assertThat(importer.hasRun.first()).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun copiesTagsCleanedUp() = runBlocking {
|
|
||||||
source.lists = listOf(list(7, "Errands"))
|
|
||||||
source.tasks = mapOf(7L to listOf(task(100, "Milk", tags = listOf("Shop", " Shop ", "", "Home"))))
|
|
||||||
|
|
||||||
importer.run()
|
|
||||||
|
|
||||||
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).single().task.categories)
|
|
||||||
.containsExactly("Shop", "Home").inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every list arrives device-only: the account belongs to the sync app. */
|
/** Every list arrives device-only: the account belongs to the sync app. */
|
||||||
@Test
|
@Test
|
||||||
fun importedListsAreDeviceOnly() = runBlocking {
|
fun importedListsAreDeviceOnly() = runBlocking {
|
||||||
@@ -252,7 +241,6 @@ class ExternalImportTest {
|
|||||||
title: String,
|
title: String,
|
||||||
uid: String? = "uid-$id",
|
uid: String? = "uid-$id",
|
||||||
parentId: Long? = null,
|
parentId: Long? = null,
|
||||||
tags: List<String> = emptyList(),
|
|
||||||
) = ExportTask(
|
) = ExportTask(
|
||||||
taskId = id,
|
taskId = id,
|
||||||
uid = uid,
|
uid = uid,
|
||||||
@@ -272,7 +260,6 @@ class ExternalImportTest {
|
|||||||
rrule = null,
|
rrule = null,
|
||||||
rdate = null,
|
rdate = null,
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
tags = tags,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
@@ -299,10 +286,9 @@ private class FakeExternalStore : TasksDataSource {
|
|||||||
override fun tasks(query: TaskQuery): List<Task> = unused()
|
override fun tasks(query: TaskQuery): List<Task> = unused()
|
||||||
override fun task(taskId: Long): Task? = unused()
|
override fun task(taskId: Long): Task? = unused()
|
||||||
override fun subtasks(parentTaskId: Long): List<Task> = unused()
|
override fun subtasks(parentTaskId: Long): List<Task> = unused()
|
||||||
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = unused()
|
override fun insertTask(form: TaskForm): Long = unused()
|
||||||
override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) = unused()
|
override fun updateTask(taskId: Long, form: TaskForm) = unused()
|
||||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
|
||||||
unused()
|
|
||||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
|
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
|
||||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused()
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused()
|
||||||
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
|
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
|
||||||
@@ -310,10 +296,8 @@ private class FakeExternalStore : TasksDataSource {
|
|||||||
override fun deleteTask(taskId: Long) = unused()
|
override fun deleteTask(taskId: Long) = unused()
|
||||||
override fun setCancelled(taskId: Long, cancelled: Boolean) = unused()
|
override fun setCancelled(taskId: Long, cancelled: Boolean) = unused()
|
||||||
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused()
|
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused()
|
||||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
|
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
|
||||||
unused()
|
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = unused()
|
||||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long =
|
|
||||||
unused()
|
|
||||||
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
|
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
|
||||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()
|
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()
|
||||||
override fun createLocalList(name: String, color: Int): Long = unused()
|
override fun createLocalList(name: String, color: Int): Long = unused()
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.sync
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.AlarmCodec
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
|
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.ResourceValidator
|
import de.jeanlucmakiola.agendula.data.tasks.ical.ResourceValidator
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
|
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||||
@@ -424,9 +422,8 @@ class CollectionSyncer(
|
|||||||
|
|
||||||
/** Null when the resource was rejected before it left the device. */
|
/** Null when the resource was rejected before it left the device. */
|
||||||
private fun serialize(local: LocalResource): String? {
|
private fun serialize(local: LocalResource): String? {
|
||||||
val alarms = store.alarmsFor(local.live.map { it.id })
|
|
||||||
val todos = local.live.map { row ->
|
val todos = local.live.map { row ->
|
||||||
VTodoMapper.write(row, parentUidOf(row), now(), alarms[row.id].orEmpty())
|
VTodoMapper.write(row, parentUidOf(row), now())
|
||||||
}
|
}
|
||||||
val calendar = CalendarResource.build(todos)
|
val calendar = CalendarResource.build(todos)
|
||||||
|
|
||||||
@@ -781,17 +778,14 @@ class CollectionSyncer(
|
|||||||
val losing = existing.values.firstOrNull { it.isDirty }
|
val losing = existing.values.firstOrNull { it.isDirty }
|
||||||
|
|
||||||
val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first()
|
val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first()
|
||||||
val masterRow = upsert(master, existing[master.entity.recurrenceId], null, href, eTag)
|
val masterRow = upsert(master.entity, existing[master.entity.recurrenceId], null, href, eTag)
|
||||||
val masterId = masterRow.id
|
val masterId = masterRow.id
|
||||||
parents[uid] = master.parentUid
|
parents[uid] = master.parentUid
|
||||||
|
|
||||||
val written = mutableListOf(masterRow)
|
val written = mutableListOf(masterRow)
|
||||||
mapped.filter { it !== master }.forEach { override ->
|
mapped.filter { it !== master }.forEach { override ->
|
||||||
// ⚠️ An override's alarms are its own: RFC 5545 has an override
|
|
||||||
// replace the component, so one the server sends without a
|
|
||||||
// VALARM has none, whatever its master carries.
|
|
||||||
written += upsert(
|
written += upsert(
|
||||||
override, existing[override.entity.recurrenceId], masterId, href, eTag,
|
override.entity, existing[override.entity.recurrenceId], masterId, href, eTag,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val kept = written.map { it.recurrenceId }.toSet()
|
val kept = written.map { it.recurrenceId }.toSet()
|
||||||
@@ -838,13 +832,12 @@ class CollectionSyncer(
|
|||||||
|
|
||||||
/** @return the row as it now stands, so the caller can index it. */
|
/** @return the row as it now stands, so the caller can index it. */
|
||||||
private fun upsert(
|
private fun upsert(
|
||||||
mapped: VTodoMapper.Mapped,
|
incoming: TaskEntity,
|
||||||
existing: TaskEntity?,
|
existing: TaskEntity?,
|
||||||
masterId: Long?,
|
masterId: Long?,
|
||||||
href: String,
|
href: String,
|
||||||
eTag: String?,
|
eTag: String?,
|
||||||
): TaskEntity {
|
): TaskEntity {
|
||||||
val incoming = mapped.entity
|
|
||||||
// Local-only columns the server has no opinion about. Taking the
|
// Local-only columns the server has no opinion about. Taking the
|
||||||
// mapper's defaults here would silently reset the user's ordering and
|
// mapper's defaults here would silently reset the user's ordering and
|
||||||
// per-task colour on every download.
|
// per-task colour on every download.
|
||||||
@@ -861,42 +854,12 @@ class CollectionSyncer(
|
|||||||
// set uploads what was just downloaded.
|
// set uploads what was just downloaded.
|
||||||
isDirty = false,
|
isDirty = false,
|
||||||
isDeleted = false,
|
isDeleted = false,
|
||||||
// The store's to change, from what the alarms do.
|
|
||||||
remindersOff = existing?.remindersOff ?: false,
|
|
||||||
)
|
)
|
||||||
// The server's alarms replace ours, as every other field does: a
|
return if (existing == null) {
|
||||||
// dirty row is never downloaded except to discard its edit.
|
row.copy(id = store.insert(row))
|
||||||
val alarms = existing?.let { keepLocalPrecision(mapped.alarms, it, row) } ?: mapped.alarms
|
} else {
|
||||||
return row.copy(id = store.upsertWithAlarms(row, alarms))
|
store.update(row)
|
||||||
}
|
row
|
||||||
|
|
||||||
/**
|
|
||||||
* [incoming] with each alarm we uploaded ourselves put back to the lead
|
|
||||||
* we hold for it.
|
|
||||||
*
|
|
||||||
* ⚠️ The wire is coarser than the row: an all-day lead goes out in whole
|
|
||||||
* days, and a before-start reminder on a task without a start goes out
|
|
||||||
* before-due. Taking the round trip at its word would quietly turn a
|
|
||||||
* 15-minute all-day reminder into "on the day", or a before-start one
|
|
||||||
* into a before-due one, the first time the task is downloaded again.
|
|
||||||
* An alarm that comes back as exactly what we would have sent has not
|
|
||||||
* been changed, so the row's own lead stands.
|
|
||||||
*/
|
|
||||||
private fun keepLocalPrecision(
|
|
||||||
incoming: List<TaskAlarmEntity>,
|
|
||||||
existing: TaskEntity,
|
|
||||||
task: TaskEntity,
|
|
||||||
): List<TaskAlarmEntity> {
|
|
||||||
val local = store.alarmsFor(listOf(existing.id))[existing.id].orEmpty()
|
|
||||||
if (local.isEmpty()) return incoming
|
|
||||||
return incoming.map { alarm ->
|
|
||||||
val mine = local.firstOrNull { it.uid == alarm.uid } ?: return@map alarm
|
|
||||||
val sent = AlarmCodec.wireLead(mine, task)
|
|
||||||
if (sent == AlarmCodec.Lead(alarm.minutesBefore, alarm.reference)) {
|
|
||||||
alarm.copy(minutesBefore = mine.minutesBefore, reference = mine.reference)
|
|
||||||
} else {
|
|
||||||
alarm
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.sync
|
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.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -12,33 +11,17 @@ import javax.inject.Inject
|
|||||||
* discovery: the reconciliation above this interface is where local edits are
|
* discovery: the reconciliation above this interface is where local edits are
|
||||||
* discarded, tombstones swept and conflicts resolved, and every one of those is
|
* discarded, tombstones swept and conflicts resolved, and every one of those is
|
||||||
* a decision that should be provable without a device. Room's test double is
|
* a decision that should be provable without a device. Room's test double is
|
||||||
* Robolectric plus an in-memory database; this is a dozen methods.
|
* Robolectric plus an in-memory database; this is eight methods.
|
||||||
*/
|
*/
|
||||||
interface SyncStore {
|
interface SyncStore {
|
||||||
|
|
||||||
/** Every row in a list, **tombstones included**. */
|
/** Every row in a list, **tombstones included**. */
|
||||||
fun rowsIn(listId: Long): List<TaskEntity>
|
fun rowsIn(listId: Long): List<TaskEntity>
|
||||||
|
|
||||||
|
fun insert(row: TaskEntity): Long
|
||||||
|
|
||||||
fun update(row: TaskEntity)
|
fun update(row: TaskEntity)
|
||||||
|
|
||||||
/** The reminders of [taskIds] — one resource's rows — by task id, for the body that uploads them. */
|
|
||||||
fun alarmsFor(taskIds: List<Long>): Map<Long, List<TaskAlarmEntity>>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes a downloaded row — inserted when its id is 0 — and makes its
|
|
||||||
* reminders exactly [alarms], in one transaction.
|
|
||||||
*
|
|
||||||
* ⚠️ One transaction, not an [update] and a second call. The row lands clean
|
|
||||||
* with the server's ETag, so if the process died between the two, nothing
|
|
||||||
* would ever download it again and the reminders would stay stale for good.
|
|
||||||
*
|
|
||||||
* A row that is gone by the time it is written — deleted on the device
|
|
||||||
* while the sync ran — stays gone, reminders and all.
|
|
||||||
*
|
|
||||||
* @return the row's id.
|
|
||||||
*/
|
|
||||||
fun upsertWithAlarms(row: TaskEntity, alarms: List<TaskAlarmEntity>): Long
|
|
||||||
|
|
||||||
fun deleteAll(taskIds: List<Long>)
|
fun deleteAll(taskIds: List<Long>)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,26 +56,12 @@ class RoomSyncStore @Inject constructor(
|
|||||||
|
|
||||||
override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
|
override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
|
||||||
|
|
||||||
|
override fun insert(row: TaskEntity) = database.tasks().insert(row)
|
||||||
|
|
||||||
override fun update(row: TaskEntity) {
|
override fun update(row: TaskEntity) {
|
||||||
database.tasks().update(row)
|
database.tasks().update(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun alarmsFor(taskIds: List<Long>) =
|
|
||||||
if (taskIds.isEmpty()) emptyMap() else database.alarms().forTasks(taskIds).groupBy { it.taskId }
|
|
||||||
|
|
||||||
override fun upsertWithAlarms(row: TaskEntity, alarms: List<TaskAlarmEntity>): Long =
|
|
||||||
database.runInTransaction<Long> {
|
|
||||||
if (row.id == 0L) {
|
|
||||||
database.tasks().insert(row).also { database.alarms().replaceFromServer(it, alarms) }
|
|
||||||
} else {
|
|
||||||
// ⚠️ Zero rows means the row was deleted under us. Its alarms
|
|
||||||
// would then name a task that does not exist, and the foreign
|
|
||||||
// key would fail the whole transaction.
|
|
||||||
if (database.tasks().update(row) > 0) database.alarms().replaceFromServer(row.id, alarms)
|
|
||||||
row.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deleteAll(taskIds: List<Long>) {
|
override fun deleteAll(taskIds: List<Long>) {
|
||||||
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
|
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,8 +149,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
|
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
|
||||||
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
|
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
|
||||||
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
|
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
|
||||||
private const val ACTION_OPEN_FILTER = "de.jeanlucmakiola.agendula.action.OPEN_FILTER"
|
|
||||||
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
|
|
||||||
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
||||||
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
||||||
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
||||||
@@ -200,12 +198,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
.putExtra(EXTRA_LIST_ID, listId)
|
.putExtra(EXTRA_LIST_ID, listId)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
/** Opens one saved filter. */
|
|
||||||
fun filterIntent(context: Context, filterId: Long): Intent =
|
|
||||||
Intent(ACTION_OPEN_FILTER, "agendula://filter/$filterId".toUri(), context, MainActivity::class.java)
|
|
||||||
.putExtra(EXTRA_FILTER_ID, filterId)
|
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
|
|
||||||
internal fun navRequestOf(intent: Intent?): NavRequest? {
|
internal fun navRequestOf(intent: Intent?): NavRequest? {
|
||||||
if (intent == null) return null
|
if (intent == null) return null
|
||||||
if (BuildConfig.SYNC_ENABLED) {
|
if (BuildConfig.SYNC_ENABLED) {
|
||||||
@@ -223,8 +215,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
?.let { return NavRequest.OpenSmart(it) }
|
?.let { return NavRequest.OpenSmart(it) }
|
||||||
ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
|
ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
|
||||||
?.let { return NavRequest.OpenList(it) }
|
?.let { return NavRequest.OpenList(it) }
|
||||||
ACTION_OPEN_FILTER -> intent.getLongExtra(EXTRA_FILTER_ID, -1L).takeIf { it > 0L }
|
|
||||||
?.let { return NavRequest.OpenFilter(it) }
|
|
||||||
}
|
}
|
||||||
importIntentUri(intent)?.let { return NavRequest.Import(it) }
|
importIntentUri(intent)?.let { return NavRequest.Import(it) }
|
||||||
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
|
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ object DataProvideModule {
|
|||||||
// backed-up `.db` does not. The backup rules carry all three files and
|
// backed-up `.db` does not. The backup rules carry all three files and
|
||||||
// the app checkpoints on ON_STOP.
|
// the app checkpoints on ON_STOP.
|
||||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||||
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3)
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.prefs
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.domain.DueWindow
|
|
||||||
import de.jeanlucmakiola.agendula.domain.Priority
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
|
||||||
import de.jeanlucmakiola.agendula.domain.TagMatch
|
|
||||||
import java.net.URLDecoder
|
|
||||||
import java.net.URLEncoder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stored form of the saved filters: one line per filter,
|
|
||||||
* `id|name|due|lists|tags|match|priority`, with name and tags URL-encoded and
|
|
||||||
* list ids qualified by the store they belong to (`OWN:3`), since each store
|
|
||||||
* numbers its lists on its own. Fixed once released — don't change without a
|
|
||||||
* data migration.
|
|
||||||
*/
|
|
||||||
internal object SavedFilterCodec {
|
|
||||||
|
|
||||||
/** The filters in [raw], with only the list ids of [mode]'s store. */
|
|
||||||
fun decode(raw: String?, mode: String): List<SavedFilter> =
|
|
||||||
lines(raw).mapNotNull { runCatching { decodeLine(it, mode) }.getOrNull() }
|
|
||||||
|
|
||||||
/** [raw] with [filter] replacing the line of the same id, or appended. */
|
|
||||||
fun upsert(raw: String?, filter: SavedFilter, mode: String): String {
|
|
||||||
val existing = lines(raw)
|
|
||||||
val index = existing.indexOfFirst { idOf(it) == filter.id }
|
|
||||||
// Keep the lists another store contributed, so switching stores and back
|
|
||||||
// doesn't widen a filter that was edited in between, unless "any list"
|
|
||||||
// was chosen here, which drops them on purpose.
|
|
||||||
val foreign = if (filter.anyList) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
existing.getOrNull(index)?.let(::fields)?.getOrNull(3)
|
|
||||||
?.let(::splitList)?.filterNot { it.startsWith("$mode:") }.orEmpty()
|
|
||||||
}
|
|
||||||
val line = encodeLine(filter, mode, foreign)
|
|
||||||
val updated = if (index >= 0) existing.toMutableList().also { it[index] = line } else existing + line
|
|
||||||
return updated.joinToString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun remove(raw: String?, id: Long): String = lines(raw).filterNot { idOf(it) == id }.joinToString("\n")
|
|
||||||
|
|
||||||
private fun lines(raw: String?): List<String> = raw.orEmpty().split('\n').filter { it.isNotBlank() }
|
|
||||||
|
|
||||||
private fun fields(line: String): List<String> = line.split('|')
|
|
||||||
|
|
||||||
private fun idOf(line: String): Long? = fields(line).firstOrNull()?.toLongOrNull()
|
|
||||||
|
|
||||||
private fun splitList(value: String): List<String> = value.split(',').filter { it.isNotEmpty() }
|
|
||||||
|
|
||||||
private fun encodeLine(filter: SavedFilter, mode: String, foreignLists: List<String>): String =
|
|
||||||
listOf(
|
|
||||||
filter.id.toString(),
|
|
||||||
encode(filter.name),
|
|
||||||
filter.due.sorted().joinToString(",") { it.name },
|
|
||||||
(filter.listIds.sorted().map { "$mode:$it" } + foreignLists).joinToString(","),
|
|
||||||
filter.tags.sorted().joinToString(",") { encode(it) },
|
|
||||||
filter.tagMatch.name,
|
|
||||||
filter.minPriority.name,
|
|
||||||
).joinToString("|")
|
|
||||||
|
|
||||||
private fun decodeLine(line: String, mode: String): SavedFilter? {
|
|
||||||
val f = fields(line)
|
|
||||||
if (f.size < 7) return null
|
|
||||||
val id = f[0].toLongOrNull() ?: return null
|
|
||||||
val lists = splitList(f[3])
|
|
||||||
val here = lists.filter { it.startsWith("$mode:") }.mapNotNull { it.removePrefix("$mode:").toLongOrNull() }.toSet()
|
|
||||||
return SavedFilter(
|
|
||||||
id = id,
|
|
||||||
name = decode(f[1]),
|
|
||||||
due = splitList(f[2]).mapNotNull { runCatching { DueWindow.valueOf(it) }.getOrNull() }.toSet(),
|
|
||||||
listIds = here,
|
|
||||||
listsElsewhere = lists.any { !it.startsWith("$mode:") },
|
|
||||||
tags = splitList(f[4]).map(::decode).toSet(),
|
|
||||||
tagMatch = runCatching { TagMatch.valueOf(f[5]) }.getOrDefault(TagMatch.ANY),
|
|
||||||
minPriority = runCatching { Priority.valueOf(f[6]) }.getOrDefault(Priority.NONE),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
|
|
||||||
|
|
||||||
private fun decode(value: String): String = URLDecoder.decode(value, Charsets.UTF_8.name())
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
@@ -21,7 +20,6 @@ import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
|||||||
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -115,10 +113,6 @@ data class Settings(
|
|||||||
/** The lead times for an all-day task in [listId]. */
|
/** The lead times for an all-day task in [listId]. */
|
||||||
fun allDayReminderLeadsFor(listId: Long): List<Int> =
|
fun allDayReminderLeadsFor(listId: Long): List<Int> =
|
||||||
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes)
|
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes)
|
||||||
|
|
||||||
/** The default leads a task in [listId] takes — the one rule for which default applies. */
|
|
||||||
fun defaultLeadsFor(listId: Long, allDay: Boolean): List<Int> =
|
|
||||||
if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||||
@@ -265,34 +259,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry
|
p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The saved filters, oldest first, with the list ids of the current store. */
|
|
||||||
val savedFilters: Flow<List<SavedFilter>> =
|
|
||||||
dataStore.data.map { p -> p[SAVED_FILTERS] to p[STORAGE_MODE] }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.map { (raw, mode) -> SavedFilterCodec.decode(raw, (storedMode(mode) ?: resolver.autoMode()).name) }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
|
|
||||||
/** One saved filter; `null` while it doesn't exist. */
|
|
||||||
fun savedFilter(id: Long): Flow<SavedFilter?> =
|
|
||||||
savedFilters.map { all -> all.firstOrNull { it.id == id } }.distinctUntilChanged()
|
|
||||||
|
|
||||||
/** Store [filter]; an id of 0 makes a new one. Returns the filter's id. */
|
|
||||||
suspend fun saveFilter(filter: SavedFilter): Long {
|
|
||||||
var id = filter.id
|
|
||||||
dataStore.edit { p ->
|
|
||||||
if (id <= 0) {
|
|
||||||
id = p[SAVED_FILTER_NEXT_ID] ?: 1L
|
|
||||||
p[SAVED_FILTER_NEXT_ID] = id + 1
|
|
||||||
}
|
|
||||||
p[SAVED_FILTERS] = SavedFilterCodec.upsert(p[SAVED_FILTERS], filter.copy(id = id), modeOf(p).name)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun deleteFilter(id: Long) = dataStore.edit { p ->
|
|
||||||
p[SAVED_FILTERS] = SavedFilterCodec.remove(p[SAVED_FILTERS], id)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
|
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
|
||||||
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
|
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
|
||||||
}
|
}
|
||||||
@@ -322,8 +288,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
||||||
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
||||||
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
||||||
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
|
|
||||||
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,41 +42,25 @@ internal object ReminderPlanner {
|
|||||||
// the one the editor shows; otherwise the task's list may override the
|
// the one the editor shows; otherwise the task's list may override the
|
||||||
// global lead, or opt out entirely (override = null). All-day tasks
|
// global lead, or opt out entirely (override = null). All-day tasks
|
||||||
// have their own day-scale defaults.
|
// have their own day-scale defaults.
|
||||||
//
|
val reminders = perTask[task.taskId].orEmpty().ifEmpty {
|
||||||
// ⚠️ Present-but-empty is "none", not "the default": the store's
|
val leads = if (task.isAllDay) settings.allDayReminderLeadsFor(task.listId)
|
||||||
// answer for a task whose alarms another client turned off. A
|
else settings.reminderLeadsFor(task.listId)
|
||||||
// default here would keep ringing for what the user switched off.
|
leads.map { TaskReminder(it) }
|
||||||
val reminders = perTask[task.taskId]
|
}
|
||||||
?: settings.defaultLeadsFor(task.listId, task.isAllDay).map { TaskReminder(it) }
|
reminders.map { reminder ->
|
||||||
reminders.mapNotNull { reminder ->
|
|
||||||
// A stored reminder says what it counts back from. Ours are always
|
// A stored reminder says what it counts back from. Ours are always
|
||||||
// before due, but an imported dmfs alarm or another client's can be
|
// before due, but an imported dmfs alarm or another client's can be
|
||||||
// before *start*.
|
// before *start*.
|
||||||
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!!
|
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!!
|
||||||
val trigger = triggerAt(
|
|
||||||
anchor,
|
|
||||||
task.isAllDay,
|
|
||||||
reminder.minutesBefore,
|
|
||||||
settings.allDayReminderMinuteOfDay,
|
|
||||||
zone,
|
|
||||||
)
|
|
||||||
// Dismissed on another client already: firing it here within
|
|
||||||
// the missed-reminder grace would ring for something done with.
|
|
||||||
//
|
|
||||||
// ⚠️ An all-day reminder is compared from the start of its day,
|
|
||||||
// not from this device's time of day: other clients ring it at
|
|
||||||
// midnight, or at their own hour, so a dismissal at 07:00 must
|
|
||||||
// still silence the 09:00 this device would ring it at.
|
|
||||||
val earliest = if (task.isAllDay) {
|
|
||||||
triggerAt(anchor, true, reminder.minutesBefore, 0, zone)
|
|
||||||
} else {
|
|
||||||
trigger
|
|
||||||
}
|
|
||||||
val acknowledged = reminder.acknowledgedAt?.let { earliest <= it.toEpochMilliseconds() } == true
|
|
||||||
if (acknowledged) return@mapNotNull null
|
|
||||||
ScheduledReminder(
|
ScheduledReminder(
|
||||||
taskId = task.taskId,
|
taskId = task.taskId,
|
||||||
triggerAt = trigger,
|
triggerAt = triggerAt(
|
||||||
|
anchor,
|
||||||
|
task.isAllDay,
|
||||||
|
reminder.minutesBefore,
|
||||||
|
settings.allDayReminderMinuteOfDay,
|
||||||
|
zone,
|
||||||
|
),
|
||||||
occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
|
occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-149
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import android.content.ContentProviderOperation
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
@@ -20,7 +19,6 @@ import de.jeanlucmakiola.agendula.domain.Task
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||||
@@ -92,14 +90,11 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
// `is_recurring` / `distance_from_current`). The by-name mapper reads
|
// `is_recurring` / `distance_from_current`). The by-name mapper reads
|
||||||
// whatever is present and nulls the rest; the repository re-sorts, so we
|
// whatever is present and nulls the rest; the repository re-sorts, so we
|
||||||
// don't depend on a provider sort column either.
|
// don't depend on a provider sort column either.
|
||||||
val tasks = resolver.query(uri, null, selection, args, null)
|
return resolver.query(uri, null, selection, args, null)
|
||||||
?.use { c ->
|
?.use { c ->
|
||||||
val reader = CursorColumnReader(c)
|
val reader = CursorColumnReader(c)
|
||||||
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
|
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
if (tasks.isEmpty()) return tasks
|
|
||||||
val tags = categories(tasks.map { it.taskId })
|
|
||||||
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun exportTasks(listId: Long): List<ExportTask> {
|
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||||
@@ -107,7 +102,7 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
// table's shape varies across provider versions, and the by-name mapper
|
// table's shape varies across provider versions, and the by-name mapper
|
||||||
// reads what's there.
|
// reads what's there.
|
||||||
val uri = TasksContract.tasksUri(authority())
|
val uri = TasksContract.tasksUri(authority())
|
||||||
val tasks = resolver.query(
|
return resolver.query(
|
||||||
uri,
|
uri,
|
||||||
null,
|
null,
|
||||||
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
|
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
|
||||||
@@ -120,60 +115,24 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
val reader = CursorColumnReader(c)
|
val reader = CursorColumnReader(c)
|
||||||
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
|
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
if (tasks.isEmpty()) return tasks
|
|
||||||
val tags = categories(tasks.map { it.taskId })
|
|
||||||
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- writes ---------------------------------------------------------------
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long {
|
override fun insertTask(form: TaskForm): Long {
|
||||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||||
// One batch, so a refused tag or alarm leaves no task behind for a retry
|
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
|
||||||
// to duplicate.
|
?: throw TaskWriteFailedException("insert task")
|
||||||
val authority = authority()
|
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
|
||||||
val ops = arrayListOf(
|
|
||||||
ContentProviderOperation.newInsert(TasksContract.tasksUri(authority))
|
|
||||||
.withValues(values.toContentValues())
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
normalizeTags(form.tags).forEach { tag ->
|
|
||||||
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
|
|
||||||
.withValues(TaskWriteMapper.categoryValues(0, tag).toContentValues().apply { remove(Properties.TASK_ID) })
|
|
||||||
.withValueBackReference(Properties.TASK_ID, 0)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
reminders.orEmpty().forEach { reminder ->
|
|
||||||
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
|
|
||||||
.withValues(
|
|
||||||
TaskWriteMapper.alarmValues(0, reminder.minutesBefore, reminder.fromStart)
|
|
||||||
.toContentValues().apply { remove(Properties.TASK_ID) },
|
|
||||||
)
|
|
||||||
.withValueBackReference(Properties.TASK_ID, 0)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
val results = runCatching { resolver.applyBatch(authority, ops) }
|
|
||||||
.getOrElse { throw TaskWriteFailedException("insert task", it) }
|
|
||||||
return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull()
|
|
||||||
?: throw TaskWriteFailedException("insert task: no id")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) {
|
override fun updateTask(taskId: Long, form: TaskForm) {
|
||||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||||
setCategories(taskId, form.tags)
|
|
||||||
writeDueReminders(taskId, dueReminders)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [leads] as the task's before-due reminders; a start-relative one from elsewhere is kept. */
|
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||||
private fun writeDueReminders(taskId: Long, leads: List<Int>?) {
|
|
||||||
if (leads == null) return
|
|
||||||
val kept = alarmRows(taskId).map { it.second }.filter { it.fromStart }
|
|
||||||
setReminders(taskId, kept + leads.distinct().sorted().map { TaskReminder(it) })
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) {
|
|
||||||
val occurrence = task(seriesId, occurrenceStart)
|
val occurrence = task(seriesId, occurrenceStart)
|
||||||
val anchors = resolver.query(
|
val anchors = resolver.query(
|
||||||
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
|
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
|
||||||
@@ -192,8 +151,6 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
|
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
|
||||||
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
|
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
|
||||||
setCategories(seriesId, form.tags)
|
|
||||||
writeDueReminders(seriesId, dueReminders)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,10 +159,10 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
* task. Per-occurrence edits past the split point are dropped rather than
|
* task. Per-occurrence edits past the split point are dropped rather than
|
||||||
* carried over — the provider offers no way to re-parent an exception.
|
* carried over — the provider offers no way to re-parent an exception.
|
||||||
*/
|
*/
|
||||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long {
|
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long {
|
||||||
val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId")
|
val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId")
|
||||||
if (spec.rrule == null || occurrenceStart <= spec.anchor) {
|
if (spec.rrule == null || occurrenceStart <= spec.anchor) {
|
||||||
updateSeries(seriesId, occurrenceStart, form, dueReminders)
|
updateSeries(seriesId, occurrenceStart, form)
|
||||||
return seriesId
|
return seriesId
|
||||||
}
|
}
|
||||||
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
|
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
|
||||||
@@ -215,8 +172,7 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
).size
|
).size
|
||||||
endSeriesBefore(seriesId, spec, occurrenceStart)
|
endSeriesBefore(seriesId, spec, occurrenceStart)
|
||||||
val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it }
|
val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it }
|
||||||
val reminders = dueReminders?.distinct()?.sorted()?.map { TaskReminder(it) }
|
return insertTask(form.copy(rrule = rule, percentComplete = null))
|
||||||
return insertTask(form.copy(rrule = rule, percentComplete = null), reminders)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
|
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
|
||||||
@@ -282,49 +238,13 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
|
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) {
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||||
if (dueReminders == null) return writeInstance(taskId, occurrenceStart, 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
|
|
||||||
// leaving it there would silently give every other occurrence in the
|
|
||||||
// series the reminder the user set on one of them.
|
|
||||||
val seriesReminders = alarmRows(taskId).map { it.second }
|
|
||||||
writeDueReminders(taskId, dueReminders)
|
|
||||||
try {
|
|
||||||
writeInstance(taskId, occurrenceStart, form)
|
|
||||||
} finally {
|
|
||||||
setReminders(taskId, seriesReminders)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun writeInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
|
||||||
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
||||||
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
||||||
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||||
// The fork copies the series' properties onto the override, so the tags
|
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||||
// go onto the series first and are put back once the override exists.
|
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
||||||
// Unchanged tags are inherited as they are, and the series is left alone.
|
|
||||||
val seriesTags = categories(listOf(taskId))[taskId].orEmpty()
|
|
||||||
val retag = normalizeTags(form.tags) != seriesTags
|
|
||||||
if (retag) setCategories(taskId, form.tags)
|
|
||||||
var failure: Throwable? = null
|
|
||||||
try {
|
|
||||||
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
|
||||||
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
failure = e
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
if (retag) {
|
|
||||||
try {
|
|
||||||
setCategories(taskId, seriesTags)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
failure?.addSuppressed(e) ?: throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -428,58 +348,6 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tags by task id for [taskIds], in stored order. */
|
|
||||||
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
|
|
||||||
val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk ->
|
|
||||||
val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})"
|
|
||||||
val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray()
|
|
||||||
resolver.query(
|
|
||||||
TasksContract.propertiesUri(authority()),
|
|
||||||
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
|
|
||||||
selection,
|
|
||||||
args,
|
|
||||||
Properties.PROPERTY_ID,
|
|
||||||
)?.use { c ->
|
|
||||||
val reader = CursorColumnReader(c)
|
|
||||||
buildList {
|
|
||||||
while (c.moveToNext()) {
|
|
||||||
val id = reader.getLong(Properties.TASK_ID) ?: continue
|
|
||||||
val name = reader.getString(TasksContract.Category.NAME) ?: continue
|
|
||||||
add(id to name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
|
|
||||||
* leave half the tags, and leaves them alone when nothing changed so the task
|
|
||||||
* isn't dirtied.
|
|
||||||
*/
|
|
||||||
private fun setCategories(taskId: Long, tags: List<String>) {
|
|
||||||
val wanted = normalizeTags(tags)
|
|
||||||
if (categories(listOf(taskId))[taskId].orEmpty() == wanted) return
|
|
||||||
val authority = authority()
|
|
||||||
val uri = TasksContract.propertiesUri(authority)
|
|
||||||
val ops = arrayListOf(
|
|
||||||
ContentProviderOperation.newDelete(uri)
|
|
||||||
.withSelection(
|
|
||||||
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
|
|
||||||
arrayOf(taskId.toString(), TasksContract.Category.MIMETYPE),
|
|
||||||
)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
wanted.forEach {
|
|
||||||
ops += ContentProviderOperation.newInsert(uri)
|
|
||||||
.withValues(TaskWriteMapper.categoryValues(taskId, it).toContentValues())
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
runCatching { resolver.applyBatch(authority, ops) }
|
|
||||||
.getOrElse { throw TaskWriteFailedException("set categories for task $taskId", it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
||||||
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
|
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
|
||||||
@@ -592,6 +460,3 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
return cv
|
return cv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** SQLite's default bound-argument limit on older Android releases. */
|
|
||||||
private const val MAX_SQL_ARGS = 999
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ class ProviderUnavailableException :
|
|||||||
IllegalStateException("No tasks provider installed")
|
IllegalStateException("No tasks provider installed")
|
||||||
|
|
||||||
/** A ContentResolver write returned no URI or affected no rows. */
|
/** A ContentResolver write returned no URI or affected no rows. */
|
||||||
class TaskWriteFailedException(operation: String, cause: Throwable? = null) :
|
class TaskWriteFailedException(operation: String) :
|
||||||
RuntimeException("Task write failed: $operation", cause)
|
RuntimeException("Task write failed: $operation")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
|
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
|
||||||
|
|||||||
+8
-9
@@ -34,21 +34,20 @@ class ModeRoutingTasksDataSource(
|
|||||||
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
|
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
|
||||||
override fun task(taskId: Long): Task? = active().task(taskId)
|
override fun task(taskId: Long): Task? = active().task(taskId)
|
||||||
override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart)
|
override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart)
|
||||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
|
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) =
|
||||||
active().updateSeries(seriesId, occurrenceStart, form, dueReminders)
|
active().updateSeries(seriesId, occurrenceStart, form)
|
||||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long =
|
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long =
|
||||||
active().splitSeries(seriesId, occurrenceStart, form, dueReminders)
|
active().splitSeries(seriesId, occurrenceStart, form)
|
||||||
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) =
|
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) =
|
||||||
active().deleteOccurrence(seriesId, occurrenceStart)
|
active().deleteOccurrence(seriesId, occurrenceStart)
|
||||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
|
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
|
||||||
active().deleteFollowing(seriesId, occurrenceStart)
|
active().deleteFollowing(seriesId, occurrenceStart)
|
||||||
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
|
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
|
||||||
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = active().insertTask(form, reminders)
|
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
|
||||||
override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) =
|
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
|
||||||
active().updateTask(taskId, form, dueReminders)
|
|
||||||
|
|
||||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) =
|
||||||
active().updateInstance(taskId, occurrenceStart, form, dueReminders)
|
active().updateInstance(taskId, occurrenceStart, form)
|
||||||
|
|
||||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
|
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
|
||||||
override fun alarms(): Map<Long, TaskReminder> = active().alarms()
|
override fun alarms(): Map<Long, TaskReminder> = active().alarms()
|
||||||
|
|||||||
@@ -113,12 +113,6 @@ object TaskWriteMapper {
|
|||||||
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
|
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun categoryValues(taskId: Long, name: String): Map<String, Any?> = mapOf(
|
|
||||||
Properties.TASK_ID to taskId,
|
|
||||||
Properties.MIMETYPE to TasksContract.Category.MIMETYPE,
|
|
||||||
TasksContract.Category.NAME to name,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The user-owned columns of a list — what an edit is allowed to change. */
|
/** The user-owned columns of a list — what an edit is allowed to change. */
|
||||||
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
||||||
Lists.NAME to name.trim(),
|
Lists.NAME to name.trim(),
|
||||||
|
|||||||
@@ -154,18 +154,6 @@ object TasksContract {
|
|||||||
const val TYPE_MESSAGE = 1
|
const val TYPE_MESSAGE = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A category property row — one tag. DAVx5 maps these to and from
|
|
||||||
* `CATEGORIES`. Inserting with only [NAME] is enough: the provider looks the
|
|
||||||
* category up in the task's account, or creates it, and fills in `data0`.
|
|
||||||
*/
|
|
||||||
object Category {
|
|
||||||
const val MIMETYPE = "vnd.android.cursor.item/category"
|
|
||||||
|
|
||||||
/** `data1` — the tag as spelled. */
|
|
||||||
const val NAME = "data1"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
|
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
|
||||||
|
|
||||||
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
||||||
|
|||||||
@@ -13,19 +13,7 @@ import kotlin.time.Instant
|
|||||||
* them — while Agendula's own UI only ever sets a before-due lead. Collapsing it
|
* them — while Agendula's own UI only ever sets a before-due lead. Collapsing it
|
||||||
* to a number here is what silently fired those reminders off the wrong anchor.
|
* to a number here is what silently fired those reminders off the wrong anchor.
|
||||||
*/
|
*/
|
||||||
data class TaskReminder(
|
data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false)
|
||||||
val minutesBefore: Int,
|
|
||||||
val fromStart: Boolean = false,
|
|
||||||
/**
|
|
||||||
* When another client last dismissed this alarm (RFC 9074 `ACKNOWLEDGED`):
|
|
||||||
* a trigger at or before it has been dealt with and is not fired here.
|
|
||||||
* Never part of what a reminder *is* — see [lead].
|
|
||||||
*/
|
|
||||||
val acknowledgedAt: Instant? = null,
|
|
||||||
) {
|
|
||||||
/** The reminder without its bookkeeping, for comparing two of them. */
|
|
||||||
val lead: TaskReminder get() = if (acknowledgedAt == null) this else copy(acknowledgedAt = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one reminder Agendula's single-reminder editor shows and writes: the last
|
* The one reminder Agendula's single-reminder editor shows and writes: the last
|
||||||
@@ -58,17 +46,8 @@ interface TasksDataSource {
|
|||||||
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
|
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
|
||||||
fun subtasks(parentTaskId: Long): List<Task>
|
fun subtasks(parentTaskId: Long): List<Task>
|
||||||
|
|
||||||
/**
|
fun insertTask(form: TaskForm): Long
|
||||||
* The write paths take the task's reminders in the same write as the task
|
fun updateTask(taskId: Long, form: TaskForm)
|
||||||
* itself, so a sync can never upload one without the other.
|
|
||||||
*
|
|
||||||
* [insertTask] takes the new task's [reminders] whole. The updates take the
|
|
||||||
* before-due leads as `dueReminders`, and `null` leaves the reminders
|
|
||||||
* alone; a start-relative reminder the task already carries is kept — the
|
|
||||||
* editor never shows one, so it never sends one.
|
|
||||||
*/
|
|
||||||
fun insertTask(form: TaskForm, reminders: List<TaskReminder>? = null): Long
|
|
||||||
fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>? = null)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a single occurrence of a recurring task, addressed by the task row and
|
* Update a single occurrence of a recurring task, addressed by the task row and
|
||||||
@@ -81,7 +60,7 @@ interface TasksDataSource {
|
|||||||
* instance row id keeps this seam independent of any one store's row
|
* instance row id keeps this seam independent of any one store's row
|
||||||
* numbering; External mode maps it back to an instance row itself.
|
* numbering; External mode maps it back to an instance row itself.
|
||||||
*/
|
*/
|
||||||
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>? = null)
|
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm)
|
||||||
/**
|
/**
|
||||||
* Set (or clear, with `null`) the task's [editable] reminder lead, stored as an
|
* Set (or clear, with `null`) the task's [editable] reminder lead, stored as an
|
||||||
* Alarm property row. Every other reminder on the task is left untouched. The
|
* Alarm property row. Every other reminder on the task is left untouched. The
|
||||||
@@ -95,13 +74,13 @@ interface TasksDataSource {
|
|||||||
* Edit the whole series [seriesId] through its occurrence at [occurrenceStart]:
|
* Edit the whole series [seriesId] through its occurrence at [occurrenceStart]:
|
||||||
* content from [form], and the series moved by however far that occurrence moved.
|
* content from [form], and the series moved by however far that occurrence moved.
|
||||||
*/
|
*/
|
||||||
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>? = null)
|
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "This and following": end [seriesId] before [occurrenceStart] and continue
|
* "This and following": end [seriesId] before [occurrenceStart] and continue
|
||||||
* from there as a new series built from [form]. Returns the new series' id.
|
* from there as a new series built from [form]. Returns the new series' id.
|
||||||
*/
|
*/
|
||||||
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>? = null): Long
|
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long
|
||||||
|
|
||||||
/** Delete one generated occurrence of [seriesId] (an `EXDATE`). */
|
/** Delete one generated occurrence of [seriesId] (an `EXDATE`). */
|
||||||
fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant)
|
fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant)
|
||||||
@@ -110,14 +89,10 @@ interface TasksDataSource {
|
|||||||
fun deleteFollowing(seriesId: Long, occurrenceStart: Instant)
|
fun deleteFollowing(seriesId: Long, occurrenceStart: Instant)
|
||||||
|
|
||||||
|
|
||||||
/** Make [taskId]'s reminders exactly [reminders]. A synced task is owed a PUT for it. */
|
/** Replace every reminder on [taskId] with [reminders], in order. */
|
||||||
fun setReminders(taskId: Long, reminders: List<TaskReminder>)
|
fun setReminders(taskId: Long, reminders: List<TaskReminder>)
|
||||||
|
|
||||||
/**
|
/** Every task's reminders, by task id, in stored order. One query, for the scheduler. */
|
||||||
* Every task's reminders, by task id, in stored order. One query, for the
|
|
||||||
* scheduler. A task missing from the map takes the default; one present
|
|
||||||
* with an empty list has none (see `TaskEntity.remindersOff`).
|
|
||||||
*/
|
|
||||||
fun reminders(): Map<Long, List<TaskReminder>> = alarms().mapValues { listOf(it.value) }
|
fun reminders(): Map<Long, List<TaskReminder>> = alarms().mapValues { listOf(it.value) }
|
||||||
|
|
||||||
/** Every task's [editable] reminder, by task id. */
|
/** Every task's [editable] reminder, by task id. */
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
@@ -14,9 +13,6 @@ import kotlin.time.Instant
|
|||||||
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
|
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
|
||||||
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
||||||
|
|
||||||
/** Tasks for a filter; [savedFilter] is a saved filter's definition, `null` once it's deleted. */
|
|
||||||
data class FilteredTasks(val tasks: List<Task>, val savedFilter: SavedFilter? = null)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single entry point the UI layer uses. Flows re-emit automatically when the
|
* The single entry point the UI layer uses. Flows re-emit automatically when the
|
||||||
* provider changes (our writes *and* external sync like DAVx5), via the
|
* provider changes (our writes *and* external sync like DAVx5), via the
|
||||||
@@ -31,10 +27,7 @@ interface TasksRepository {
|
|||||||
*/
|
*/
|
||||||
fun writableTaskLists(): Flow<List<TaskList>> =
|
fun writableTaskLists(): Flow<List<TaskList>> =
|
||||||
taskLists().map { lists -> lists.filter { it.acceptsWrites } }
|
taskLists().map { lists -> lists.filter { it.acceptsWrites } }
|
||||||
fun tasks(filter: TaskFilter): Flow<List<Task>> = filteredTasks(filter).map { it.tasks }
|
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||||||
|
|
||||||
/** [tasks] together with the definition behind a [TaskFilter.Saved]. */
|
|
||||||
fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The direct children of [parentId], re-emitting live like the other flows.
|
* The direct children of [parentId], re-emitting live like the other flows.
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.floret.time.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SeriesCollapse
|
import de.jeanlucmakiola.agendula.domain.SeriesCollapse
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
@@ -19,11 +17,7 @@ import kotlinx.coroutines.channels.Channel
|
|||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.channels.consumeEach
|
import kotlinx.coroutines.channels.consumeEach
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -38,26 +32,12 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
private val dataSource: TasksDataSource,
|
private val dataSource: TasksDataSource,
|
||||||
private val providerResolver: ProviderResolver,
|
private val providerResolver: ProviderResolver,
|
||||||
private val startupGate: StartupGate,
|
private val startupGate: StartupGate,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : TasksRepository {
|
) : TasksRepository {
|
||||||
|
|
||||||
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() }
|
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() }
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
||||||
override fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks> = when (filter) {
|
|
||||||
// A deleted filter shows nothing rather than failing the screen or widget on it.
|
|
||||||
is TaskFilter.Saved -> settingsPrefs.savedFilter(filter.id)
|
|
||||||
.flatMapLatest { saved ->
|
|
||||||
if (saved == null) flowOf(FilteredTasks(emptyList()))
|
|
||||||
else observing {
|
|
||||||
// A deleted list's id would otherwise leave the filter quietly empty.
|
|
||||||
val current = saved.withListsIn(dataSource.taskLists().mapTo(HashSet()) { it.id })
|
|
||||||
FilteredTasks(loadTasks(filter, current), current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> observing { FilteredTasks(loadTasks(filter)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
|
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
|
||||||
SeriesCollapse.visible(dataSource.subtasks(parentId))
|
SeriesCollapse.visible(dataSource.subtasks(parentId))
|
||||||
@@ -75,7 +55,7 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadTasks(filter: TaskFilter, saved: SavedFilter? = null): List<Task> {
|
private fun loadTasks(filter: TaskFilter): List<Task> {
|
||||||
val query = when (filter) {
|
val query = when (filter) {
|
||||||
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
|
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
|
||||||
// Read completed tasks too, then let [TaskFiltering.matches] enforce each
|
// Read completed tasks too, then let [TaskFiltering.matches] enforce each
|
||||||
@@ -86,8 +66,6 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
|
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
|
||||||
// several lists are read in full and membership is filtered in-memory.
|
// several lists are read in full and membership is filtered in-memory.
|
||||||
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
|
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
|
||||||
is TaskFilter.Tagged -> TaskQuery(includeCompleted = true)
|
|
||||||
is TaskFilter.Saved -> TaskQuery(includeCompleted = true)
|
|
||||||
}
|
}
|
||||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||||
val all = SeriesCollapse.visible(dataSource.tasks(query))
|
val all = SeriesCollapse.visible(dataSource.tasks(query))
|
||||||
@@ -99,7 +77,7 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
(if (t.isCompleted) done + 1 else done) to (total + 1)
|
(if (t.isCompleted) done + 1 else done) to (total + 1)
|
||||||
}
|
}
|
||||||
return all
|
return all
|
||||||
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd, saved) }
|
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
|
||||||
.map { task ->
|
.map { task ->
|
||||||
progress[task.taskId]?.let { (done, total) ->
|
progress[task.taskId]?.let { (done, total) ->
|
||||||
task.copy(subtaskDone = done, subtaskTotal = total)
|
task.copy(subtaskDone = done, subtaskTotal = total)
|
||||||
@@ -110,41 +88,11 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun createTask(form: TaskForm): Long =
|
override suspend fun createTask(form: TaskForm): Long =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
dataSource.insertTask(form, dueRemindersFor(form, current = null).distinct().sorted().map { TaskReminder(it) })
|
val id = dataSource.insertTask(form)
|
||||||
|
writeReminders(id, form)
|
||||||
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The before-due leads to store for [form], given the task as it was.
|
|
||||||
*
|
|
||||||
* A task that gets its due date in this write and names no reminder of its
|
|
||||||
* own takes the list's or the app's default *as its own reminders*. The
|
|
||||||
* default is this device's setting, which nothing else can see; stored on
|
|
||||||
* the task, it travels with it to the server and fires on every client.
|
|
||||||
* The cost is tasks.org's: changing the default later does not reach the
|
|
||||||
* tasks that already have it.
|
|
||||||
*
|
|
||||||
* A task that already had a due date keeps what the form says, so an
|
|
||||||
* edit that clears every reminder means "use the default here" as before —
|
|
||||||
* with one exception. Switching between timed and all-day swaps the
|
|
||||||
* default it was given for the other kind's, as long as the reminders are
|
|
||||||
* still exactly that default: a timed 15 minutes is no lead for an all-day
|
|
||||||
* task, which the planner used to sort out by itself while the default
|
|
||||||
* stayed unstored.
|
|
||||||
*/
|
|
||||||
private suspend fun dueRemindersFor(form: TaskForm, current: Task?): List<Int> {
|
|
||||||
if (form.due == null) return form.reminders
|
|
||||||
val hadDue = current?.due != null
|
|
||||||
if (!hadDue) {
|
|
||||||
if (form.reminders.isNotEmpty()) return form.reminders
|
|
||||||
return settingsPrefs.settings.first().defaultLeadsFor(form.listId, form.isAllDay)
|
|
||||||
}
|
|
||||||
if (current.isAllDay == form.isAllDay || form.reminders.isEmpty()) return form.reminders
|
|
||||||
val settings = settingsPrefs.settings.first()
|
|
||||||
val previous = settings.defaultLeadsFor(current.listId, current.isAllDay)
|
|
||||||
return if (form.reminders.sorted() == previous.sorted()) settings.defaultLeadsFor(form.listId, form.isAllDay)
|
|
||||||
else form.reminders
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
|
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
val task = checkNotNull(dataSource.task(taskId, occurrenceStart)) { "Task $taskId is gone" }
|
val task = checkNotNull(dataSource.task(taskId, occurrenceStart)) { "Task $taskId is gone" }
|
||||||
@@ -152,9 +100,6 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun copyOf(task: Task, parentId: Long?, depth: Int): Long {
|
private fun copyOf(task: Task, parentId: Long?, depth: Int): Long {
|
||||||
// In the same write as the copy: inserted first and given its reminders
|
|
||||||
// after, a sync landing in between uploads the copy without them.
|
|
||||||
val reminders = dataSource.reminders()[task.taskId].orEmpty().map { it.lead }
|
|
||||||
val id = dataSource.insertTask(
|
val id = dataSource.insertTask(
|
||||||
TaskForm(
|
TaskForm(
|
||||||
title = task.title,
|
title = task.title,
|
||||||
@@ -167,10 +112,10 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
location = task.location,
|
location = task.location,
|
||||||
url = task.url,
|
url = task.url,
|
||||||
tags = task.tags,
|
|
||||||
),
|
),
|
||||||
reminders,
|
|
||||||
)
|
)
|
||||||
|
val reminders = dataSource.reminders()[task.taskId].orEmpty()
|
||||||
|
if (reminders.isNotEmpty()) dataSource.setReminders(id, reminders)
|
||||||
if (depth < MAX_COPY_DEPTH) {
|
if (depth < MAX_COPY_DEPTH) {
|
||||||
SeriesCollapse.visible(dataSource.subtasks(task.taskId))
|
SeriesCollapse.visible(dataSource.subtasks(task.taskId))
|
||||||
.filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED }
|
.filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED }
|
||||||
@@ -205,17 +150,18 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
val seen = current?.lastModified
|
val seen = current?.lastModified
|
||||||
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
|
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
|
||||||
}
|
}
|
||||||
val reminders = dueRemindersFor(form, current)
|
|
||||||
val seriesId = current?.seriesId
|
val seriesId = current?.seriesId
|
||||||
val occurrence = current?.occurrenceStart
|
val occurrence = current?.occurrenceStart
|
||||||
if (seriesId != null && occurrence != null) {
|
if (seriesId != null && occurrence != null) {
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringScope.AllOccurrences -> {
|
RecurringScope.AllOccurrences -> {
|
||||||
dataSource.updateSeries(seriesId, occurrence, form, reminders)
|
writeReminders(seriesId, form)
|
||||||
|
dataSource.updateSeries(seriesId, occurrence, form)
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
RecurringScope.ThisAndFollowing -> {
|
RecurringScope.ThisAndFollowing -> {
|
||||||
dataSource.splitSeries(seriesId, occurrence, form, reminders)
|
val created = dataSource.splitSeries(seriesId, occurrence, form)
|
||||||
|
writeReminders(created, form)
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
RecurringScope.ThisOccurrence -> Unit
|
RecurringScope.ThisOccurrence -> Unit
|
||||||
@@ -223,11 +169,31 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
// A recurring task's start/due are one occurrence's resolved times, so
|
// A recurring task's start/due are one occurrence's resolved times, so
|
||||||
// writing them back to the task row would re-anchor the whole series.
|
// writing them back to the task row would re-anchor the whole series.
|
||||||
// Going through the occurrence forks an override instead, which takes
|
// Going through the occurrence forks an override instead.
|
||||||
// the reminders on its own row and leaves the series' alone.
|
|
||||||
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
|
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
|
||||||
if (instance == null) dataSource.updateTask(taskId, form, reminders)
|
if (instance == null) {
|
||||||
else dataSource.updateInstance(taskId, instance, form, reminders)
|
writeReminders(taskId, form)
|
||||||
|
dataSource.updateTask(taskId, form)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
// ⚠️ 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 leaving it there would silently give every other
|
||||||
|
// occurrence in the series the reminder the user set on one of them.
|
||||||
|
val seriesReminders = dataSource.reminders()[taskId].orEmpty()
|
||||||
|
writeReminders(taskId, form)
|
||||||
|
try {
|
||||||
|
dataSource.updateInstance(taskId, instance, form)
|
||||||
|
} finally {
|
||||||
|
dataSource.setReminders(taskId, seriesReminders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The form's before-due reminders; a start-relative one from elsewhere is kept as it was. */
|
||||||
|
private fun writeReminders(taskId: Long, form: TaskForm) {
|
||||||
|
val kept = dataSource.reminders()[taskId].orEmpty().filter { it.fromStart }
|
||||||
|
dataSource.setReminders(taskId, kept + form.reminders.distinct().sorted().map { TaskReminder(it) })
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) =
|
override suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) =
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks.ical
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParam
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
|
||||||
import kotlin.math.roundToLong
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `VALARM` ↔ [TaskAlarmEntity].
|
|
||||||
*
|
|
||||||
* ## What is claimed
|
|
||||||
*
|
|
||||||
* Only an alarm Agendula can fire exactly as written becomes a reminder row.
|
|
||||||
* Everything else stays in the task's residue and round-trips untouched — it
|
|
||||||
* still fires in the client that wrote it, just not here:
|
|
||||||
*
|
|
||||||
* - `ACTION` other than `DISPLAY`/`AUDIO`;
|
|
||||||
* - Apple's default alarms (`X-APPLE-DEFAULT-ALARM:TRUE`). They are that Mac's
|
|
||||||
* preference, not task data: claimed, they would come back to the Mac as an
|
|
||||||
* explicit alarm without the flag after the first edit here;
|
|
||||||
* - location alarms (`X-APPLE-PROXIMITY`, RFC 9074 `PROXIMITY`, a nested
|
|
||||||
* component) and Apple's `19760401T005545Z` sentinel trigger they carry;
|
|
||||||
* - repeating alarms (`REPEAT`/`DURATION`) and RFC 9074 snoozes (`RELATED-TO`);
|
|
||||||
* - a trigger *after* its anchor. The planner coerces a negative lead to 0, so
|
|
||||||
* it would fire at the wrong time;
|
|
||||||
* - an absolute trigger on a recurring task, or on one with no `DUE` to count
|
|
||||||
* back from.
|
|
||||||
*
|
|
||||||
* ## What "unchanged" means
|
|
||||||
*
|
|
||||||
* A claimed row keeps the `VALARM` it was read from in [TaskAlarmEntity.raw].
|
|
||||||
* On write, the row is "unchanged" when that original still [resolve]s to the
|
|
||||||
* row's lead against the task *as it is now* — so `-PT15M` and `-P0DT0H15M0S`
|
|
||||||
* are the same, a relative alarm survives a due-date edit, and an absolute one
|
|
||||||
* stops matching once `DUE` moves. An unchanged row is re-emitted verbatim; a
|
|
||||||
* changed one is authored fresh under its existing uid.
|
|
||||||
*
|
|
||||||
* ## Anchors
|
|
||||||
*
|
|
||||||
* `RELATED=END` counts from `DUE`. `RELATED=START` (the default) counts from
|
|
||||||
* `DTSTART` — except when `DTSTART` equals `DUE`, or when there is no
|
|
||||||
* `DTSTART` but there is a `DUE`. Apple writes the latter shape (against §3.8.6.3,
|
|
||||||
* which wants `DTSTART` present), and reading it as a before-due reminder is
|
|
||||||
* what makes it editable here: the editor only shows before-due rows.
|
|
||||||
*
|
|
||||||
* ## All-day tasks
|
|
||||||
*
|
|
||||||
* The local fire time of day is a device preference this mapper cannot see, so
|
|
||||||
* an all-day lead is whole days, written `-P<n>D` against `DUE`'s midnight. Read
|
|
||||||
* back, any trigger on an all-day task rounds to the nearest whole day.
|
|
||||||
*/
|
|
||||||
object AlarmCodec {
|
|
||||||
|
|
||||||
private const val DAY_MINUTES = 24 * 60
|
|
||||||
|
|
||||||
/** Apple's placeholder trigger for location alarms. Never a real time. */
|
|
||||||
private const val APPLE_SENTINEL = "19760401T005545Z"
|
|
||||||
|
|
||||||
/** What a `VALARM` means to the scheduler. */
|
|
||||||
data class Lead(val minutesBefore: Int, val reference: AlarmReference)
|
|
||||||
|
|
||||||
/** A `VALARM` as a reminder row — `taskId` is the caller's — or null if it is not ours to claim. */
|
|
||||||
fun fromVAlarm(alarm: ICalComponent, task: TaskEntity): TaskAlarmEntity? {
|
|
||||||
val lead = resolve(alarm, task) ?: return null
|
|
||||||
val raw = ICalSerializer.serialize(alarm)
|
|
||||||
// Too large to keep verbatim means it could not be re-emitted, and an
|
|
||||||
// alarm that is authored fresh on every write loses what made it large.
|
|
||||||
if (raw.toByteArray(Charsets.UTF_8).size > VTodoMapper.MAX_RESIDUE_BYTES) return null
|
|
||||||
val uid = (alarm.property("X-WR-ALARMUID") ?: alarm.property("UID"))?.value?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
val base = TaskAlarmEntity(
|
|
||||||
taskId = 0,
|
|
||||||
minutesBefore = lead.minutesBefore,
|
|
||||||
reference = lead.reference,
|
|
||||||
raw = raw,
|
|
||||||
)
|
|
||||||
return if (uid == null) base else base.copy(uid = uid)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The lead [alarm] stands for on [task], or null when it is not claimable. */
|
|
||||||
fun resolve(alarm: ICalComponent, task: TaskEntity): Lead? {
|
|
||||||
val action = alarm.property("ACTION")?.value?.trim()?.uppercase()
|
|
||||||
if (action != "DISPLAY" && action != "AUDIO") return null
|
|
||||||
if (alarm.property("X-APPLE-DEFAULT-ALARM")?.value?.trim().equals("TRUE", ignoreCase = true)) return null
|
|
||||||
val excluded = listOf("X-APPLE-PROXIMITY", "PROXIMITY", "REPEAT", "DURATION", "RELATED-TO")
|
|
||||||
if (excluded.any { alarm.property(it) != null }) return null
|
|
||||||
if (alarm.components.isNotEmpty()) return null
|
|
||||||
|
|
||||||
val trigger = alarm.properties("TRIGGER").singleOrNull() ?: return null
|
|
||||||
val value = trigger.value.trim()
|
|
||||||
if (value.startsWith(APPLE_SENTINEL)) return null
|
|
||||||
|
|
||||||
val absolute = trigger.param("VALUE").equals("DATE-TIME", ignoreCase = true) ||
|
|
||||||
(!value.startsWith("P", ignoreCase = true) && !value.startsWith("-") && !value.startsWith("+"))
|
|
||||||
return if (absolute) absoluteLead(trigger, task) else relativeLead(trigger, task)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun relativeLead(trigger: ICalProperty, task: TaskEntity): Lead? {
|
|
||||||
val seconds = ICalValues.parseDuration(trigger.value) ?: return null
|
|
||||||
val reference = when {
|
|
||||||
trigger.param("RELATED").equals("END", ignoreCase = true) ->
|
|
||||||
if (task.due != null) AlarmReference.DUE else return null
|
|
||||||
task.dtstart != null && task.dtstart != task.due -> AlarmReference.START
|
|
||||||
task.due != null -> AlarmReference.DUE
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
val before = -seconds
|
|
||||||
val minutes = if (task.isAllDay) {
|
|
||||||
// Rounded before the sign check: `PT9H` on a date-only DUE is
|
|
||||||
// "09:00 on the day", which is this device's "on the day".
|
|
||||||
roundToDays(before)
|
|
||||||
} else {
|
|
||||||
// A lead in seconds is not one the planner can reproduce.
|
|
||||||
if (before % 60 != 0L) return null
|
|
||||||
before / 60
|
|
||||||
}
|
|
||||||
return minutes.toIntOrNull()?.let { Lead(it, reference) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun absoluteLead(trigger: ICalProperty, task: TaskEntity): Lead? {
|
|
||||||
if (task.rrule != null || task.rdate != null) return null
|
|
||||||
val due = task.due ?: return null
|
|
||||||
val at = (ICalValues.parseTime(trigger) as? ICalValues.TimeValue.Timed)
|
|
||||||
?.takeIf { it.tzid == null } // §3.8.6.3: an absolute trigger MUST be UTC
|
|
||||||
?.instant ?: return null
|
|
||||||
val before = (due - at).inWholeSeconds
|
|
||||||
val minutes = if (task.isAllDay) {
|
|
||||||
// An all-day DUE is stored as UTC midnight of its date, which is
|
|
||||||
// the calendar date the trigger counts back from.
|
|
||||||
roundToDays(before)
|
|
||||||
} else {
|
|
||||||
Math.floorDiv(before, 60L)
|
|
||||||
}
|
|
||||||
return minutes.toIntOrNull()?.let { Lead(it, AlarmReference.DUE) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nearest whole day, in minutes. */
|
|
||||||
private fun roundToDays(seconds: Long): Long =
|
|
||||||
(seconds / 86_400.0).roundToLong() * DAY_MINUTES
|
|
||||||
|
|
||||||
/** Negative is after the anchor, which is not claimed. */
|
|
||||||
private fun Long.toIntOrNull(): Int? = takeIf { it in 0..Int.MAX_VALUE }?.toInt()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [row] as a `VALARM` on [task], or null when it cannot be written: its
|
|
||||||
* anchor is missing. That row stays on the device — the planner ignores it
|
|
||||||
* too — because `RELATED=END` without `DUE` is a resource the server
|
|
||||||
* refuses ([VTodoMapper.validate]).
|
|
||||||
*
|
|
||||||
* ⚠️ A before-start row on a task that has lost its start is written
|
|
||||||
* against `DUE`, which is what the planner fires it from. Leaving it out
|
|
||||||
* would drop it from every other client, and the next download would then
|
|
||||||
* take it off this device too.
|
|
||||||
*
|
|
||||||
* [now] marks an authored alarm `ACKNOWLEDGED` once it is past even the
|
|
||||||
* planner's missed-reminder grace, so a client that honours RFC 9074 does
|
|
||||||
* not greet it as a missed alarm. Not sooner: the stamp comes back on the
|
|
||||||
* next download and would silence the late reminder this device still owes.
|
|
||||||
*/
|
|
||||||
fun toVAlarm(row: TaskAlarmEntity, task: TaskEntity, now: Instant): ICalComponent? {
|
|
||||||
val reference = if (row.reference == AlarmReference.START && task.dtstart == null) AlarmReference.DUE
|
|
||||||
else row.reference
|
|
||||||
val lead = Lead(row.minutesBefore, reference)
|
|
||||||
row.raw?.let(::parse)?.let { original ->
|
|
||||||
if (resolve(original, task) == lead) return original
|
|
||||||
}
|
|
||||||
|
|
||||||
val anchor = when (reference) {
|
|
||||||
AlarmReference.DUE -> task.due
|
|
||||||
AlarmReference.START -> task.dtstart
|
|
||||||
} ?: return null
|
|
||||||
val related = if (reference == AlarmReference.DUE) "END" else "START"
|
|
||||||
val minutes = row.minutesBefore.coerceAtLeast(0).toLong()
|
|
||||||
val seconds = if (task.isAllDay) {
|
|
||||||
-((minutes + DAY_MINUTES / 2) / DAY_MINUTES) * 86_400
|
|
||||||
} else {
|
|
||||||
-minutes * 60
|
|
||||||
}
|
|
||||||
|
|
||||||
val properties = mutableListOf(
|
|
||||||
ICalProperty("UID", value = row.uid),
|
|
||||||
ICalProperty("X-WR-ALARMUID", value = row.uid),
|
|
||||||
ICalProperty("ACTION", value = "DISPLAY"),
|
|
||||||
ICalProperty("DESCRIPTION", value = ICalValues.escapeText(row.message ?: "Reminder")),
|
|
||||||
ICalProperty("TRIGGER", listOf(ICalParam("RELATED", related)), ICalValues.formatDuration(seconds)),
|
|
||||||
)
|
|
||||||
val recurring = task.rrule != null || task.rdate != null
|
|
||||||
if (!recurring && anchor + seconds.seconds + ReminderPlanner.MISSED_GRACE_MS.milliseconds < now) {
|
|
||||||
properties += ICalProperty("ACKNOWLEDGED", value = ICalValues.formatDateTime(now, null))
|
|
||||||
}
|
|
||||||
return ICalComponent("VALARM", properties)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What [row] reads back as once it has been to the server and back — not
|
|
||||||
* always the row itself, because the wire is coarser: an all-day lead is
|
|
||||||
* whole days, and a before-start row on a task without a start goes out
|
|
||||||
* before-due. A download that brings back exactly this has not changed the
|
|
||||||
* reminder, and the row's own lead must be kept rather than taken from it.
|
|
||||||
*/
|
|
||||||
fun wireLead(row: TaskAlarmEntity, task: TaskEntity): Lead? =
|
|
||||||
toVAlarm(row, task, Instant.DISTANT_PAST)?.let { resolve(it, task) }
|
|
||||||
|
|
||||||
private fun parse(raw: String): ICalComponent? =
|
|
||||||
runCatching { ICalParser.parse(raw) }.getOrNull()?.takeIf { it.name.equals("VALARM", ignoreCase = true) }
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks.ical
|
package de.jeanlucmakiola.agendula.data.tasks.ical
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
@@ -10,7 +9,6 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
|||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -53,11 +51,10 @@ import kotlin.time.Instant
|
|||||||
*
|
*
|
||||||
* ## Alarms
|
* ## Alarms
|
||||||
*
|
*
|
||||||
* A `VALARM` [AlarmCodec] can fire exactly as written is claimed into
|
* `VALARM`s round-trip in the residue and are never authored here. Local
|
||||||
* [Mapped.alarms] — rows for `task_alarms`, carrying the original verbatim —
|
* reminders live in `task_alarms` and are never serialised. The two stores are
|
||||||
* and [write] authors the task's rows back. Any other `VALARM` (Apple's default
|
* disjoint, so neither can destroy the other. Merging them is a later
|
||||||
* and location alarms, repeating ones, snoozes) stays in the residue and is
|
* decision, not a silent one.
|
||||||
* re-emitted untouched after the authored ones.
|
|
||||||
*/
|
*/
|
||||||
object VTodoMapper {
|
object VTodoMapper {
|
||||||
|
|
||||||
@@ -84,8 +81,6 @@ object VTodoMapper {
|
|||||||
// Parameterised copies stay in the residue verbatim; authoring the
|
// Parameterised copies stay in the residue verbatim; authoring the
|
||||||
// column beside them would emit the same exclusions twice.
|
// column beside them would emit the same exclusions twice.
|
||||||
"RDATE", "EXDATE",
|
"RDATE", "EXDATE",
|
||||||
// Several lines, or one the column would not re-serialise exactly.
|
|
||||||
"CATEGORIES",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
|
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
|
||||||
@@ -97,8 +92,6 @@ object VTodoMapper {
|
|||||||
val uidWasMissing: Boolean,
|
val uidWasMissing: Boolean,
|
||||||
/** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */
|
/** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */
|
||||||
val droppedResidue: Boolean,
|
val droppedResidue: Boolean,
|
||||||
/** The claimed `VALARM`s, `taskId` unset. Not counted against the residue cap. */
|
|
||||||
val alarms: List<TaskAlarmEntity> = emptyList(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------- read
|
// ---------------------------------------------------------------- read
|
||||||
@@ -207,9 +200,6 @@ object VTodoMapper {
|
|||||||
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
|
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
|
||||||
)
|
)
|
||||||
|
|
||||||
val (categories, categoriesProperty) = readCategories(vtodo)
|
|
||||||
claim(categoriesProperty)
|
|
||||||
|
|
||||||
val sequenceProperty = vtodo.property("SEQUENCE")
|
val sequenceProperty = vtodo.property("SEQUENCE")
|
||||||
val sequence = take(
|
val sequence = take(
|
||||||
sequenceProperty,
|
sequenceProperty,
|
||||||
@@ -235,7 +225,6 @@ object VTodoMapper {
|
|||||||
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
|
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
|
||||||
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
|
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
|
||||||
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
|
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
|
||||||
categories = categories,
|
|
||||||
// URI, not TEXT — escaping it would corrupt a query string.
|
// URI, not TEXT — escaping it would corrupt a query string.
|
||||||
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
|
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
|
||||||
status = status,
|
status = status,
|
||||||
@@ -267,18 +256,8 @@ object VTodoMapper {
|
|||||||
.filterIndexed { index, _ -> index !in claimed }
|
.filterIndexed { index, _ -> index !in claimed }
|
||||||
.filterNot { it.name.equals("DTSTAMP", ignoreCase = true) }
|
.filterNot { it.name.equals("DTSTAMP", ignoreCase = true) }
|
||||||
|
|
||||||
// Claimed against the entity as read, so the anchors are the ones the
|
|
||||||
// rows will be written back against.
|
|
||||||
val alarms = mutableListOf<TaskAlarmEntity>()
|
|
||||||
val residueComponents = vtodo.components.filter { component ->
|
|
||||||
if (!component.name.equals("VALARM", ignoreCase = true)) return@filter true
|
|
||||||
val row = AlarmCodec.fromVAlarm(component, entity) ?: return@filter true
|
|
||||||
alarms += row
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
val residueText = ICalSerializer.serializeProperties(residueProperties) +
|
val residueText = ICalSerializer.serializeProperties(residueProperties) +
|
||||||
ICalSerializer.serializeAll(residueComponents)
|
ICalSerializer.serializeAll(vtodo.components)
|
||||||
val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES
|
val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES
|
||||||
|
|
||||||
return Mapped(
|
return Mapped(
|
||||||
@@ -288,7 +267,6 @@ object VTodoMapper {
|
|||||||
parentUid = parentUid,
|
parentUid = parentUid,
|
||||||
uidWasMissing = uid.isEmpty(),
|
uidWasMissing = uid.isEmpty(),
|
||||||
droppedResidue = tooLarge,
|
droppedResidue = tooLarge,
|
||||||
alarms = alarms,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,19 +276,16 @@ object VTodoMapper {
|
|||||||
* Serialises [entity] back to a VTODO.
|
* Serialises [entity] back to a VTODO.
|
||||||
*
|
*
|
||||||
* [parentUid] is the parent's `UID`, which the entity holds only as a row id.
|
* [parentUid] is the parent's `UID`, which the entity holds only as a row id.
|
||||||
* [now] is the `DTSTAMP`. [alarms] are the task's reminder rows; one whose
|
* [now] is the `DTSTAMP`.
|
||||||
* anchor the task no longer has is left out (see [AlarmCodec.toVAlarm]).
|
|
||||||
*/
|
*/
|
||||||
fun write(
|
fun write(
|
||||||
entity: TaskEntity,
|
entity: TaskEntity,
|
||||||
parentUid: String? = null,
|
parentUid: String? = null,
|
||||||
now: Instant = Clock.System.now(),
|
now: Instant = Clock.System.now(),
|
||||||
alarms: List<TaskAlarmEntity> = emptyList(),
|
|
||||||
): ICalComponent {
|
): ICalComponent {
|
||||||
val residue = parseResidue(entity.unknownProperties)
|
val residue = parseResidue(entity.unknownProperties)
|
||||||
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
|
|
||||||
val keptResidue =
|
val keptResidue =
|
||||||
residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) }
|
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) }
|
||||||
val suppressed = keptResidue
|
val suppressed = keptResidue
|
||||||
.map { it.name.uppercase() }
|
.map { it.name.uppercase() }
|
||||||
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
|
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
|
||||||
@@ -332,7 +307,6 @@ object VTodoMapper {
|
|||||||
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
|
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
|
||||||
add("LOCATION", entity.location?.let(ICalValues::escapeText))
|
add("LOCATION", entity.location?.let(ICalValues::escapeText))
|
||||||
add("URL", entity.url)
|
add("URL", entity.url)
|
||||||
add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList))
|
|
||||||
|
|
||||||
add("STATUS", entity.status.toICalName())
|
add("STATUS", entity.status.toICalName())
|
||||||
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
|
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
|
||||||
@@ -366,7 +340,7 @@ object VTodoMapper {
|
|||||||
return ICalComponent(
|
return ICalComponent(
|
||||||
name = "VTODO",
|
name = "VTODO",
|
||||||
properties = properties + keptResidue,
|
properties = properties + keptResidue,
|
||||||
components = alarms.mapNotNull { AlarmCodec.toVAlarm(it, entity, now) } + residue.components,
|
components = residue.components,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +386,6 @@ object VTodoMapper {
|
|||||||
private fun contradictsResidue(
|
private fun contradictsResidue(
|
||||||
property: ICalProperty,
|
property: ICalProperty,
|
||||||
residue: ICalComponent,
|
residue: ICalComponent,
|
||||||
residueCategories: List<String>,
|
|
||||||
entity: TaskEntity,
|
entity: TaskEntity,
|
||||||
parentUid: String?,
|
parentUid: String?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
@@ -460,7 +433,6 @@ object VTodoMapper {
|
|||||||
"PRIORITY" -> entity.priority != PRIORITY_NONE
|
"PRIORITY" -> entity.priority != PRIORITY_NONE
|
||||||
"PERCENT-COMPLETE" -> entity.percentComplete != null
|
"PERCENT-COMPLETE" -> entity.percentComplete != null
|
||||||
"CLASS" -> entity.classification != null
|
"CLASS" -> entity.classification != null
|
||||||
"CATEGORIES" -> entity.categories != residueCategories
|
|
||||||
// Not SEQUENCE: it is the organiser's counter and never ours to bump,
|
// Not SEQUENCE: it is the organiser's counter and never ours to bump,
|
||||||
// so the column stays at its fallback and the residue always wins.
|
// so the column stays at its fallback and the residue always wins.
|
||||||
else -> false
|
else -> false
|
||||||
@@ -503,23 +475,6 @@ object VTodoMapper {
|
|||||||
|
|
||||||
// -------------------------------------------------------------- helpers
|
// -------------------------------------------------------------- helpers
|
||||||
|
|
||||||
/**
|
|
||||||
* Every `CATEGORIES` copy's values, merged, and the one line to claim — only
|
|
||||||
* when it is the sole, parameterless copy and the column re-serialises it
|
|
||||||
* byte for byte. Anything else is read for display and stays in the residue.
|
|
||||||
*/
|
|
||||||
private fun readCategories(vtodo: ICalComponent): Pair<List<String>, ICalProperty?> {
|
|
||||||
val copies = vtodo.properties("CATEGORIES")
|
|
||||||
val values = normalizeTags(copies.flatMap(::categoryValues))
|
|
||||||
val claimable = copies.singleOrNull()?.takeIf {
|
|
||||||
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
|
|
||||||
}
|
|
||||||
return values to claimable
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun categoryValues(property: ICalProperty): List<String> =
|
|
||||||
normalizeTags(ICalValues.parseTextList(property.value))
|
|
||||||
|
|
||||||
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
|
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
|
||||||
|
|
||||||
private class TimeRead(
|
private class TimeRead(
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
|
||||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -222,17 +221,12 @@ class OneShotImport @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val alarms = mutableListOf<LegacyAlarm>()
|
val alarms = mutableListOf<LegacyAlarm>()
|
||||||
val categories = mutableMapOf<Long, MutableList<String>>()
|
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null)
|
||||||
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties ORDER BY property_id", null)
|
|
||||||
.use { cursor ->
|
.use { cursor ->
|
||||||
val r = CursorColumnReader(cursor)
|
val r = CursorColumnReader(cursor)
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
val taskId = r.getLong("task_id") ?: continue
|
|
||||||
if (r.getString("mimetype") == CATEGORY_MIMETYPE) {
|
|
||||||
r.getString("data1")?.let { categories.getOrPut(taskId, ::mutableListOf) += it }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
|
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
|
||||||
|
val taskId = r.getLong("task_id") ?: continue
|
||||||
val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
|
val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
|
||||||
alarms += LegacyAlarm(
|
alarms += LegacyAlarm(
|
||||||
taskId = taskId,
|
taskId = taskId,
|
||||||
@@ -247,7 +241,7 @@ class OneShotImport @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return LegacySnapshot(lists, rows, alarms, categories.mapValues { normalizeTags(it.value) })
|
return LegacySnapshot(lists, rows, alarms)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- writing into Room ----------------------------------------------------
|
// --- writing into Room ----------------------------------------------------
|
||||||
@@ -282,12 +276,7 @@ class OneShotImport @Inject constructor(
|
|||||||
// the unique index; a fresh uid costs the row nothing it still has.
|
// the unique index; a fresh uid costs the row nothing it still has.
|
||||||
val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
|
val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
|
||||||
?: UUID.randomUUID().toString()
|
?: UUID.randomUUID().toString()
|
||||||
val entity = row.entity.copy(
|
val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId)
|
||||||
listId = listId,
|
|
||||||
uid = uid,
|
|
||||||
recurrenceId = recurrenceId,
|
|
||||||
categories = snapshot.categories[row.id].orEmpty(),
|
|
||||||
)
|
|
||||||
val newId = taskDao.insert(entity)
|
val newId = taskDao.insert(entity)
|
||||||
taskIds[row.id] = newId
|
taskIds[row.id] = newId
|
||||||
inserted += row to entity.copy(id = newId)
|
inserted += row to entity.copy(id = newId)
|
||||||
@@ -390,7 +379,6 @@ class OneShotImport @Inject constructor(
|
|||||||
const val ARCHIVE_NAME = "tasks.db.imported"
|
const val ARCHIVE_NAME = "tasks.db.imported"
|
||||||
|
|
||||||
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
|
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
|
||||||
private const val CATEGORY_MIMETYPE = "vnd.android.cursor.item/category"
|
|
||||||
private const val REFERENCE_START = "2"
|
private const val REFERENCE_START = "2"
|
||||||
private val SIDECARS = listOf("-journal", "-wal", "-shm")
|
private val SIDECARS = listOf("-journal", "-wal", "-shm")
|
||||||
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
|
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
|
||||||
@@ -403,8 +391,6 @@ private class LegacySnapshot(
|
|||||||
val lists: List<LegacyList>,
|
val lists: List<LegacyList>,
|
||||||
val tasks: List<LegacyTaskRow>,
|
val tasks: List<LegacyTaskRow>,
|
||||||
val alarms: List<LegacyAlarm>,
|
val alarms: List<LegacyAlarm>,
|
||||||
/** Tags by legacy task `_id`. */
|
|
||||||
val categories: Map<Long, List<String>>,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private class LegacyList(val id: Long, val entity: TaskListEntity)
|
private class LegacyList(val id: Long, val entity: TaskListEntity)
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
|
||||||
import kotlin.math.roundToLong
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lifts claimable `VALARM`s out of a residue written before v3 synced them.
|
|
||||||
*
|
|
||||||
* ⚠️ Frozen with [TasksDatabase.MIGRATION_2_3]: a copy of `AlarmCodec`'s claim
|
|
||||||
* rule as it stood then, not a call into it, so a later change to what the
|
|
||||||
* codec claims cannot change what this migration produced for an earlier
|
|
||||||
* upgrader.
|
|
||||||
*/
|
|
||||||
internal object AlarmLift {
|
|
||||||
|
|
||||||
/** The task columns the claim rule reads. */
|
|
||||||
data class Task(
|
|
||||||
val dtstart: Instant?,
|
|
||||||
val due: Instant?,
|
|
||||||
val isAllDay: Boolean,
|
|
||||||
val recurring: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Alarm(val minutesBefore: Int, val reference: AlarmReference, val uid: String?, val raw: String)
|
|
||||||
|
|
||||||
/** The lifted alarms and the residue without them, or `null` residue when nothing is left. */
|
|
||||||
data class Result(val alarms: List<Alarm>, val residue: String?)
|
|
||||||
|
|
||||||
private const val DAY_MINUTES = 24 * 60
|
|
||||||
private const val MAX_RAW_BYTES = 25 * 1024
|
|
||||||
|
|
||||||
fun lift(residue: String?, task: Task): Result {
|
|
||||||
val component = parse(residue) ?: return Result(emptyList(), residue)
|
|
||||||
val alarms = mutableListOf<Alarm>()
|
|
||||||
val kept = component.components.filter { sub ->
|
|
||||||
if (!sub.name.equals("VALARM", ignoreCase = true)) return@filter true
|
|
||||||
val alarm = claim(sub, task) ?: return@filter true
|
|
||||||
alarms += alarm
|
|
||||||
false
|
|
||||||
}
|
|
||||||
if (alarms.isEmpty()) return Result(emptyList(), residue)
|
|
||||||
val rest = ICalSerializer.serializeProperties(component.properties) + ICalSerializer.serializeAll(kept)
|
|
||||||
return Result(alarms, rest.ifEmpty { null })
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun claim(alarm: ICalComponent, task: Task): Alarm? {
|
|
||||||
val action = alarm.property("ACTION")?.value?.trim()?.uppercase()
|
|
||||||
if (action != "DISPLAY" && action != "AUDIO") return null
|
|
||||||
if (alarm.property("X-APPLE-DEFAULT-ALARM")?.value?.trim().equals("TRUE", ignoreCase = true)) return null
|
|
||||||
if (listOf("X-APPLE-PROXIMITY", "PROXIMITY", "REPEAT", "DURATION", "RELATED-TO").any { alarm.property(it) != null }) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (alarm.components.isNotEmpty()) return null
|
|
||||||
val trigger = alarm.properties("TRIGGER").singleOrNull() ?: return null
|
|
||||||
val value = trigger.value.trim()
|
|
||||||
if (value.startsWith("19760401T005545Z")) return null
|
|
||||||
val absolute = trigger.param("VALUE").equals("DATE-TIME", ignoreCase = true) ||
|
|
||||||
(!value.startsWith("P", ignoreCase = true) && !value.startsWith("-") && !value.startsWith("+"))
|
|
||||||
val (minutes, reference) = (if (absolute) absolute(trigger, task) else relative(trigger, task)) ?: return null
|
|
||||||
val raw = ICalSerializer.serialize(alarm)
|
|
||||||
if (raw.toByteArray(Charsets.UTF_8).size > MAX_RAW_BYTES) return null
|
|
||||||
val uid = (alarm.property("X-WR-ALARMUID") ?: alarm.property("UID"))?.value?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
return Alarm(minutes, reference, uid, raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun relative(trigger: ICalProperty, task: Task): Pair<Int, AlarmReference>? {
|
|
||||||
val seconds = ICalValues.parseDuration(trigger.value) ?: return null
|
|
||||||
val reference = when {
|
|
||||||
trigger.param("RELATED").equals("END", ignoreCase = true) ->
|
|
||||||
if (task.due != null) AlarmReference.DUE else return null
|
|
||||||
task.dtstart != null && task.dtstart != task.due -> AlarmReference.START
|
|
||||||
task.due != null -> AlarmReference.DUE
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
val before = -seconds
|
|
||||||
val minutes = if (task.isAllDay) days(before) else if (before % 60 != 0L) return null else before / 60
|
|
||||||
return minutes.takeIf { it in 0..Int.MAX_VALUE }?.let { it.toInt() to reference }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun absolute(trigger: ICalProperty, task: Task): Pair<Int, AlarmReference>? {
|
|
||||||
if (task.recurring) return null
|
|
||||||
val due = task.due ?: return null
|
|
||||||
val at = (ICalValues.parseTime(trigger) as? ICalValues.TimeValue.Timed)
|
|
||||||
?.takeIf { it.tzid == null }?.instant ?: return null
|
|
||||||
val before = (due - at).inWholeSeconds
|
|
||||||
val minutes = if (task.isAllDay) days(before) else Math.floorDiv(before, 60L)
|
|
||||||
return minutes.takeIf { it in 0..Int.MAX_VALUE }?.let { it.toInt() to AlarmReference.DUE }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun days(seconds: Long): Long = (seconds / 86_400.0).roundToLong() * DAY_MINUTES
|
|
||||||
|
|
||||||
private fun parse(text: String?): ICalComponent? {
|
|
||||||
if (text.isNullOrEmpty()) return null
|
|
||||||
return runCatching { ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n") }.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lifts `CATEGORIES` out of a residue written before v2 claimed it.
|
|
||||||
*
|
|
||||||
* ⚠️ Frozen with [TasksDatabase.MIGRATION_1_2]. The claim rule is copied here
|
|
||||||
* rather than shared with the mapper, so a later change to what the mapper
|
|
||||||
* claims cannot change what this migration produced for an earlier upgrader.
|
|
||||||
*/
|
|
||||||
internal object CategoriesLift {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every copy's values merged, and the residue with the one claimed line
|
|
||||||
* removed — or `null` for the residue when nothing was claimed, so the caller
|
|
||||||
* leaves the stored one alone.
|
|
||||||
*/
|
|
||||||
data class Result(val categories: List<String>, val residue: String?, val claimed: Boolean)
|
|
||||||
|
|
||||||
fun lift(residue: String?): Result {
|
|
||||||
val component = parse(residue) ?: return Result(emptyList(), residue, claimed = false)
|
|
||||||
val copies = component.properties.filter { it.name.equals("CATEGORIES", ignoreCase = true) }
|
|
||||||
val values = copies.flatMap(::values)
|
|
||||||
val claim = copies.singleOrNull()?.takeIf {
|
|
||||||
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
|
|
||||||
} ?: return Result(values, residue, claimed = false)
|
|
||||||
val rest = ICalSerializer.serializeProperties(component.properties.filterNot { it === claim }) +
|
|
||||||
ICalSerializer.serializeAll(component.components)
|
|
||||||
return Result(values, rest.ifEmpty { null }, claimed = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun values(property: ICalProperty): List<String> =
|
|
||||||
ICalValues.parseTextList(property.value).map(String::trim).filter(String::isNotEmpty).distinct()
|
|
||||||
|
|
||||||
private fun parse(text: String?): ICalComponent? {
|
|
||||||
if (text.isNullOrEmpty()) return null
|
|
||||||
return runCatching { ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n") }.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,6 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
|||||||
|
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
|
||||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
|
||||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||||
import de.jeanlucmakiola.agendula.domain.toInt
|
import de.jeanlucmakiola.agendula.domain.toInt
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -38,17 +36,3 @@ object Converters {
|
|||||||
fun alarmReferenceFrom(value: String): AlarmReference =
|
fun alarmReferenceFrom(value: String): AlarmReference =
|
||||||
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE)
|
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* [TaskEntity.categories] stored as a `CATEGORIES` value, so escaping has one
|
|
||||||
* home; empty when there are none. Scoped to that field rather than the
|
|
||||||
* database, so no other `List<String>` picks up iCalendar escaping by accident.
|
|
||||||
*/
|
|
||||||
object CategoriesConverter {
|
|
||||||
@TypeConverter
|
|
||||||
fun toText(value: List<String>): String = ICalValues.formatTextList(normalizeTags(value))
|
|
||||||
|
|
||||||
@TypeConverter
|
|
||||||
fun fromText(value: String): List<String> =
|
|
||||||
if (value.isEmpty()) emptyList() else normalizeTags(ICalValues.parseTextList(value))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import androidx.room.Entity
|
|||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import androidx.room.TypeConverters
|
|
||||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
import java.util.UUID
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,10 +143,6 @@ data class TaskEntity(
|
|||||||
@ColumnInfo(name = "url") val url: String? = null,
|
@ColumnInfo(name = "url") val url: String? = null,
|
||||||
/** ARGB override for the list colour. */
|
/** ARGB override for the list colour. */
|
||||||
@ColumnInfo(name = "color") val color: Int? = null,
|
@ColumnInfo(name = "color") val color: Int? = null,
|
||||||
/** RFC 5545 `CATEGORIES` — the tags — in the spelling each task carries them. */
|
|
||||||
@ColumnInfo(name = "categories", defaultValue = "")
|
|
||||||
@field:TypeConverters(CategoriesConverter::class)
|
|
||||||
val categories: List<String> = emptyList(),
|
|
||||||
|
|
||||||
// state
|
// state
|
||||||
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||||
@@ -200,31 +194,12 @@ data class TaskEntity(
|
|||||||
* verbatim on write so a round-trip cannot silently lose a field.
|
* verbatim on write so a round-trip cannot silently lose a field.
|
||||||
*/
|
*/
|
||||||
@ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null,
|
@ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null,
|
||||||
|
|
||||||
// reminders
|
|
||||||
/**
|
|
||||||
* Another client removed this task's last alarm, so it has none — not "use
|
|
||||||
* the default". A task without reminder rows normally takes the list's or
|
|
||||||
* the app's default, which is right for one that never had any (a client
|
|
||||||
* that does not write alarms), and wrong for one whose alarms were turned
|
|
||||||
* off elsewhere: it would keep ringing here. Set by a download that takes
|
|
||||||
* the last row away; cleared as soon as the task has a reminder again.
|
|
||||||
*/
|
|
||||||
@ColumnInfo(name = "reminders_off", defaultValue = "0") val remindersOff: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** What [TaskAlarmEntity.minutesBefore] counts back from. */
|
/** What [TaskAlarmEntity.minutesBefore] counts back from. */
|
||||||
enum class AlarmReference { DUE, START }
|
enum class AlarmReference { DUE, START }
|
||||||
|
|
||||||
/**
|
/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */
|
||||||
* A reminder lead on a task. Positive [minutesBefore] is *before* [reference].
|
|
||||||
*
|
|
||||||
* Synced as a `VALARM` (see `AlarmCodec`). [uid] is the alarm's identity on the
|
|
||||||
* wire — `X-WR-ALARMUID` / RFC 9074 `UID` — minted when the row is created, so
|
|
||||||
* every upload of the same alarm names it the same way. [raw] is the `VALARM`
|
|
||||||
* this row was read from, re-emitted verbatim while the row still means what
|
|
||||||
* it did, so another client's `ACKNOWLEDGED`, `X-*` and trigger form survive.
|
|
||||||
*/
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "task_alarms",
|
tableName = "task_alarms",
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
@@ -244,6 +219,4 @@ data class TaskAlarmEntity(
|
|||||||
@ColumnInfo(name = "minutes_before") val minutesBefore: Int,
|
@ColumnInfo(name = "minutes_before") val minutesBefore: Int,
|
||||||
@ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE,
|
@ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE,
|
||||||
@ColumnInfo(name = "message") val message: String? = null,
|
@ColumnInfo(name = "message") val message: String? = null,
|
||||||
@ColumnInfo(name = "uid", defaultValue = "") val uid: String = UUID.randomUUID().toString(),
|
|
||||||
@ColumnInfo(name = "raw") val raw: String? = null,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ object RoomTaskMapper {
|
|||||||
description = row.task.description,
|
description = row.task.description,
|
||||||
location = row.task.location,
|
location = row.task.location,
|
||||||
url = row.task.url,
|
url = row.task.url,
|
||||||
tags = row.task.categories,
|
|
||||||
priority = priorityFromICal(row.task.priority),
|
priority = priorityFromICal(row.task.priority),
|
||||||
status = row.task.status,
|
status = row.task.status,
|
||||||
percentComplete = row.task.percentComplete,
|
percentComplete = row.task.percentComplete,
|
||||||
@@ -80,7 +79,6 @@ object RoomTaskMapper {
|
|||||||
description = task.description,
|
description = task.description,
|
||||||
location = task.location,
|
location = task.location,
|
||||||
url = task.url,
|
url = task.url,
|
||||||
tags = task.categories,
|
|
||||||
priority = priorityFromICal(task.priority),
|
priority = priorityFromICal(task.priority),
|
||||||
status = task.status,
|
status = task.status,
|
||||||
percentComplete = task.percentComplete,
|
percentComplete = task.percentComplete,
|
||||||
|
|||||||
+34
-85
@@ -33,9 +33,6 @@ internal const val SPLIT_COUNT_CEILING = 100_000
|
|||||||
|
|
||||||
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
||||||
|
|
||||||
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
|
|
||||||
private const val ACK_CACHE_SIZE = 512
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [TasksDataSource] over Agendula's own Room store.
|
* [TasksDataSource] over Agendula's own Room store.
|
||||||
*
|
*
|
||||||
@@ -107,23 +104,13 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
override fun exportTasks(listId: Long): List<ExportTask> =
|
override fun exportTasks(listId: Long): List<ExportTask> =
|
||||||
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
||||||
|
|
||||||
override fun reminders(): Map<Long, List<TaskReminder>> {
|
override fun reminders(): Map<Long, List<TaskReminder>> =
|
||||||
val stored = alarms.all().groupBy({ it.taskId }) { it.asReminder().copy(acknowledgedAt = acknowledged(it)) }
|
alarms.all().groupBy({ it.taskId }) {
|
||||||
// Turned off elsewhere: present and empty, so nothing falls back to a default.
|
TaskReminder(
|
||||||
return stored + alarms.remindersOff().filterNot(stored::containsKey).associateWith { emptyList() }
|
minutesBefore = it.minutesBefore,
|
||||||
}
|
fromStart = it.reference == AlarmReference.START,
|
||||||
|
)
|
||||||
/** `raw` → its `ACKNOWLEDGED`, so a scheduler pass does not re-parse every alarm. */
|
|
||||||
private val acknowledgements = object : LinkedHashMap<String, Instant?>(64, 0.75f, true) {
|
|
||||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Instant?>) = size > ACK_CACHE_SIZE
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun acknowledged(row: TaskAlarmEntity): Instant? {
|
|
||||||
val raw = row.raw ?: return null
|
|
||||||
return synchronized(acknowledgements) {
|
|
||||||
if (raw in acknowledgements) acknowledgements[raw] else row.acknowledgedAt().also { acknowledgements[raw] = it }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun alarms(): Map<Long, TaskReminder> =
|
override fun alarms(): Map<Long, TaskReminder> =
|
||||||
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
|
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
|
||||||
@@ -186,44 +173,20 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
|
|
||||||
// --- writes ---------------------------------------------------------------
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long {
|
override fun insertTask(form: TaskForm): Long {
|
||||||
if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}")
|
if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}")
|
||||||
val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone())
|
val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone())
|
||||||
var id = 0L
|
return tasks.insert(TaskFormWriter.withRule(entity, form.rrule)).also { writes.onWritten(setOf(form.listId)) }
|
||||||
tx {
|
|
||||||
id = tasks.insert(TaskFormWriter.withRule(entity, form.rrule))
|
|
||||||
reminders?.let { alarms.syncForTask(id, it) }
|
|
||||||
writes.onWritten(setOf(form.listId))
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
override fun updateTask(taskId: Long, form: TaskForm) {
|
||||||
* [leads] as [taskId]'s before-due reminders, inside the caller's
|
|
||||||
* transaction. A start-relative row is kept: the editor never shows one.
|
|
||||||
*
|
|
||||||
* ⚠️ Part of the task write rather than a second call after it. Once
|
|
||||||
* 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 dueLeads(taskId: Long): List<Int> =
|
|
||||||
alarms.forTask(taskId).filter { it.reference == AlarmReference.DUE }.map { it.minutesBefore }.sorted()
|
|
||||||
|
|
||||||
private fun writeDueReminders(taskId: Long, leads: List<Int>?) {
|
|
||||||
if (leads == null) return
|
|
||||||
val kept = alarms.forTask(taskId).filter { it.reference == AlarmReference.START }.map { it.asReminder() }
|
|
||||||
alarms.syncForTask(taskId, kept + leads.distinct().sorted().map { TaskReminder(it) })
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) = tx {
|
|
||||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
|
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
|
||||||
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
|
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
|
||||||
writeDueReminders(taskId, dueReminders)
|
|
||||||
if (current.masterId == null && edited.listId != current.listId) {
|
if (current.masterId == null && edited.listId != current.listId) {
|
||||||
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
||||||
move(current, TaskFormWriter.withRule(edited, form.rrule))
|
database.runInTransaction { move(current, TaskFormWriter.withRule(edited, form.rrule)) }
|
||||||
writes.onWritten(setOf(current.listId, edited.listId))
|
writes.onWritten(setOf(current.listId, edited.listId))
|
||||||
return@tx
|
return
|
||||||
}
|
}
|
||||||
// ⚠️ An override's list and parent are the master's, the rule
|
// ⚠️ An override's list and parent are the master's, the rule
|
||||||
// [updateInstance] states and this path has to keep. An overridden
|
// [updateInstance] states and this path has to keep. An overridden
|
||||||
@@ -257,20 +220,8 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
plan.tombstone?.let { tasks.insert(it) }
|
plan.tombstone?.let { tasks.insert(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) = tx {
|
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = tx {
|
||||||
val master = seriesMaster(seriesId)
|
val master = seriesMaster(seriesId)
|
||||||
// ⚠️ The overrides too, where they still carry the series' reminders.
|
|
||||||
// A fork copies them as a snapshot — completing one occurrence is
|
|
||||||
// enough — and each override uploads its own VALARMs, so leaving them
|
|
||||||
// would keep ringing those occurrences at the old lead everywhere. One
|
|
||||||
// whose reminders were set for that occurrence alone keeps them.
|
|
||||||
if (dueReminders != null) {
|
|
||||||
val before = dueLeads(master.id)
|
|
||||||
tasks.overrides(master.id)
|
|
||||||
.filter { dueLeads(it.id) == before }
|
|
||||||
.forEach { writeDueReminders(it.id, dueReminders) }
|
|
||||||
}
|
|
||||||
writeDueReminders(master.id, dueReminders)
|
|
||||||
val (start, due) = resolvedTimes(master, occurrenceStart)
|
val (start, due) = resolvedTimes(master, occurrenceStart)
|
||||||
val edited = TaskFormWriter.seriesEdit(master, start, due, form, clock.now(), zone())
|
val edited = TaskFormWriter.seriesEdit(master, start, due, form, clock.now(), zone())
|
||||||
// A moved or re-ruled series no longer generates the anchors its
|
// A moved or re-ruled series no longer generates the anchors its
|
||||||
@@ -286,14 +237,14 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
writes.onWritten(setOf(master.listId, edited.listId))
|
writes.onWritten(setOf(master.listId, edited.listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long {
|
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long {
|
||||||
var created = seriesId
|
var created = seriesId
|
||||||
tx {
|
tx {
|
||||||
val master = seriesMaster(seriesId)
|
val master = seriesMaster(seriesId)
|
||||||
val spec = master.recurrenceSpec()
|
val spec = master.recurrenceSpec()
|
||||||
val anchor = master.recurrenceAnchor
|
val anchor = master.recurrenceAnchor
|
||||||
if (spec == null || anchor == null || occurrenceStart <= anchor) {
|
if (spec == null || anchor == null || occurrenceStart <= anchor) {
|
||||||
updateSeries(seriesId, occurrenceStart, form, dueReminders)
|
updateSeries(seriesId, occurrenceStart, form)
|
||||||
return@tx
|
return@tx
|
||||||
}
|
}
|
||||||
val now = clock.now()
|
val now = clock.now()
|
||||||
@@ -336,9 +287,7 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
else TaskFormWriter.shiftValues(laterExceptions, delta, master.timezone ?: zone())
|
else TaskFormWriter.shiftValues(laterExceptions, delta, master.timezone ?: zone())
|
||||||
val fresh = TaskFormWriter.withRule(applied, rule).let { if (it.rrule != null) it.copy(exdate = exceptions) else it }
|
val fresh = TaskFormWriter.withRule(applied, rule).let { if (it.rrule != null) it.copy(exdate = exceptions) else it }
|
||||||
val newId = tasks.insert(fresh)
|
val newId = tasks.insert(fresh)
|
||||||
// A new UID, so a new resource: the alarms are copies, not the same ones.
|
alarms.forTask(master.id).forEach { alarms.insert(it.copy(id = 0, taskId = newId)) }
|
||||||
alarms.copyToTask(master.id, newId, sameResource = false)
|
|
||||||
writeDueReminders(newId, dueReminders)
|
|
||||||
|
|
||||||
val unmoved = fresh.dtstart == start && fresh.due == due && sameRule
|
val unmoved = fresh.dtstart == start && fresh.due == due && sameRule
|
||||||
tasks.overrides(master.id)
|
tasks.overrides(master.id)
|
||||||
@@ -403,7 +352,7 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
* which is the model least compatible with sync; the override shares its
|
* which is the model least compatible with sync; the override shares its
|
||||||
* master's UID, which is exactly what makes it an override.
|
* master's UID, which is exactly what makes it an override.
|
||||||
*/
|
*/
|
||||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) = tx {
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||||
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
|
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
|
||||||
val now = clock.now()
|
val now = clock.now()
|
||||||
val existing = tasks.override(taskId, occurrenceStart)
|
val existing = tasks.override(taskId, occurrenceStart)
|
||||||
@@ -412,9 +361,8 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
// master's, whatever the form was carrying.
|
// master's, whatever the form was carrying.
|
||||||
val edited = TaskFormWriter.apply(existing, form, now, zone())
|
val edited = TaskFormWriter.apply(existing, form, now, zone())
|
||||||
tasks.update(edited.copy(listId = master.listId, parentId = master.parentId))
|
tasks.update(edited.copy(listId = master.listId, parentId = master.parentId))
|
||||||
writeDueReminders(existing.id, dueReminders)
|
|
||||||
writes.onWritten(setOf(master.listId))
|
writes.onWritten(setOf(master.listId))
|
||||||
return@tx
|
return
|
||||||
}
|
}
|
||||||
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||||
val fork = TaskFormWriter.apply(
|
val fork = TaskFormWriter.apply(
|
||||||
@@ -426,27 +374,28 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
// The list and parent come from the master: moving one occurrence between
|
// The list and parent come from the master: moving one occurrence between
|
||||||
// lists or parents is not something the override model expresses.
|
// lists or parents is not something the override model expresses.
|
||||||
val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId))
|
val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId))
|
||||||
// The occurrence starts out with the series' reminders and then takes
|
alarms.replaceAllForTask(id, alarms.forTask(taskId))
|
||||||
// the form's — the master's own are never touched.
|
|
||||||
alarms.copyToTask(taskId, id, sameResource = true)
|
|
||||||
writeDueReminders(id, dueReminders)
|
|
||||||
writes.onWritten(setOf(master.listId))
|
writes.onWritten(setOf(master.listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Through [setReminders], so a synced task is owed the PUT and the others keep their identity. */
|
|
||||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
|
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
|
||||||
val current = alarms.forTask(taskId).map { it.asReminder() }
|
alarms.replaceEditable(
|
||||||
val editable = current.indexOfLast { !it.fromStart }
|
taskId,
|
||||||
val others = if (editable < 0) current else current.filterIndexed { index, _ -> index != editable }
|
minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) },
|
||||||
setReminders(taskId, others + listOfNotNull(minutesBeforeDue?.let { TaskReminder(it) }))
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = tx {
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
|
||||||
if (!alarms.syncForTask(taskId, reminders)) return@tx
|
alarms.replaceAllForTask(
|
||||||
// The reminders are part of the VTODO now, so changing them is an edit.
|
taskId,
|
||||||
val current = tasks.entity(taskId) ?: return@tx
|
reminders.map {
|
||||||
tasks.update(current.copy(isDirty = true, lastModified = clock.now()))
|
TaskAlarmEntity(
|
||||||
writes.onWritten(setOf(current.listId))
|
taskId = taskId,
|
||||||
|
minutesBefore = it.minutesBefore,
|
||||||
|
reference = if (it.fromStart) AlarmReference.START else AlarmReference.DUE,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
||||||
@@ -498,7 +447,7 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||||
val id = tasks.insert(write(newOverride(master, taskId, occurrenceStart, start, due)))
|
val id = tasks.insert(write(newOverride(master, taskId, occurrenceStart, start, due)))
|
||||||
alarms.copyToTask(taskId, id, sameResource = true)
|
alarms.replaceAllForTask(id, alarms.forTask(taskId))
|
||||||
writes.onWritten(setOf(master.listId))
|
writes.onWritten(setOf(master.listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import androidx.room.Update
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/** Reads and writes over `task_alarms`. */
|
/** Reads and writes over `task_alarms`. */
|
||||||
@Dao
|
@Dao
|
||||||
@@ -21,10 +16,6 @@ interface TaskAlarmDao {
|
|||||||
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId ORDER BY id")
|
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId ORDER BY id")
|
||||||
fun forTask(taskId: Long): List<TaskAlarmEntity>
|
fun forTask(taskId: Long): List<TaskAlarmEntity>
|
||||||
|
|
||||||
/** The reminders of several tasks in one query — one resource's rows, for an upload. */
|
|
||||||
@Query("SELECT * FROM task_alarms WHERE task_id IN (:taskIds) ORDER BY id")
|
|
||||||
fun forTasks(taskIds: List<Long>): List<TaskAlarmEntity>
|
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
fun insert(alarm: TaskAlarmEntity): Long
|
fun insert(alarm: TaskAlarmEntity): Long
|
||||||
|
|
||||||
@@ -34,121 +25,28 @@ interface TaskAlarmDao {
|
|||||||
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
|
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
|
||||||
fun deleteForTask(taskId: Long): Int
|
fun deleteForTask(taskId: Long): Int
|
||||||
|
|
||||||
@Update
|
|
||||||
fun update(alarm: TaskAlarmEntity)
|
|
||||||
|
|
||||||
/** See [TaskEntity.remindersOff]. */
|
|
||||||
@Query("UPDATE tasks SET reminders_off = :off WHERE id = :taskId")
|
|
||||||
fun setRemindersOff(taskId: Long, off: Boolean): Int
|
|
||||||
|
|
||||||
/** The tasks whose alarms were turned off elsewhere — "none", not "the default". */
|
|
||||||
@Query("SELECT id FROM tasks WHERE reminders_off = 1")
|
|
||||||
fun remindersOff(): List<Long>
|
|
||||||
|
|
||||||
/** Set the task's only reminder, or clear every one with `null`. */
|
/** Set the task's only reminder, or clear every one with `null`. */
|
||||||
@Transaction
|
@Transaction
|
||||||
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) {
|
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) = replaceAllForTask(taskId, listOfNotNull(alarm))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace every reminder on the task. The rows that land are always new ones —
|
||||||
|
* ids are cleared so alarms lifted off another task (forking an occurrence
|
||||||
|
* copies the master's) insert instead of colliding.
|
||||||
|
*/
|
||||||
|
@Transaction
|
||||||
|
fun replaceAllForTask(taskId: Long, alarms: List<TaskAlarmEntity>) {
|
||||||
deleteForTask(taskId)
|
deleteForTask(taskId)
|
||||||
|
alarms.forEach { insert(it.copy(id = 0, taskId = taskId)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the task's editable reminder — its last before-due one — or clear it
|
||||||
|
* with `null`. Start-relative and additional reminders stay as they are.
|
||||||
|
*/
|
||||||
|
@Transaction
|
||||||
|
fun replaceEditable(taskId: Long, alarm: TaskAlarmEntity?) {
|
||||||
|
forTask(taskId).lastOrNull { it.reference == AlarmReference.DUE }?.let { delete(it.id) }
|
||||||
alarm?.let { insert(it.copy(id = 0, taskId = taskId)) }
|
alarm?.let { insert(it.copy(id = 0, taskId = taskId)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Make the task's reminders [wanted], touching only what differs.
|
|
||||||
*
|
|
||||||
* ⚠️ A diff, not a delete-and-reinsert. A row that is still wanted keeps its
|
|
||||||
* [TaskAlarmEntity.uid] and its [TaskAlarmEntity.raw] — which [TaskReminder]
|
|
||||||
* carries neither of, so a rewrite from it would give every save a new
|
|
||||||
* alarm identity on the server and throw away another client's `VALARM` the
|
|
||||||
* user never touched.
|
|
||||||
*
|
|
||||||
* Duplicates count: two identical leads are two rows.
|
|
||||||
*
|
|
||||||
* @return whether anything changed.
|
|
||||||
*/
|
|
||||||
@Transaction
|
|
||||||
fun syncForTask(taskId: Long, wanted: List<TaskReminder>): Boolean {
|
|
||||||
val unmatched = wanted.map { it.lead }.toMutableList()
|
|
||||||
var changed = false
|
|
||||||
forTask(taskId).forEach { row ->
|
|
||||||
if (!unmatched.remove(row.asReminder())) {
|
|
||||||
delete(row.id)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unmatched.forEach {
|
|
||||||
insert(it.toAlarmEntity(taskId))
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
// A reminder of its own again, so the "turned off elsewhere" no longer holds.
|
|
||||||
if (wanted.isNotEmpty()) setRemindersOff(taskId, false)
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the task's reminders with what a download carried.
|
|
||||||
*
|
|
||||||
* Incoming alarms are matched to the rows already here by uid first, then by
|
|
||||||
* lead, so a reminder the server merely re-described keeps its row rather
|
|
||||||
* than being churned into a new one.
|
|
||||||
*
|
|
||||||
* A download that takes away every alarm the task had marks it
|
|
||||||
* [TaskEntity.remindersOff]; one that carries alarms clears that.
|
|
||||||
*/
|
|
||||||
@Transaction
|
|
||||||
fun replaceFromServer(taskId: Long, incoming: List<TaskAlarmEntity>) {
|
|
||||||
val existing = forTask(taskId).toMutableList()
|
|
||||||
val hadAny = existing.isNotEmpty()
|
|
||||||
val placed = incoming.map { alarm ->
|
|
||||||
val match = existing.firstOrNull { it.uid == alarm.uid }
|
|
||||||
?: existing.firstOrNull { it.asReminder() == alarm.asReminder() }
|
|
||||||
match?.let(existing::remove)
|
|
||||||
alarm.copy(id = match?.id ?: 0, taskId = taskId)
|
|
||||||
}
|
|
||||||
existing.forEach { delete(it.id) }
|
|
||||||
placed.forEach { if (it.id == 0L) insert(it) else update(it) }
|
|
||||||
when {
|
|
||||||
incoming.isNotEmpty() -> setRemindersOff(taskId, false)
|
|
||||||
hadAny -> setRemindersOff(taskId, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy [fromTaskId]'s reminders onto [toTaskId].
|
|
||||||
*
|
|
||||||
* [sameResource] is for an override forked inside the same series — the
|
|
||||||
* same `VTODO` UID, so the alarms keep their identity. Anything else is a
|
|
||||||
* different task on the server and gets fresh uids: two tasks naming one
|
|
||||||
* alarm is an identity no other client expects.
|
|
||||||
*/
|
|
||||||
@Transaction
|
|
||||||
fun copyToTask(fromTaskId: Long, toTaskId: Long, sameResource: Boolean) {
|
|
||||||
forTask(fromTaskId).forEach { insert(if (sameResource) it.copy(id = 0, taskId = toTaskId) else it.freshCopy(toTaskId)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the row means to the scheduler and the editor, without its identity. */
|
|
||||||
fun TaskAlarmEntity.asReminder() = TaskReminder(minutesBefore, reference == AlarmReference.START)
|
|
||||||
|
|
||||||
/** The inverse of [asReminder]: a new row, with an identity of its own. */
|
|
||||||
fun TaskReminder.toAlarmEntity(taskId: Long) = TaskAlarmEntity(
|
|
||||||
taskId = taskId,
|
|
||||||
minutesBefore = minutesBefore,
|
|
||||||
reference = if (fromStart) AlarmReference.START else AlarmReference.DUE,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The same reminder on another task: what it means, never who it is on the server. */
|
|
||||||
fun TaskAlarmEntity.freshCopy(taskId: Long) =
|
|
||||||
TaskAlarmEntity(taskId = taskId, minutesBefore = minutesBefore, reference = reference, message = message)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `ACKNOWLEDGED` stamp of the `VALARM` the row was read from, if any.
|
|
||||||
*
|
|
||||||
* Read off [TaskAlarmEntity.raw] rather than kept in a column: it is the other
|
|
||||||
* client's bookkeeping, and it changes only when a download replaces the row.
|
|
||||||
*/
|
|
||||||
fun TaskAlarmEntity.acknowledgedAt(): Instant? {
|
|
||||||
val text = raw ?: return null
|
|
||||||
if (!text.contains("ACKNOWLEDGED", ignoreCase = true)) return null
|
|
||||||
val property = runCatching { ICalParser.parse(text) }.getOrNull()?.property("ACKNOWLEDGED") ?: return null
|
|
||||||
return (ICalValues.parseTime(property) as? ICalValues.TimeValue.Timed)?.instant
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
|
||||||
import de.jeanlucmakiola.agendula.domain.toICal
|
import de.jeanlucmakiola.agendula.domain.toICal
|
||||||
import kotlin.time.Duration.Companion.days
|
import kotlin.time.Duration.Companion.days
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
@@ -51,7 +50,6 @@ object TaskFormWriter {
|
|||||||
description = form.description?.trim()?.ifBlank { null },
|
description = form.description?.trim()?.ifBlank { null },
|
||||||
location = form.location?.trim()?.ifBlank { null },
|
location = form.location?.trim()?.ifBlank { null },
|
||||||
url = form.url?.trim()?.ifBlank { null },
|
url = form.url?.trim()?.ifBlank { null },
|
||||||
categories = normalizeTags(form.tags),
|
|
||||||
priority = form.priority.toICal(),
|
priority = form.priority.toICal(),
|
||||||
percentComplete = percent,
|
percentComplete = percent,
|
||||||
status = statusFor(percent, current.status),
|
status = statusFor(percent, current.status),
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import androidx.room.migration.Migration
|
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agendula's own task store. Four tables, designed from
|
* Agendula's own task store. Four tables, designed from
|
||||||
@@ -22,7 +18,7 @@ import kotlin.time.Instant
|
|||||||
TaskEntity::class,
|
TaskEntity::class,
|
||||||
TaskAlarmEntity::class,
|
TaskAlarmEntity::class,
|
||||||
],
|
],
|
||||||
version = 3,
|
version = 1,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
@@ -34,144 +30,5 @@ abstract class TasksDatabase : RoomDatabase() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val NAME = "agendula-tasks.db"
|
const val NAME = "agendula-tasks.db"
|
||||||
|
|
||||||
/**
|
|
||||||
* v2 adds `categories`. Until then `CATEGORIES` lived in the residue, and
|
|
||||||
* an unchanged task is never downloaded again, so the values are lifted
|
|
||||||
* out here. Not marked dirty: nothing changed on the server's side.
|
|
||||||
*/
|
|
||||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE tasks ADD COLUMN categories TEXT NOT NULL DEFAULT ''")
|
|
||||||
val lifted = mutableListOf<Pair<Long, CategoriesLift.Result>>()
|
|
||||||
db.query(
|
|
||||||
"SELECT id, unknown_properties FROM tasks WHERE unknown_properties LIKE '%CATEGORIES%'",
|
|
||||||
).use { cursor ->
|
|
||||||
while (cursor.moveToNext()) {
|
|
||||||
val result = CategoriesLift.lift(cursor.getString(1))
|
|
||||||
if (result.categories.isNotEmpty()) lifted += cursor.getLong(0) to result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lifted.forEach { (id, result) ->
|
|
||||||
val categories = CategoriesConverter.toText(result.categories)
|
|
||||||
if (result.claimed) {
|
|
||||||
db.execSQL(
|
|
||||||
"UPDATE tasks SET categories = ?, unknown_properties = ? WHERE id = ?",
|
|
||||||
arrayOf<Any?>(categories, result.residue, id),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
db.execSQL("UPDATE tasks SET categories = ? WHERE id = ?", arrayOf<Any?>(categories, id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* v3 syncs reminders as `VALARM`s, which takes two things the rows did not
|
|
||||||
* have: an identity on the wire (`uid`) and the original text (`raw`).
|
|
||||||
*
|
|
||||||
* Claimable `VALARM`s are lifted out of the residue for every row, synced
|
|
||||||
* or not — an unchanged task is never downloaded again, and an imported
|
|
||||||
* one never was. Not marked dirty: the server already has them. Where a
|
|
||||||
* task held the same lead in both stores (an override forked before v3
|
|
||||||
* copies the master's alarms *and* carries its `VALARM`s), the lifted
|
|
||||||
* one wins: it is the one with an identity.
|
|
||||||
*
|
|
||||||
* Reminders that so far lived only on this device are pushed once, by
|
|
||||||
* marking their tasks dirty — bounded to open tasks in synced lists whose
|
|
||||||
* reminder can still fire, so the upgrade is not a burst of PUTs landing
|
|
||||||
* on other clients as long-missed alarms.
|
|
||||||
*
|
|
||||||
* `tasks.reminders_off` starts at 0 everywhere: until now nothing could
|
|
||||||
* have turned a synced task's alarms off.
|
|
||||||
*/
|
|
||||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE task_alarms ADD COLUMN uid TEXT NOT NULL DEFAULT ''")
|
|
||||||
db.execSQL("ALTER TABLE task_alarms ADD COLUMN raw TEXT")
|
|
||||||
db.execSQL("ALTER TABLE tasks ADD COLUMN reminders_off INTEGER NOT NULL DEFAULT 0")
|
|
||||||
// A version-4 UUID per row, in one statement: `randomblob` is
|
|
||||||
// evaluated per row, and the upgrade runs on app start.
|
|
||||||
db.execSQL(
|
|
||||||
"""
|
|
||||||
UPDATE task_alarms SET uid =
|
|
||||||
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
|
|
||||||
substr(lower(hex(randomblob(2))), 2) || '-' ||
|
|
||||||
substr('89ab', 1 + (abs(random()) % 4), 1) || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
|
||||||
lower(hex(randomblob(6)))
|
|
||||||
""".trimIndent(),
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Lifted(val taskId: Long, val result: AlarmLift.Result)
|
|
||||||
val lifted = mutableListOf<Lifted>()
|
|
||||||
db.query(
|
|
||||||
"SELECT id, dtstart, due, is_all_day, rrule, rdate, unknown_properties FROM tasks " +
|
|
||||||
"WHERE unknown_properties LIKE '%VALARM%'",
|
|
||||||
).use { c ->
|
|
||||||
while (c.moveToNext()) {
|
|
||||||
val task = AlarmLift.Task(
|
|
||||||
dtstart = if (c.isNull(1)) null else Instant.fromEpochMilliseconds(c.getLong(1)),
|
|
||||||
due = if (c.isNull(2)) null else Instant.fromEpochMilliseconds(c.getLong(2)),
|
|
||||||
isAllDay = c.getInt(3) != 0,
|
|
||||||
recurring = !c.isNull(4) || !c.isNull(5),
|
|
||||||
)
|
|
||||||
val result = AlarmLift.lift(c.getString(6), task)
|
|
||||||
if (result.alarms.isNotEmpty()) lifted += Lifted(c.getLong(0), result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lifted.forEach { (taskId, result) ->
|
|
||||||
val local = mutableListOf<Triple<Long, Int, String>>()
|
|
||||||
db.query(
|
|
||||||
"SELECT id, minutes_before, reference FROM task_alarms WHERE task_id = ?",
|
|
||||||
arrayOf<Any?>(taskId),
|
|
||||||
).use { c -> while (c.moveToNext()) local += Triple(c.getLong(0), c.getInt(1), c.getString(2)) }
|
|
||||||
result.alarms.forEach { alarm ->
|
|
||||||
local.firstOrNull { it.second == alarm.minutesBefore && it.third == alarm.reference.name }
|
|
||||||
?.let { duplicate ->
|
|
||||||
local.remove(duplicate)
|
|
||||||
db.execSQL("DELETE FROM task_alarms WHERE id = ?", arrayOf<Any?>(duplicate.first))
|
|
||||||
}
|
|
||||||
db.execSQL(
|
|
||||||
"INSERT INTO task_alarms (task_id, minutes_before, reference, uid, raw) VALUES (?, ?, ?, ?, ?)",
|
|
||||||
arrayOf<Any?>(
|
|
||||||
taskId,
|
|
||||||
alarm.minutesBefore,
|
|
||||||
alarm.reference.name,
|
|
||||||
alarm.uid ?: UUID.randomUUID().toString(),
|
|
||||||
alarm.raw,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
db.execSQL("UPDATE tasks SET unknown_properties = ? WHERE id = ?", arrayOf<Any?>(result.residue, taskId))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ⚠️ `raw IS NULL` is "never on the server": everything lifted
|
|
||||||
// above carries its original. Status 0/1 are NEEDS-ACTION and
|
|
||||||
// IN-PROCESS (`ICalStatus`).
|
|
||||||
//
|
|
||||||
// ⚠️ Never a list the upload cannot write to. A dirty row is
|
|
||||||
// also one the download will not overwrite, so marking a task in
|
|
||||||
// a read-only share — or a confidential one, which the upload
|
|
||||||
// skips in a shared collection — would freeze it for good: never
|
|
||||||
// sent, and the owner's edits never taken. Classification 2 is
|
|
||||||
// CONFIDENTIAL.
|
|
||||||
//
|
|
||||||
// LAST-MODIFIED moves with it: the body changes, and a client
|
|
||||||
// reconciling by that stamp would otherwise keep its stale copy.
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
db.execSQL(
|
|
||||||
"""
|
|
||||||
UPDATE tasks SET is_dirty = 1, last_modified = ?
|
|
||||||
WHERE is_deleted = 0
|
|
||||||
AND status IN (0, 1)
|
|
||||||
AND (classification IS NULL OR classification != 2)
|
|
||||||
AND list_id IN (SELECT id FROM task_lists WHERE account_id IS NOT NULL AND is_read_only = 0)
|
|
||||||
AND (rrule IS NOT NULL OR rdate IS NOT NULL OR due > ?)
|
|
||||||
AND id IN (SELECT task_id FROM task_alarms WHERE raw IS NULL)
|
|
||||||
""".trimIndent(),
|
|
||||||
arrayOf<Any?>(now, now),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.toAlarmEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
|
||||||
import de.jeanlucmakiola.agendula.domain.toICal
|
import de.jeanlucmakiola.agendula.domain.toICal
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -199,7 +199,7 @@ class ExternalImport @Inject constructor(
|
|||||||
var alarmCount = 0
|
var alarmCount = 0
|
||||||
for ((sourceTaskId, reminders) in snapshot.alarms) {
|
for ((sourceTaskId, reminders) in snapshot.alarms) {
|
||||||
val taskId = taskIds[sourceTaskId] ?: continue
|
val taskId = taskIds[sourceTaskId] ?: continue
|
||||||
reminders.forEach { alarmDao.insert(it.toAlarmEntity(taskId)) }
|
reminders.forEach { alarmDao.insert(it.toEntity(taskId)) }
|
||||||
alarmCount += reminders.size
|
alarmCount += reminders.size
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +213,6 @@ class ExternalImport @Inject constructor(
|
|||||||
description = description,
|
description = description,
|
||||||
location = location,
|
location = location,
|
||||||
url = url,
|
url = url,
|
||||||
categories = normalizeTags(tags),
|
|
||||||
status = status,
|
status = status,
|
||||||
percentComplete = percentComplete,
|
percentComplete = percentComplete,
|
||||||
completedAt = completedAt,
|
completedAt = completedAt,
|
||||||
@@ -227,6 +226,12 @@ class ExternalImport @Inject constructor(
|
|||||||
lastModified = lastModified,
|
lastModified = lastModified,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun TaskReminder.toEntity(taskId: Long) = TaskAlarmEntity(
|
||||||
|
taskId = taskId,
|
||||||
|
minutesBefore = minutesBefore,
|
||||||
|
reference = if (fromStart) AlarmReference.START else AlarmReference.DUE,
|
||||||
|
)
|
||||||
|
|
||||||
private fun verify(written: TransferCounts, before: TransferCounts) {
|
private fun verify(written: TransferCounts, before: TransferCounts) {
|
||||||
val after = tableCounts()
|
val after = tableCounts()
|
||||||
check(after.lists - before.lists == written.lists) {
|
check(after.lists - before.lists == written.lists) {
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
|
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
|
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.freshCopy
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -67,11 +65,7 @@ interface IcsImportStore {
|
|||||||
fun <T> transaction(block: () -> T): T
|
fun <T> transaction(block: () -> T): T
|
||||||
fun createList(name: String, color: Int): Long
|
fun createList(name: String, color: Int): Long
|
||||||
fun hasUid(listId: Long, uid: String): Boolean
|
fun hasUid(listId: Long, uid: String): Boolean
|
||||||
|
|
||||||
/** Whether any list holds [uid] — the same file imported somewhere else before. */
|
|
||||||
fun hasUidAnywhere(uid: String): Boolean
|
|
||||||
fun insert(row: TaskEntity): Long
|
fun insert(row: TaskEntity): Long
|
||||||
fun insertAlarms(taskId: Long, alarms: List<TaskAlarmEntity>)
|
|
||||||
fun masterByUid(listId: Long, uid: String): TaskEntity?
|
fun masterByUid(listId: Long, uid: String): TaskEntity?
|
||||||
fun setParent(taskId: Long, parentId: Long?)
|
fun setParent(taskId: Long, parentId: Long?)
|
||||||
}
|
}
|
||||||
@@ -82,9 +76,9 @@ interface IcsImportStore {
|
|||||||
*
|
*
|
||||||
* Goes through the same [CalendarResource] / [VTodoMapper] path CalDAV sync
|
* Goes through the same [CalendarResource] / [VTodoMapper] path CalDAV sync
|
||||||
* uses, so recurrence, `RECURRENCE-ID` overrides, `RELATED-TO` parents and every
|
* uses, so recurrence, `RECURRENCE-ID` overrides, `RELATED-TO` parents and every
|
||||||
* property the mapper does not claim survive as they would on a download. A
|
* property the mapper does not claim (`VALARM`s included) survive as they would
|
||||||
* `VALARM` the mapper claims becomes a reminder on the task, exactly as a
|
* on a download. `VALARM`s stay in the residue: like sync, the import does not
|
||||||
* download does; the rest stay in the residue.
|
* turn them into local reminders.
|
||||||
*
|
*
|
||||||
* Tasks whose UID the target list already holds are skipped, so importing the
|
* Tasks whose UID the target list already holds are skipped, so importing the
|
||||||
* same file twice is harmless.
|
* same file twice is harmless.
|
||||||
@@ -206,17 +200,8 @@ class IcsImport @Inject constructor(
|
|||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// ⚠️ The same file in a second list is a second task carrying
|
|
||||||
// the first one's alarm identities. Those are what clients
|
|
||||||
// track a dismissal by, so the copy's alarms are new ones.
|
|
||||||
val copied = store.hasUidAnywhere(task.uid)
|
|
||||||
fun alarmsOf(mapped: VTodoMapper.Mapped) =
|
|
||||||
if (copied) mapped.alarms.map { it.freshCopy(0) } else mapped.alarms
|
|
||||||
val masterId = store.insert(task.master.entity.forImport(listId, masterId = null))
|
val masterId = store.insert(task.master.entity.forImport(listId, masterId = null))
|
||||||
store.insertAlarms(masterId, alarmsOf(task.master))
|
task.overrides.forEach { store.insert(it.entity.forImport(listId, masterId)) }
|
||||||
task.overrides.forEach {
|
|
||||||
store.insertAlarms(store.insert(it.entity.forImport(listId, masterId)), alarmsOf(it))
|
|
||||||
}
|
|
||||||
task.master.parentUid?.let { parents += masterId to it }
|
task.master.parentUid?.let { parents += masterId to it }
|
||||||
imported++
|
imported++
|
||||||
}
|
}
|
||||||
@@ -285,15 +270,8 @@ class RoomIcsImportStore(private val database: TasksDatabase) : IcsImportStore {
|
|||||||
arrayOf<Any?>(listId, uid),
|
arrayOf<Any?>(listId, uid),
|
||||||
).use { it.moveToFirst() }
|
).use { it.moveToFirst() }
|
||||||
|
|
||||||
override fun hasUidAnywhere(uid: String): Boolean =
|
|
||||||
database.query("SELECT 1 FROM tasks WHERE uid = ? LIMIT 1", arrayOf<Any?>(uid)).use { it.moveToFirst() }
|
|
||||||
|
|
||||||
override fun insert(row: TaskEntity): Long = database.tasks().insert(row)
|
override fun insert(row: TaskEntity): Long = database.tasks().insert(row)
|
||||||
|
|
||||||
override fun insertAlarms(taskId: Long, alarms: List<TaskAlarmEntity>) {
|
|
||||||
alarms.forEach { database.alarms().insert(it.copy(id = 0, taskId = taskId)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun masterByUid(listId: Long, uid: String): TaskEntity? = database.tasks().byUid(listId, uid)
|
override fun masterByUid(listId: Long, uid: String): TaskEntity? = database.tasks().byUid(listId, uid)
|
||||||
|
|
||||||
override fun setParent(taskId: Long, parentId: Long?) {
|
override fun setParent(taskId: Long, parentId: Long?) {
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ data class Task(
|
|||||||
val description: String?,
|
val description: String?,
|
||||||
val location: String?,
|
val location: String?,
|
||||||
val url: String?,
|
val url: String?,
|
||||||
/** `CATEGORIES`, as the task spells them. */
|
|
||||||
val tags: List<String> = emptyList(),
|
|
||||||
val priority: Priority,
|
val priority: Priority,
|
||||||
val status: TaskStatus,
|
val status: TaskStatus,
|
||||||
val percentComplete: Int?,
|
val percentComplete: Int?,
|
||||||
@@ -163,10 +161,3 @@ fun TaskStatus.toInt(): Int = when (this) {
|
|||||||
TaskStatus.COMPLETED -> ICalStatus.COMPLETED
|
TaskStatus.COMPLETED -> ICalStatus.COMPLETED
|
||||||
TaskStatus.CANCELLED -> ICalStatus.CANCELLED
|
TaskStatus.CANCELLED -> ICalStatus.CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tags as every writer stores them: trimmed, no blanks, each spelling once, order kept. */
|
|
||||||
fun normalizeTags(tags: List<String>): List<String> = tags.map(String::trim).filter(String::isNotEmpty).distinct()
|
|
||||||
|
|
||||||
/** Every tag on [tasks], each spelling once, alphabetically ignoring case. */
|
|
||||||
fun tagsInUse(tasks: Iterable<Task>): List<String> =
|
|
||||||
tasks.flatMap { it.tags }.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER)
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.domain
|
|
||||||
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/** Where an open task's due date falls, relative to today. */
|
|
||||||
enum class DueWindow { OVERDUE, TODAY, UPCOMING, NO_DATE }
|
|
||||||
|
|
||||||
/** How a [SavedFilter] with several tags reads them. */
|
|
||||||
enum class TagMatch { ANY, ALL }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A user-made view over the open tasks. An empty criterion doesn't narrow:
|
|
||||||
* no [due] windows means any due date, no [listIds] any list, no [tags] any tag.
|
|
||||||
* [minPriority] [Priority.NONE] lets every priority through.
|
|
||||||
*
|
|
||||||
* [listsElsewhere] is set when the filter also has lists in another task store.
|
|
||||||
* With none of its lists here it then matches nothing rather than every list.
|
|
||||||
*/
|
|
||||||
data class SavedFilter(
|
|
||||||
val id: Long,
|
|
||||||
val name: String,
|
|
||||||
val due: Set<DueWindow> = emptySet(),
|
|
||||||
val listIds: Set<Long> = emptySet(),
|
|
||||||
val tags: Set<String> = emptySet(),
|
|
||||||
val tagMatch: TagMatch = TagMatch.ANY,
|
|
||||||
val minPriority: Priority = Priority.NONE,
|
|
||||||
val listsElsewhere: Boolean = false,
|
|
||||||
) {
|
|
||||||
/** Whether the filter narrows by list at all. */
|
|
||||||
val anyList: Boolean get() = listIds.isEmpty() && !listsElsewhere
|
|
||||||
|
|
||||||
/** This filter without the list ids missing from [existing], e.g. lists since deleted. */
|
|
||||||
fun withListsIn(existing: Set<Long>): SavedFilter = copy(listIds = listIds intersect existing)
|
|
||||||
|
|
||||||
fun matches(task: Task, todayStart: Instant, todayEnd: Instant): Boolean {
|
|
||||||
if (task.isClosed) return false
|
|
||||||
if (listsElsewhere && listIds.isEmpty()) return false
|
|
||||||
if (listIds.isNotEmpty() && task.listId !in listIds) return false
|
|
||||||
if (task.priority < minPriority) return false
|
|
||||||
if (due.isNotEmpty() && dueWindowOf(task, todayStart, todayEnd) !in due) return false
|
|
||||||
return when {
|
|
||||||
tags.isEmpty() -> true
|
|
||||||
tagMatch == TagMatch.ALL -> task.tags.containsAll(tags)
|
|
||||||
else -> task.tags.any { it in tags }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where [task]'s due falls against local today ([todayStart] to [todayEnd], from
|
|
||||||
* [DayWindow] in [zone]). An all-day due is stored as UTC midnight, so it is read
|
|
||||||
* as its calendar date first rather than compared as a raw instant.
|
|
||||||
*/
|
|
||||||
fun dueWindowOf(
|
|
||||||
task: Task,
|
|
||||||
todayStart: Instant,
|
|
||||||
todayEnd: Instant,
|
|
||||||
zone: ZoneId = ZoneId.systemDefault(),
|
|
||||||
): DueWindow {
|
|
||||||
val due = task.due?.let { if (task.isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
|
|
||||||
return when {
|
|
||||||
due == null -> DueWindow.NO_DATE
|
|
||||||
due < todayStart -> DueWindow.OVERDUE
|
|
||||||
due < todayEnd -> DueWindow.TODAY
|
|
||||||
else -> DueWindow.UPCOMING
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a task created from a [SavedFilter] starts with, so it lands in that filter. */
|
|
||||||
data class NewTaskPreset(
|
|
||||||
val listIds: Set<Long> = emptySet(),
|
|
||||||
val tags: List<String> = emptyList(),
|
|
||||||
val dueDate: LocalDate? = null,
|
|
||||||
val priority: Priority = Priority.NONE,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The preset for a new task in this filter. Due prefers today, then tomorrow;
|
|
||||||
* a filter that admits undated tasks, or only overdue ones, leaves it empty.
|
|
||||||
*/
|
|
||||||
fun SavedFilter.newTaskPreset(today: LocalDate): NewTaskPreset = NewTaskPreset(
|
|
||||||
listIds = listIds,
|
|
||||||
tags = when {
|
|
||||||
tagMatch == TagMatch.ALL -> tags.sorted()
|
|
||||||
else -> listOfNotNull(tags.minOrNull())
|
|
||||||
},
|
|
||||||
dueDate = when {
|
|
||||||
due.isEmpty() || DueWindow.NO_DATE in due -> null
|
|
||||||
DueWindow.TODAY in due -> today
|
|
||||||
DueWindow.UPCOMING in due -> today.plusDays(1)
|
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
priority = minPriority,
|
|
||||||
)
|
|
||||||
@@ -5,16 +5,12 @@ import kotlin.time.Instant
|
|||||||
/** The built-in "smart" lists, computed from due dates rather than membership. */
|
/** The built-in "smart" lists, computed from due dates rather than membership. */
|
||||||
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
|
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
|
||||||
|
|
||||||
/** What a task screen is showing: one real list, a smart list, or a tag. */
|
/** What a task screen is showing: one real list, or a smart list. */
|
||||||
sealed interface TaskFilter {
|
sealed interface TaskFilter {
|
||||||
data class OfList(val listId: Long) : TaskFilter
|
data class OfList(val listId: Long) : TaskFilter
|
||||||
/** Several lists shown together, e.g. one widget covering more than one list. */
|
/** Several lists shown together, e.g. one widget covering more than one list. */
|
||||||
data class OfLists(val listIds: Set<Long>) : TaskFilter
|
data class OfLists(val listIds: Set<Long>) : TaskFilter
|
||||||
data class Smart(val list: SmartList) : TaskFilter
|
data class Smart(val list: SmartList) : TaskFilter
|
||||||
/** Every task carrying [tag], open and completed, across all lists. */
|
|
||||||
data class Tagged(val tag: String) : TaskFilter
|
|
||||||
/** A [SavedFilter] by id; the repository resolves it to its definition. */
|
|
||||||
data class Saved(val id: Long) : TaskFilter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,32 +20,22 @@ sealed interface TaskFilter {
|
|||||||
*/
|
*/
|
||||||
object TaskFiltering {
|
object TaskFiltering {
|
||||||
|
|
||||||
/** [saved] is the definition behind a [TaskFilter.Saved]; without one it matches nothing. */
|
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
|
||||||
fun matches(
|
|
||||||
task: Task,
|
|
||||||
filter: TaskFilter,
|
|
||||||
todayStart: Instant,
|
|
||||||
todayEnd: Instant,
|
|
||||||
saved: SavedFilter? = null,
|
|
||||||
): Boolean =
|
|
||||||
when (filter) {
|
when (filter) {
|
||||||
is TaskFilter.OfList -> task.listId == filter.listId
|
is TaskFilter.OfList -> task.listId == filter.listId
|
||||||
is TaskFilter.OfLists -> task.listId in filter.listIds
|
is TaskFilter.OfLists -> task.listId in filter.listIds
|
||||||
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
|
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
|
||||||
is TaskFilter.Tagged -> filter.tag in task.tags
|
|
||||||
is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether [task] carries every one of [tags]; an empty set matches everything. */
|
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean {
|
||||||
fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags)
|
val due = task.due
|
||||||
|
return when (list) {
|
||||||
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean =
|
|
||||||
when (list) {
|
|
||||||
SmartList.COMPLETED -> task.isCompleted
|
SmartList.COMPLETED -> task.isCompleted
|
||||||
SmartList.ALL -> !task.isClosed
|
SmartList.ALL -> !task.isClosed
|
||||||
SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE
|
SmartList.NO_DATE -> !task.isClosed && due == null
|
||||||
SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE
|
SmartList.OVERDUE -> !task.isClosed && due != null && due < todayStart
|
||||||
SmartList.TODAY -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.TODAY
|
SmartList.TODAY -> !task.isClosed && due != null && due >= todayStart && due < todayEnd
|
||||||
SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING
|
SmartList.UPCOMING -> !task.isClosed && due != null && due >= todayEnd
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ data class TaskForm(
|
|||||||
val reminders: List<Int> = emptyList(),
|
val reminders: List<Int> = emptyList(),
|
||||||
val location: String? = null,
|
val location: String? = null,
|
||||||
val url: String? = null,
|
val url: String? = null,
|
||||||
val tags: List<String> = emptyList(),
|
|
||||||
/** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */
|
/** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */
|
||||||
val rrule: String? = null,
|
val rrule: String? = null,
|
||||||
) {
|
) {
|
||||||
@@ -45,7 +44,7 @@ enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITH
|
|||||||
* a setting; the rest unfold on demand. Declaring these as an enum keeps the
|
* a setting; the rest unfold on demand. Declaring these as an enum keeps the
|
||||||
* disclosure generic, so adding a field later is one entry plus its card.
|
* disclosure generic, so adding a field later is one entry plus its card.
|
||||||
*/
|
*/
|
||||||
enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url, Tags }
|
enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url }
|
||||||
|
|
||||||
/** The optional fields that already carry a value — auto-revealed when editing. */
|
/** The optional fields that already carry a value — auto-revealed when editing. */
|
||||||
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
|
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
|
||||||
@@ -57,5 +56,4 @@ fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
|
|||||||
if (rrule != null) add(TaskFormField.Recurrence)
|
if (rrule != null) add(TaskFormField.Recurrence)
|
||||||
if (!location.isNullOrBlank()) add(TaskFormField.Location)
|
if (!location.isNullOrBlank()) add(TaskFormField.Location)
|
||||||
if (!url.isNullOrBlank()) add(TaskFormField.Url)
|
if (!url.isNullOrBlank()) add(TaskFormField.Url)
|
||||||
if (tags.isNotEmpty()) add(TaskFormField.Tags)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ object TaskSections {
|
|||||||
|
|
||||||
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
|
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
|
||||||
if (task.isCompleted) return TaskSection.COMPLETED
|
if (task.isCompleted) return TaskSection.COMPLETED
|
||||||
return when (dueWindowOf(task, todayStart, todayEnd)) {
|
val due = task.due ?: return TaskSection.NO_DATE
|
||||||
DueWindow.OVERDUE -> TaskSection.OVERDUE
|
return when {
|
||||||
DueWindow.TODAY -> TaskSection.TODAY
|
due < todayStart -> TaskSection.OVERDUE
|
||||||
DueWindow.UPCOMING -> TaskSection.UPCOMING
|
due < todayEnd -> TaskSection.TODAY
|
||||||
DueWindow.NO_DATE -> TaskSection.NO_DATE
|
else -> TaskSection.UPCOMING
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ data class ExportTask(
|
|||||||
val rdate: String?,
|
val rdate: String?,
|
||||||
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
|
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
|
||||||
val parentId: Long?,
|
val parentId: Long?,
|
||||||
val tags: List<String> = emptyList(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** A task list and everything in it, ready to become one `.ics` document. */
|
/** A task list and everything in it, ready to become one `.ics` document. */
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ object ICalendarWriter {
|
|||||||
task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) }
|
task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) }
|
||||||
// URL is a URI, not TEXT: it must not be escaped like one.
|
// URL is a URI, not TEXT: it must not be escaped like one.
|
||||||
task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) }
|
task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) }
|
||||||
task.tags.takeIf { it.isNotEmpty() }?.let { rawProperty("CATEGORIES", ICalValues.formatTextList(it)) }
|
|
||||||
|
|
||||||
task.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
|
task.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
|
||||||
task.due?.let { dateProperty("DUE", it, task.isAllDay) }
|
task.due?.let { dateProperty("DUE", it, task.isAllDay) }
|
||||||
|
|||||||
@@ -49,37 +49,6 @@ object ICalValues {
|
|||||||
return out.toString()
|
return out.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A multi-valued TEXT property (`CATEGORIES`, §3.8.1.2): split on the
|
|
||||||
* commas that are not escaped, then unescape each value.
|
|
||||||
*/
|
|
||||||
fun parseTextList(value: String): List<String> {
|
|
||||||
val values = mutableListOf<String>()
|
|
||||||
val current = StringBuilder()
|
|
||||||
var i = 0
|
|
||||||
while (i < value.length) {
|
|
||||||
val c = value[i]
|
|
||||||
when {
|
|
||||||
c == '\\' && i + 1 < value.length -> {
|
|
||||||
current.append(c).append(value[i + 1])
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
c == ',' -> {
|
|
||||||
values += unescapeText(current.toString())
|
|
||||||
current.clear()
|
|
||||||
}
|
|
||||||
else -> current.append(c)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
values += unescapeText(current.toString())
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The inverse of [parseTextList]. */
|
|
||||||
fun formatTextList(values: List<String>): String = values.joinToString(",", transform = ::escapeText)
|
|
||||||
|
|
||||||
/** How a DATE / DATE-TIME property was written, and whether we can reproduce it. */
|
/** How a DATE / DATE-TIME property was written, and whether we can reproduce it. */
|
||||||
sealed interface TimeValue {
|
sealed interface TimeValue {
|
||||||
/** `VALUE=DATE` — date-only. */
|
/** `VALUE=DATE` — date-only. */
|
||||||
@@ -161,57 +130,6 @@ object ICalValues {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val DURATION = Regex(
|
|
||||||
"([+-])?P(?:(\\d+)W|(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?)",
|
|
||||||
RegexOption.IGNORE_CASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RFC 5545 §3.3.6 `dur-value` in signed seconds: `-PT15M` → -900, `P1W` →
|
|
||||||
* 604800, `-P1DT2H` → -93600. Null when it does not parse.
|
|
||||||
*
|
|
||||||
* A little more lenient than the grammar — `PT1H5S` skips the minutes the
|
|
||||||
* grammar would require — because a stricter reader would leave another
|
|
||||||
* client's alarm unread over a form nobody misreads.
|
|
||||||
*/
|
|
||||||
fun parseDuration(value: String): Long? {
|
|
||||||
val match = DURATION.matchEntire(value.trim()) ?: return null
|
|
||||||
val (sign, weeks, days, hours, minutes, seconds) = match.destructured
|
|
||||||
// "P" and "PT" alone match the pattern and mean nothing.
|
|
||||||
if (listOf(weeks, days, hours, minutes, seconds).all(String::isEmpty)) return null
|
|
||||||
if (value.trim().endsWith("T", ignoreCase = true)) return null
|
|
||||||
fun part(text: String, scale: Long): Long =
|
|
||||||
if (text.isEmpty()) 0L else Math.multiplyExact(text.toLong(), scale)
|
|
||||||
val total = runCatching {
|
|
||||||
listOf(
|
|
||||||
part(weeks, 7 * 86_400L), part(days, 86_400L),
|
|
||||||
part(hours, 3_600L), part(minutes, 60L), part(seconds, 1L),
|
|
||||||
).reduce(Math::addExact)
|
|
||||||
}.getOrNull() ?: return null
|
|
||||||
return if (sign == "-") -total else total
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The inverse of [parseDuration], in days and time, never weeks: `-PT15M`, `-P1D`, `PT0S`. */
|
|
||||||
fun formatDuration(seconds: Long): String {
|
|
||||||
if (seconds == 0L) return "PT0S"
|
|
||||||
val magnitude = kotlin.math.abs(seconds)
|
|
||||||
val days = magnitude / 86_400
|
|
||||||
val hours = magnitude % 86_400 / 3_600
|
|
||||||
val minutes = magnitude % 3_600 / 60
|
|
||||||
val rest = magnitude % 60
|
|
||||||
return buildString {
|
|
||||||
if (seconds < 0) append('-')
|
|
||||||
append('P')
|
|
||||||
if (days > 0) append(days).append('D')
|
|
||||||
if (hours > 0 || minutes > 0 || rest > 0) {
|
|
||||||
append('T')
|
|
||||||
if (hours > 0) append(hours).append('H')
|
|
||||||
if (minutes > 0) append(minutes).append('M')
|
|
||||||
if (rest > 0) append(rest).append('S')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun java.time.Instant.toKotlin(): Instant =
|
private fun java.time.Instant.toKotlin(): Instant =
|
||||||
Instant.fromEpochMilliseconds(toEpochMilli())
|
Instant.fromEpochMilliseconds(toEpochMilli())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.rounded.LocationOn
|
|||||||
import androidx.compose.material.icons.rounded.Notifications
|
import androidx.compose.material.icons.rounded.Notifications
|
||||||
import androidx.compose.material.icons.rounded.Percent
|
import androidx.compose.material.icons.rounded.Percent
|
||||||
import androidx.compose.material.icons.rounded.Repeat
|
import androidx.compose.material.icons.rounded.Repeat
|
||||||
import androidx.compose.material.icons.rounded.Sell
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
@@ -24,7 +23,6 @@ fun TaskFormField.labelRes(): Int = when (this) {
|
|||||||
TaskFormField.Reminder -> R.string.edit_reminder_label
|
TaskFormField.Reminder -> R.string.edit_reminder_label
|
||||||
TaskFormField.Location -> R.string.edit_location_label
|
TaskFormField.Location -> R.string.edit_location_label
|
||||||
TaskFormField.Url -> R.string.edit_url_label
|
TaskFormField.Url -> R.string.edit_url_label
|
||||||
TaskFormField.Tags -> R.string.edit_tags_label
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun TaskFormField.icon(): ImageVector = when (this) {
|
fun TaskFormField.icon(): ImageVector = when (this) {
|
||||||
@@ -36,5 +34,4 @@ fun TaskFormField.icon(): ImageVector = when (this) {
|
|||||||
TaskFormField.Reminder -> Icons.Rounded.Notifications
|
TaskFormField.Reminder -> Icons.Rounded.Notifications
|
||||||
TaskFormField.Location -> Icons.Rounded.LocationOn
|
TaskFormField.Location -> Icons.Rounded.LocationOn
|
||||||
TaskFormField.Url -> Icons.Rounded.Link
|
TaskFormField.Url -> Icons.Rounded.Link
|
||||||
TaskFormField.Tags -> Icons.Rounded.Sell
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
|
|
||||||
/** A lone grouped card holding a name, for the list and filter editors. */
|
|
||||||
@Composable
|
|
||||||
internal fun NameField(
|
|
||||||
name: String,
|
|
||||||
placeholder: String,
|
|
||||||
autoFocus: Boolean,
|
|
||||||
onNameChange: (String) -> Unit,
|
|
||||||
onImeAction: () -> Unit,
|
|
||||||
leading: (@Composable () -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() }
|
|
||||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = GroupedListInset)) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
leading?.invoke()
|
|
||||||
InlineTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = onNameChange,
|
|
||||||
placeholder = placeholder,
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
onImeAction = onImeAction,
|
|
||||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pill naming one tag. Solid secondary by default so it reads against any card;
|
|
||||||
* [muted] is the quieter tonal look for something not yet applied, like a suggestion.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun TagChip(
|
|
||||||
name: String,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
muted: Boolean = false,
|
|
||||||
icon: ImageVector? = null,
|
|
||||||
iconContentDescription: String? = null,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val container = if (muted) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.secondary
|
|
||||||
val content = if (muted) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSecondary
|
|
||||||
val shape = RoundedCornerShape(50)
|
|
||||||
val body: @Composable () -> Unit = {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier.padding(start = 12.dp, end = if (icon != null) 8.dp else 12.dp, top = 6.dp, bottom = 6.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = name,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
if (icon != null) {
|
|
||||||
Icon(icon, contentDescription = iconContentDescription, modifier = Modifier.size(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (onClick != null) {
|
|
||||||
Surface(onClick = onClick, color = container, contentColor = content, shape = shape, modifier = modifier) { body() }
|
|
||||||
} else {
|
|
||||||
Surface(color = container, contentColor = content, shape = shape, modifier = modifier) { body() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,6 @@ import androidx.compose.foundation.text.BasicTextField
|
|||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
@@ -44,7 +42,6 @@ import android.net.Uri
|
|||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.rounded.Info
|
import androidx.compose.material.icons.rounded.Info
|
||||||
import androidx.compose.material.icons.rounded.Link
|
import androidx.compose.material.icons.rounded.Link
|
||||||
import androidx.compose.material.icons.rounded.Sell
|
|
||||||
import androidx.compose.material.icons.rounded.LocationOn
|
import androidx.compose.material.icons.rounded.LocationOn
|
||||||
import androidx.compose.material.icons.rounded.Notifications
|
import androidx.compose.material.icons.rounded.Notifications
|
||||||
import androidx.compose.material.icons.rounded.Repeat
|
import androidx.compose.material.icons.rounded.Repeat
|
||||||
@@ -63,7 +60,6 @@ import de.jeanlucmakiola.agendula.domain.TaskStatus
|
|||||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
|
import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
|
||||||
import de.jeanlucmakiola.agendula.ui.common.TagChip
|
|
||||||
import de.jeanlucmakiola.agendula.ui.common.linkified
|
import de.jeanlucmakiola.agendula.ui.common.linkified
|
||||||
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
||||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||||
@@ -101,7 +97,6 @@ import de.jeanlucmakiola.agendula.R
|
|||||||
import de.jeanlucmakiola.agendula.domain.readableDescription
|
import de.jeanlucmakiola.agendula.domain.readableDescription
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.floret.components.revealOnFocus
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
|
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
|
||||||
import de.jeanlucmakiola.floret.time.formatDate
|
import de.jeanlucmakiola.floret.time.formatDate
|
||||||
@@ -245,7 +240,6 @@ fun TaskDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DetailBody(
|
private fun DetailBody(
|
||||||
detail: TaskDetail,
|
detail: TaskDetail,
|
||||||
@@ -459,19 +453,6 @@ private fun DetailBody(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.tags.isNotEmpty()) {
|
|
||||||
add {
|
|
||||||
DetailCard(icon = Icons.Rounded.Sell, iconContentDescription = stringResource(R.string.edit_tags_label)) {
|
|
||||||
FlowRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
task.tags.forEach { TagChip(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description — selectable, with its links tappable.
|
// Description — selectable, with its links tappable.
|
||||||
task.description?.readableDescription()?.takeIf { it.isNotBlank() }?.let { description ->
|
task.description?.readableDescription()?.takeIf { it.isNotBlank() }?.let { description ->
|
||||||
add {
|
add {
|
||||||
@@ -625,7 +606,7 @@ private fun AddSubtaskField(onAdd: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
val accentOn = MaterialTheme.colorScheme.onPrimaryContainer
|
val accentOn = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).revealOnFocus().padding(start = 8.dp, end = 8.dp),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||||
|
|||||||
@@ -1,25 +1,9 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.edit
|
package de.jeanlucmakiola.agendula.ui.edit
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.ScrollState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.ui.layout.onPlaced
|
|
||||||
import androidx.compose.ui.layout.positionInParent
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import de.jeanlucmakiola.agendula.ui.common.TagChip
|
|
||||||
import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
|
import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
|
||||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.material.icons.rounded.Link
|
import androidx.compose.material.icons.rounded.Link
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.material.icons.rounded.Sell
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.material.icons.rounded.LocationOn
|
import androidx.compose.material.icons.rounded.LocationOn
|
||||||
import androidx.compose.material.icons.rounded.Repeat
|
import androidx.compose.material.icons.rounded.Repeat
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
@@ -31,7 +15,6 @@ import de.jeanlucmakiola.agendula.ui.common.icon
|
|||||||
import de.jeanlucmakiola.agendula.ui.common.labelRes
|
import de.jeanlucmakiola.agendula.ui.common.labelRes
|
||||||
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
||||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.floret.components.revealOnFocus
|
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||||
@@ -170,25 +153,11 @@ fun TaskEditScreen(
|
|||||||
val requestBack = { if (state.isDirty && !state.saved) showDiscard = true else onBack() }
|
val requestBack = { if (state.isDirty && !state.saved) showDiscard = true else onBack() }
|
||||||
BackHandler(enabled = state.isDirty && !state.saved) { showDiscard = true }
|
BackHandler(enabled = state.isDirty && !state.saved) { showDiscard = true }
|
||||||
|
|
||||||
// Once the title field scrolls away it rides in the bar, so a long form never
|
|
||||||
// loses track of which task is being edited.
|
|
||||||
val scroll = rememberScrollState()
|
|
||||||
var titleBottom by remember { mutableIntStateOf(Int.MAX_VALUE) }
|
|
||||||
val titleInBar by remember { derivedStateOf { scroll.value > titleBottom } }
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {},
|
||||||
AnimatedVisibility(
|
|
||||||
visible = titleInBar && state.title.isNotBlank(),
|
|
||||||
enter = fadeIn() + slideInVertically { it / 2 },
|
|
||||||
exit = fadeOut() + slideOutVertically { it / 2 },
|
|
||||||
) {
|
|
||||||
Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = requestBack) {
|
IconButton(onClick = requestBack) {
|
||||||
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close))
|
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close))
|
||||||
@@ -211,8 +180,6 @@ fun TaskEditScreen(
|
|||||||
EditContent(
|
EditContent(
|
||||||
state = state,
|
state = state,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
scroll = scroll,
|
|
||||||
onTitleBottom = { titleBottom = it },
|
|
||||||
modifier = Modifier.padding(inner),
|
modifier = Modifier.padding(inner),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -253,8 +220,6 @@ private enum class PickerTarget { Start, Due }
|
|||||||
private fun EditContent(
|
private fun EditContent(
|
||||||
state: TaskEditUiState,
|
state: TaskEditUiState,
|
||||||
viewModel: TaskEditViewModel,
|
viewModel: TaskEditViewModel,
|
||||||
scroll: ScrollState,
|
|
||||||
onTitleBottom: (Int) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -285,7 +250,7 @@ private fun EditContent(
|
|||||||
// Shrink the scroll viewport by the keyboard so the focused field
|
// Shrink the scroll viewport by the keyboard so the focused field
|
||||||
// scrolls into view above the IME instead of being hidden.
|
// scrolls into view above the IME instead of being hidden.
|
||||||
.imePadding()
|
.imePadding()
|
||||||
.verticalScroll(scroll)
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||||
) {
|
) {
|
||||||
// Title: borderless headline + accent bar, mirroring the detail screen.
|
// Title: borderless headline + accent bar, mirroring the detail screen.
|
||||||
@@ -294,9 +259,7 @@ private fun EditContent(
|
|||||||
onValueChange = viewModel::onTitleChange,
|
onValueChange = viewModel::onTitleChange,
|
||||||
placeholder = stringResource(R.string.edit_title_hint),
|
placeholder = stringResource(R.string.edit_title_hint),
|
||||||
textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
modifier = Modifier
|
modifier = Modifier.focusRequester(titleFocus),
|
||||||
.focusRequester(titleFocus)
|
|
||||||
.onPlaced { onTitleBottom(it.positionInParent().y.toInt() + it.size.height) },
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Box(
|
Box(
|
||||||
@@ -609,19 +572,6 @@ private fun EditContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
OptionalFormSection(visible = TaskFormField.Tags in state.visibleFields) {
|
|
||||||
Spacer(Modifier.height(gap))
|
|
||||||
TagsCard(
|
|
||||||
tags = state.tags,
|
|
||||||
draft = state.tagDraft,
|
|
||||||
suggestions = state.tagSuggestions,
|
|
||||||
onDraftChange = viewModel::onTagDraftChange,
|
|
||||||
onCommitDraft = viewModel::commitTagDraft,
|
|
||||||
onAdd = viewModel::addTag,
|
|
||||||
onRemove = viewModel::removeTag,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -777,9 +727,9 @@ private fun EditCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (onClick != null) {
|
if (onClick != null) {
|
||||||
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth().revealOnFocus()) { inner() }
|
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() }
|
||||||
} else {
|
} else {
|
||||||
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth().revealOnFocus()) { inner() }
|
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1090,77 +1040,6 @@ private fun AddReminderCard(position: Position, label: String, onClick: () -> Un
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The input sits beside the icon; the tags and any suggestions run below it. */
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun TagsCard(
|
|
||||||
tags: List<String>,
|
|
||||||
draft: String,
|
|
||||||
suggestions: List<String>,
|
|
||||||
onDraftChange: (String) -> Unit,
|
|
||||||
onCommitDraft: () -> Unit,
|
|
||||||
onAdd: (String) -> Unit,
|
|
||||||
onRemove: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
val query = draft.trim()
|
|
||||||
val matches = if (query.isEmpty()) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
suggestions.filter { it.contains(query, ignoreCase = true) && it !in tags }.take(MAX_TAG_SUGGESTIONS)
|
|
||||||
}
|
|
||||||
EditCard(
|
|
||||||
icon = Icons.Rounded.Sell,
|
|
||||||
iconContentDescription = stringResource(R.string.edit_tags_label),
|
|
||||||
header = {
|
|
||||||
InlineTextField(
|
|
||||||
value = draft,
|
|
||||||
onValueChange = onDraftChange,
|
|
||||||
placeholder = stringResource(R.string.edit_tags_hint),
|
|
||||||
capitalization = KeyboardCapitalization.None,
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
onImeAction = onCommitDraft,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (matches.isNotEmpty()) {
|
|
||||||
TagFlow(modifier = Modifier.padding(top = 8.dp)) {
|
|
||||||
matches.forEach { match ->
|
|
||||||
TagChip(
|
|
||||||
name = match,
|
|
||||||
muted = true,
|
|
||||||
icon = Icons.Rounded.Add,
|
|
||||||
onClick = { onAdd(match) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tags.isNotEmpty()) {
|
|
||||||
TagFlow(modifier = Modifier.padding(top = 8.dp)) {
|
|
||||||
tags.forEach { tag ->
|
|
||||||
TagChip(
|
|
||||||
name = tag,
|
|
||||||
icon = Icons.Rounded.Close,
|
|
||||||
iconContentDescription = stringResource(R.string.edit_tag_remove, tag),
|
|
||||||
onClick = { onRemove(tag) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun TagFlow(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
|
||||||
FlowRow(
|
|
||||||
modifier = modifier,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) { content() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val MAX_TAG_SUGGESTIONS = 8
|
|
||||||
|
|
||||||
/** Picks one hidden optional section to add to the form. */
|
/** Picks one hidden optional section to add to the form. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun FieldPickerDialog(
|
private fun FieldPickerDialog(
|
||||||
|
|||||||
@@ -8,20 +8,15 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
|
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.populatedFields
|
import de.jeanlucmakiola.agendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.agendula.domain.htmlToPlainText
|
import de.jeanlucmakiola.agendula.domain.htmlToPlainText
|
||||||
import de.jeanlucmakiola.agendula.domain.looksLikeHtml
|
import de.jeanlucmakiola.agendula.domain.looksLikeHtml
|
||||||
import de.jeanlucmakiola.agendula.domain.newTaskPreset
|
|
||||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
|
||||||
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
|
||||||
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
|
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
|
||||||
import de.jeanlucmakiola.agendula.domain.readableDescription
|
import de.jeanlucmakiola.agendula.domain.readableDescription
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||||
@@ -30,7 +25,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.time.LocalDate
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -57,11 +51,6 @@ data class TaskEditUiState(
|
|||||||
val reminders: List<Int> = emptyList(),
|
val reminders: List<Int> = emptyList(),
|
||||||
val location: String = "",
|
val location: String = "",
|
||||||
val url: String = "",
|
val url: String = "",
|
||||||
val tags: List<String> = emptyList(),
|
|
||||||
/** What is typed in the tag field and not added yet. Saving still takes it. */
|
|
||||||
val tagDraft: String = "",
|
|
||||||
/** Every tag already in use, for completing what the user types. */
|
|
||||||
val tagSuggestions: List<String> = emptyList(),
|
|
||||||
val rrule: String? = null,
|
val rrule: String? = null,
|
||||||
/** Editing one occurrence of a series: saving asks how far the edit reaches. */
|
/** Editing one occurrence of a series: saving asks how far the edit reaches. */
|
||||||
val isOccurrence: Boolean = false,
|
val isOccurrence: Boolean = false,
|
||||||
@@ -125,13 +114,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
private var defaultFields: Set<TaskFormField> = emptySet()
|
private var defaultFields: Set<TaskFormField> = emptySet()
|
||||||
|
|
||||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||||
fun bindNew(
|
fun bindNew(presetListId: Long? = null, parentId: Long? = null, initialTitle: String? = null) {
|
||||||
presetListId: Long? = null,
|
|
||||||
parentId: Long? = null,
|
|
||||||
initialTitle: String? = null,
|
|
||||||
presetTag: String? = null,
|
|
||||||
presetFilterId: Long? = null,
|
|
||||||
) {
|
|
||||||
if (bound) return
|
if (bound) return
|
||||||
bound = true
|
bound = true
|
||||||
editingTaskId = null
|
editingTaskId = null
|
||||||
@@ -140,16 +123,12 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
val settings = settingsPrefs.settings.first()
|
val settings = settingsPrefs.settings.first()
|
||||||
defaultFields = settings.defaultEditFields
|
defaultFields = settings.defaultEditFields
|
||||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||||
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
|
|
||||||
?.newTaskPreset(LocalDate.now())
|
|
||||||
// A read-only or vanished default — or a widget's preset list, if it was
|
// 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
|
// since deleted or turned read-only — would give the task a list it
|
||||||
// cannot be saved to.
|
// cannot be saved to.
|
||||||
val writable = lists.filter { it.acceptsWrites }
|
val writable = lists.filter { it.acceptsWrites }
|
||||||
val filterLists = preset?.listIds.orEmpty()
|
|
||||||
val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } }
|
val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } }
|
||||||
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } && (filterLists.isEmpty() || id in filterLists) }
|
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } }
|
||||||
?: writable.firstOrNull { it.id in filterLists }?.id
|
|
||||||
?: writable.firstOrNull { !it.isLocal }?.id
|
?: writable.firstOrNull { !it.isLocal }?.id
|
||||||
?: writable.firstOrNull()?.id
|
?: writable.firstOrNull()?.id
|
||||||
_state.value = withFields(
|
_state.value = withFields(
|
||||||
@@ -158,22 +137,11 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
isNew = true,
|
isNew = true,
|
||||||
autofocusTitle = settings.autofocusTitle,
|
autofocusTitle = settings.autofocusTitle,
|
||||||
listId = defaultList,
|
listId = defaultList,
|
||||||
// A preset due is all-day, so a default start has to be too, or
|
start = if (settings.defaultStartToday) Clock.System.now() else null,
|
||||||
// today's start would land after a due of today.
|
|
||||||
start = when {
|
|
||||||
!settings.defaultStartToday -> null
|
|
||||||
preset?.dueDate != 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,
|
|
||||||
priority = preset?.priority ?: Priority.NONE,
|
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
lists = lists,
|
lists = lists,
|
||||||
parentCandidates = loadParents(defaultList, selfId = null),
|
parentCandidates = loadParents(defaultList, selfId = null),
|
||||||
title = initialTitle.orEmpty(),
|
title = initialTitle.orEmpty(),
|
||||||
tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(),
|
|
||||||
),
|
),
|
||||||
).also { baseline = it.toForm().copy(title = "") }
|
).also { baseline = it.toForm().copy(title = "") }
|
||||||
}
|
}
|
||||||
@@ -211,7 +179,6 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore },
|
reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore },
|
||||||
location = task.location.orEmpty(),
|
location = task.location.orEmpty(),
|
||||||
url = task.url.orEmpty(),
|
url = task.url.orEmpty(),
|
||||||
tags = task.tags,
|
|
||||||
rrule = task.recurrenceRule,
|
rrule = task.recurrenceRule,
|
||||||
originalRule = task.recurrenceRule,
|
originalRule = task.recurrenceRule,
|
||||||
isOccurrence = task.isOccurrence,
|
isOccurrence = task.isOccurrence,
|
||||||
@@ -235,18 +202,6 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId }
|
return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var suggestionsRequested = false
|
|
||||||
|
|
||||||
/** Tags on open tasks, read once the user starts typing one rather than on every bind. */
|
|
||||||
private fun loadTagSuggestions() {
|
|
||||||
if (suggestionsRequested) return
|
|
||||||
suggestionsRequested = true
|
|
||||||
viewModelScope.launch {
|
|
||||||
val tasks = runCatching { repository.tasks(TaskFilter.Smart(SmartList.ALL)).first() }.getOrElse { emptyList() }
|
|
||||||
_state.value = _state.value.copy(tagSuggestions = tagsInUse(tasks))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reveal an optional section (one tap from the "More fields" picker). */
|
/** Reveal an optional section (one tap from the "More fields" picker). */
|
||||||
fun revealField(field: TaskFormField) = update {
|
fun revealField(field: TaskFormField) = update {
|
||||||
val visible = it.visibleFields + field
|
val visible = it.visibleFields + field
|
||||||
@@ -302,18 +257,6 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) }
|
fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) }
|
||||||
fun onLocationChange(value: String) = update { it.copy(location = value) }
|
fun onLocationChange(value: String) = update { it.copy(location = value) }
|
||||||
fun onUrlChange(value: String) = update { it.copy(url = value) }
|
fun onUrlChange(value: String) = update { it.copy(url = value) }
|
||||||
fun addTag(name: String) = update { it.copy(tags = normalizeTags(it.tags + name), tagDraft = "") }
|
|
||||||
|
|
||||||
/** A typed comma adds what came before it. */
|
|
||||||
fun onTagDraftChange(value: String) {
|
|
||||||
loadTagSuggestions()
|
|
||||||
update {
|
|
||||||
if (',' in value) it.copy(tags = it.withDraft(value), tagDraft = "") else it.copy(tagDraft = value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun commitTagDraft() = update { it.copy(tags = it.withDraft(it.tagDraft), tagDraft = "") }
|
|
||||||
fun removeTag(name: String) = update { it.copy(tags = it.tags - name) }
|
|
||||||
fun onRecurrenceChange(rrule: String?) = update { it.copy(rrule = rrule, errors = emptySet()) }
|
fun onRecurrenceChange(rrule: String?) = update { it.copy(rrule = rrule, errors = emptySet()) }
|
||||||
|
|
||||||
/** Dismiss the conflict prompt without saving (the user keeps editing). */
|
/** Dismiss the conflict prompt without saving (the user keeps editing). */
|
||||||
@@ -396,8 +339,5 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
|
|||||||
reminders = reminders,
|
reminders = reminders,
|
||||||
location = location.ifBlank { null },
|
location = location.ifBlank { null },
|
||||||
url = url.ifBlank { null },
|
url = url.ifBlank { null },
|
||||||
tags = withDraft(tagDraft),
|
|
||||||
rrule = rrule,
|
rrule = rrule,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun TaskEditUiState.withDraft(draft: String): List<String> = normalizeTags(tags + draft.split(','))
|
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.filters
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.ChevronRight
|
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import de.jeanlucmakiola.agendula.R
|
|
||||||
import de.jeanlucmakiola.agendula.domain.DueWindow
|
|
||||||
import de.jeanlucmakiola.agendula.domain.Priority
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
|
||||||
import de.jeanlucmakiola.agendula.domain.TagMatch
|
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
|
||||||
import de.jeanlucmakiola.agendula.ui.common.NameField
|
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates or edits a [SavedFilter]: a name, then due windows, priority, lists and
|
|
||||||
* tags. Each multi-choice section leads with an "any" row that is selected while
|
|
||||||
* nothing in it is, so an untouched section visibly doesn't narrow.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun FilterEditScreen(
|
|
||||||
viewModel: FilterEditViewModel,
|
|
||||||
isNew: Boolean,
|
|
||||||
onSaved: (Long) -> Unit,
|
|
||||||
onDeleted: () -> Unit,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val draft by viewModel.draft.collectAsStateWithLifecycle()
|
|
||||||
val lists by viewModel.lists.collectAsStateWithLifecycle()
|
|
||||||
val tagsInUse by viewModel.tags.collectAsStateWithLifecycle()
|
|
||||||
val filter = draft
|
|
||||||
var choosingPriority by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var confirmDelete by rememberSaveable { mutableStateOf(false) }
|
|
||||||
val save = { viewModel.save(onSaved) }
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
|
||||||
title = stringResource(if (isNew) R.string.filter_new_title else R.string.filter_edit_title),
|
|
||||||
onBack = onBack,
|
|
||||||
largeTopBar = false,
|
|
||||||
actions = {
|
|
||||||
Button(
|
|
||||||
onClick = save,
|
|
||||||
enabled = filter?.name?.isNotBlank() == true,
|
|
||||||
modifier = Modifier.padding(end = 12.dp),
|
|
||||||
) { Text(stringResource(R.string.save)) }
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (filter == null) return@CollapsingScaffold
|
|
||||||
|
|
||||||
NameField(
|
|
||||||
name = filter.name,
|
|
||||||
placeholder = stringResource(R.string.filter_name_hint),
|
|
||||||
autoFocus = isNew,
|
|
||||||
onNameChange = { name -> viewModel.update { it.copy(name = name) } },
|
|
||||||
onImeAction = save,
|
|
||||||
)
|
|
||||||
|
|
||||||
GroupedSectionHeader(stringResource(R.string.filter_section_due))
|
|
||||||
val windows = DueWindow.entries
|
|
||||||
ChoiceRow(
|
|
||||||
title = stringResource(R.string.filter_any_due),
|
|
||||||
position = positionOf(0, windows.size + 1),
|
|
||||||
selected = filter.due.isEmpty(),
|
|
||||||
onClick = { viewModel.update { it.copy(due = emptySet()) } },
|
|
||||||
)
|
|
||||||
windows.forEachIndexed { index, window ->
|
|
||||||
ChoiceRow(
|
|
||||||
title = dueWindowLabel(window),
|
|
||||||
position = positionOf(index + 1, windows.size + 1),
|
|
||||||
selected = window in filter.due,
|
|
||||||
onClick = { viewModel.update { it.copy(due = it.due.toggle(window)) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
GroupedSectionHeader(stringResource(R.string.filter_section_priority))
|
|
||||||
GroupedRow(
|
|
||||||
title = minPriorityLabel(filter.minPriority),
|
|
||||||
position = Position.Alone,
|
|
||||||
trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) },
|
|
||||||
onClick = { choosingPriority = true },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (lists.isNotEmpty()) {
|
|
||||||
GroupedSectionHeader(stringResource(R.string.filter_section_lists))
|
|
||||||
val showAccount = lists.mapTo(HashSet()) { it.accountName }.size > 1
|
|
||||||
ChoiceRow(
|
|
||||||
title = stringResource(R.string.filter_any_list),
|
|
||||||
position = positionOf(0, lists.size + 1),
|
|
||||||
selected = filter.anyList,
|
|
||||||
onClick = { viewModel.update { it.copy(listIds = emptySet(), listsElsewhere = false) } },
|
|
||||||
)
|
|
||||||
lists.forEachIndexed { index, list ->
|
|
||||||
ChoiceRow(
|
|
||||||
title = list.name,
|
|
||||||
summary = list.accountName.takeIf { showAccount },
|
|
||||||
position = positionOf(index + 1, lists.size + 1),
|
|
||||||
selected = list.id in filter.listIds,
|
|
||||||
leading = { ListColorChip(list.color) },
|
|
||||||
onClick = { viewModel.update { it.copy(listIds = it.listIds.toggle(list.id)) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A tag no open task carries any more stays listed while the filter uses
|
|
||||||
// it, so it can still be unticked.
|
|
||||||
val tags = remember(tagsInUse, filter.tags) {
|
|
||||||
(tagsInUse + filter.tags).distinct().sortedWith(String.CASE_INSENSITIVE_ORDER)
|
|
||||||
}
|
|
||||||
if (tags.isNotEmpty()) {
|
|
||||||
GroupedSectionHeader(stringResource(R.string.filter_section_tags))
|
|
||||||
ChoiceRow(
|
|
||||||
title = stringResource(R.string.filter_any_tag),
|
|
||||||
position = positionOf(0, tags.size + 1),
|
|
||||||
selected = filter.tags.isEmpty(),
|
|
||||||
onClick = { viewModel.update { it.copy(tags = emptySet()) } },
|
|
||||||
)
|
|
||||||
tags.forEachIndexed { index, tag ->
|
|
||||||
ChoiceRow(
|
|
||||||
title = tag,
|
|
||||||
position = positionOf(index + 1, tags.size + 1),
|
|
||||||
selected = tag in filter.tags,
|
|
||||||
onClick = { viewModel.update { it.copy(tags = it.tags.toggle(tag)) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (filter.tags.size > 1) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.filter_match_all_tags),
|
|
||||||
summary = stringResource(
|
|
||||||
if (filter.tagMatch == TagMatch.ALL) R.string.filter_match_all_on else R.string.filter_match_all_off,
|
|
||||||
),
|
|
||||||
position = Position.Alone,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = filter.tagMatch == TagMatch.ALL,
|
|
||||||
onCheckedChange = { all ->
|
|
||||||
viewModel.update { it.copy(tagMatch = if (all) TagMatch.ALL else TagMatch.ANY) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
viewModel.update { it.copy(tagMatch = if (it.tagMatch == TagMatch.ALL) TagMatch.ANY else TagMatch.ALL) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.id > 0) {
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.filter_delete),
|
|
||||||
position = Position.Alone,
|
|
||||||
contentColor = MaterialTheme.colorScheme.error,
|
|
||||||
leading = { Icon(Icons.Rounded.Delete, contentDescription = null) },
|
|
||||||
onClick = { confirmDelete = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (choosingPriority && filter != null) {
|
|
||||||
OptionPicker(
|
|
||||||
title = stringResource(R.string.filter_section_priority),
|
|
||||||
options = Priority.entries,
|
|
||||||
selected = filter.minPriority,
|
|
||||||
label = { minPriorityLabel(it) },
|
|
||||||
onSelect = { priority -> viewModel.update { it.copy(minPriority = priority) } },
|
|
||||||
onDismiss = { choosingPriority = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (confirmDelete && filter != null) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { confirmDelete = false },
|
|
||||||
title = { Text(stringResource(R.string.filter_delete_title, filter.name)) },
|
|
||||||
text = { Text(stringResource(R.string.filter_delete_body)) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { confirmDelete = false; viewModel.delete(onDeleted) }) {
|
|
||||||
Text(stringResource(R.string.filter_delete_confirm))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { confirmDelete = false }) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ChoiceRow(
|
|
||||||
title: String,
|
|
||||||
position: Position,
|
|
||||||
selected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
summary: String? = null,
|
|
||||||
leading: @Composable (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
GroupedRow(
|
|
||||||
title = title,
|
|
||||||
summary = summary,
|
|
||||||
position = position,
|
|
||||||
selected = selected,
|
|
||||||
leading = leading,
|
|
||||||
trailing = if (selected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T> Set<T>.toggle(item: T): Set<T> = if (item in this) this - item else this + item
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun dueWindowLabel(window: DueWindow): String = stringResource(
|
|
||||||
when (window) {
|
|
||||||
DueWindow.OVERDUE -> R.string.smart_overdue
|
|
||||||
DueWindow.TODAY -> R.string.smart_today
|
|
||||||
DueWindow.UPCOMING -> R.string.smart_upcoming
|
|
||||||
DueWindow.NO_DATE -> R.string.smart_no_date
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun minPriorityLabel(priority: Priority): String = stringResource(
|
|
||||||
when (priority) {
|
|
||||||
Priority.NONE -> R.string.filter_priority_any
|
|
||||||
Priority.LOW -> R.string.filter_priority_low
|
|
||||||
Priority.MEDIUM -> R.string.filter_priority_medium
|
|
||||||
Priority.HIGH -> R.string.filter_priority_high
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.filters
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
|
||||||
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
|
||||||
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/** Holds the filter being created or edited until it's saved. Call [bind] first. */
|
|
||||||
@HiltViewModel
|
|
||||||
class FilterEditViewModel @Inject constructor(
|
|
||||||
private val settingsPrefs: SettingsPrefs,
|
|
||||||
private val widgetUpdater: TaskWidgetUpdater,
|
|
||||||
private val repository: TasksRepository,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _draft = MutableStateFlow<SavedFilter?>(null)
|
|
||||||
|
|
||||||
/** `null` until [bind] has loaded the filter. An id of 0 is a new one. */
|
|
||||||
val draft: StateFlow<SavedFilter?> = _draft.asStateFlow()
|
|
||||||
|
|
||||||
val lists: StateFlow<List<TaskList>> = repository.taskLists()
|
|
||||||
.catch { emit(emptyList()) }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
|
||||||
|
|
||||||
/** Every tag on an open task. */
|
|
||||||
val tags: StateFlow<List<String>> = repository.tasks(TaskFilter.Smart(SmartList.ALL))
|
|
||||||
.map { tagsInUse(it) }
|
|
||||||
.catch { emit(emptyList()) }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
|
||||||
|
|
||||||
private var bound = false
|
|
||||||
private var saving = false
|
|
||||||
|
|
||||||
fun bind(filterId: Long?) {
|
|
||||||
if (bound) return
|
|
||||||
bound = true
|
|
||||||
if (filterId == null) {
|
|
||||||
_draft.value = SavedFilter(id = 0, name = "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
val saved = settingsPrefs.savedFilter(filterId).first()
|
|
||||||
// Show it as it matches: lists deleted since drop out, as they do in the repository.
|
|
||||||
val existing = runCatching { repository.taskLists().first().mapTo(HashSet()) { it.id } }.getOrNull()
|
|
||||||
_draft.value = saved?.let { if (existing == null) it else it.withListsIn(existing) }
|
|
||||||
?: SavedFilter(id = 0, name = "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun update(transform: (SavedFilter) -> SavedFilter) {
|
|
||||||
_draft.value = _draft.value?.let(transform)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun save(onSaved: (Long) -> Unit) {
|
|
||||||
val filter = _draft.value ?: return
|
|
||||||
if (filter.name.isBlank() || saving) return
|
|
||||||
saving = true
|
|
||||||
viewModelScope.launch {
|
|
||||||
val id = settingsPrefs.saveFilter(filter.copy(name = filter.name.trim()))
|
|
||||||
// A widget showing this filter doesn't watch the prefs.
|
|
||||||
widgetUpdater.requestRefresh()
|
|
||||||
onSaved(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun delete(onDeleted: () -> Unit) {
|
|
||||||
val id = _draft.value?.id?.takeIf { it > 0 } ?: return
|
|
||||||
viewModelScope.launch {
|
|
||||||
settingsPrefs.deleteFilter(id)
|
|
||||||
widgetUpdater.requestRefresh()
|
|
||||||
onDeleted()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,6 +30,7 @@ import androidx.compose.material3.Switch
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -39,22 +40,25 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.ui.common.DefaultListColor
|
import de.jeanlucmakiola.agendula.ui.common.DefaultListColor
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ListPalette
|
import de.jeanlucmakiola.agendula.ui.common.ListPalette
|
||||||
import de.jeanlucmakiola.agendula.ui.common.NameField
|
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
@@ -254,14 +258,27 @@ internal fun ListNameField(
|
|||||||
autoFocus: Boolean,
|
autoFocus: Boolean,
|
||||||
onNameChange: (String) -> Unit,
|
onNameChange: (String) -> Unit,
|
||||||
onImeAction: () -> Unit,
|
onImeAction: () -> Unit,
|
||||||
) = NameField(
|
) {
|
||||||
name = name,
|
val focusRequester = remember { FocusRequester() }
|
||||||
placeholder = stringResource(R.string.list_name_hint),
|
LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() }
|
||||||
autoFocus = autoFocus,
|
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
onNameChange = onNameChange,
|
Row(
|
||||||
onImeAction = onImeAction,
|
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
|
||||||
leading = { ListColorChip(color) },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
)
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
ListColorChip(color)
|
||||||
|
InlineTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
placeholder = stringResource(R.string.list_name_hint),
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
onImeAction = onImeAction,
|
||||||
|
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The palette as two rows of round swatches; the chosen one carries a check.
|
* The palette as two rows of round swatches; the chosen one carries a check.
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package de.jeanlucmakiola.agendula.ui.lists
|
|||||||
import de.jeanlucmakiola.floret.components.ReorderableColumn
|
import de.jeanlucmakiola.floret.components.ReorderableColumn
|
||||||
import androidx.compose.material.icons.rounded.Check
|
import androidx.compose.material.icons.rounded.Check
|
||||||
import androidx.compose.material.icons.rounded.DragHandle
|
import androidx.compose.material.icons.rounded.DragHandle
|
||||||
import androidx.compose.material.icons.rounded.FilterList
|
|
||||||
import androidx.compose.material.icons.rounded.Sell
|
|
||||||
import androidx.compose.material.icons.rounded.SwapVert
|
import androidx.compose.material.icons.rounded.SwapVert
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -32,7 +30,6 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
@@ -89,7 +86,6 @@ import de.jeanlucmakiola.agendula.domain.Task
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
import de.jeanlucmakiola.floret.components.SnackChip
|
import de.jeanlucmakiola.floret.components.SnackChip
|
||||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||||
@@ -118,7 +114,6 @@ fun ListsScreen(
|
|||||||
onNewTask: () -> Unit,
|
onNewTask: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onNewFilter: () -> Unit = {},
|
|
||||||
viewModel: ListsViewModel = hiltViewModel(),
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -184,7 +179,6 @@ fun ListsScreen(
|
|||||||
onOpenFilter = onOpenFilter,
|
onOpenFilter = onOpenFilter,
|
||||||
onOpenTask = onOpenTask,
|
onOpenTask = onOpenTask,
|
||||||
onNewList = { newList = true },
|
onNewList = { newList = true },
|
||||||
onNewFilter = onNewFilter,
|
|
||||||
onReorder = viewModel::reorderLists,
|
onReorder = viewModel::reorderLists,
|
||||||
topPadding = 0.dp,
|
topPadding = 0.dp,
|
||||||
bottomPadding = inner.calculateBottomPadding() + 96.dp,
|
bottomPadding = inner.calculateBottomPadding() + 96.dp,
|
||||||
@@ -258,7 +252,6 @@ private fun ListsContent(
|
|||||||
onOpenFilter: (TaskFilter) -> Unit,
|
onOpenFilter: (TaskFilter) -> Unit,
|
||||||
onOpenTask: (Task) -> Unit,
|
onOpenTask: (Task) -> Unit,
|
||||||
onNewList: () -> Unit,
|
onNewList: () -> Unit,
|
||||||
onNewFilter: () -> Unit,
|
|
||||||
onReorder: (List<Long>) -> Unit,
|
onReorder: (List<Long>) -> Unit,
|
||||||
topPadding: androidx.compose.ui.unit.Dp,
|
topPadding: androidx.compose.ui.unit.Dp,
|
||||||
bottomPadding: androidx.compose.ui.unit.Dp,
|
bottomPadding: androidx.compose.ui.unit.Dp,
|
||||||
@@ -377,90 +370,6 @@ private fun ListsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.tags.isNotEmpty()) {
|
|
||||||
item(key = "tags-header") { SectionHeader(stringResource(R.string.tags_header)) }
|
|
||||||
itemsIndexed(state.tags, key = { _, t -> "tag-${t.tag}" }) { index, overview ->
|
|
||||||
GroupedRow(
|
|
||||||
title = overview.tag,
|
|
||||||
position = positionOf(index, state.tags.size),
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.Sell,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Text(
|
|
||||||
overview.openCount.toString(),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { onOpenFilter(TaskFilter.Tagged(overview.tag)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.groups.isNotEmpty()) {
|
|
||||||
filtersSection(state.filters, onOpenFilter, onNewFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The saved filters, or a single row offering the first one. */
|
|
||||||
private fun LazyListScope.filtersSection(
|
|
||||||
filters: List<FilterOverview>,
|
|
||||||
onOpenFilter: (TaskFilter) -> Unit,
|
|
||||||
onNewFilter: () -> Unit,
|
|
||||||
) {
|
|
||||||
item(key = "filters-header") {
|
|
||||||
SectionHeader(
|
|
||||||
text = stringResource(R.string.filters_header),
|
|
||||||
action = if (filters.isEmpty()) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
{
|
|
||||||
IconButton(onClick = onNewFilter) {
|
|
||||||
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.filter_new_title))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (filters.isEmpty()) {
|
|
||||||
item(key = "filters-empty") {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.filter_new_title),
|
|
||||||
summary = stringResource(R.string.filters_empty_hint),
|
|
||||||
position = Position.Alone,
|
|
||||||
leading = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
|
||||||
onClick = onNewFilter,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
itemsIndexed(filters, key = { _, f -> "filter-${f.filter.id}" }) { index, overview ->
|
|
||||||
GroupedRow(
|
|
||||||
title = overview.filter.name,
|
|
||||||
position = positionOf(index, filters.size),
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.FilterList,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Text(
|
|
||||||
overview.openCount.toString(),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { onOpenFilter(TaskFilter.Saved(overview.filter.id)) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.floret.time.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -75,8 +73,6 @@ internal fun RemoteLists.Outcome.asFailure(local: ListWriteFailure): ListWriteFa
|
|||||||
data class ListOverview(val list: TaskList, val openCount: Int)
|
data class ListOverview(val list: TaskList, val openCount: Int)
|
||||||
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
||||||
data class SmartCount(val smart: SmartList, val count: Int)
|
data class SmartCount(val smart: SmartList, val count: Int)
|
||||||
data class TagOverview(val tag: String, val openCount: Int)
|
|
||||||
data class FilterOverview(val filter: SavedFilter, val openCount: Int)
|
|
||||||
|
|
||||||
sealed interface ListsUiState {
|
sealed interface ListsUiState {
|
||||||
data object Loading : ListsUiState
|
data object Loading : ListsUiState
|
||||||
@@ -91,10 +87,6 @@ sealed interface ListsUiState {
|
|||||||
val upcoming: List<Task>,
|
val upcoming: List<Task>,
|
||||||
/** Every task (open and completed) — the corpus the home search filters. */
|
/** Every task (open and completed) — the corpus the home search filters. */
|
||||||
val allTasks: List<Task>,
|
val allTasks: List<Task>,
|
||||||
/** Each tag on an open task, alphabetically. */
|
|
||||||
val tags: List<TagOverview> = emptyList(),
|
|
||||||
/** The saved filters, oldest first. */
|
|
||||||
val filters: List<FilterOverview> = emptyList(),
|
|
||||||
) : ListsUiState
|
) : ListsUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +125,8 @@ class ListsViewModel @Inject constructor(
|
|||||||
// ones already ticked off to show "x of y done", so read them too.
|
// ones already ticked off to show "x of y done", so read them too.
|
||||||
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
||||||
settingsPrefs.settings.map { it.hiddenFromSmartLists }.distinctUntilChanged(),
|
settingsPrefs.settings.map { it.hiddenFromSmartLists }.distinctUntilChanged(),
|
||||||
settingsPrefs.savedFilters,
|
) { lists, openTasks, completedTasks, hidden ->
|
||||||
) { lists, openTasks, completedTasks, hidden, filters ->
|
buildContent(lists, openTasks, completedTasks, hidden) as ListsUiState
|
||||||
buildContent(lists, openTasks, completedTasks, hidden, filters) as ListsUiState
|
|
||||||
}.recoveringFromProviderFailure { ListsUiState.Failure }
|
}.recoveringFromProviderFailure { ListsUiState.Failure }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
||||||
|
|
||||||
@@ -144,7 +135,6 @@ class ListsViewModel @Inject constructor(
|
|||||||
openTasks: List<Task>,
|
openTasks: List<Task>,
|
||||||
completedTasks: List<Task>,
|
completedTasks: List<Task>,
|
||||||
hidden: Set<Long>,
|
hidden: Set<Long>,
|
||||||
savedFilters: List<SavedFilter>,
|
|
||||||
): ListsUiState.Content {
|
): ListsUiState.Content {
|
||||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||||
// Count only top-level tasks: a subtask is represented by its parent (and
|
// Count only top-level tasks: a subtask is represented by its parent (and
|
||||||
@@ -187,29 +177,13 @@ class ListsViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sortedBy { it.accountName.lowercase() }
|
.sortedBy { it.accountName.lowercase() }
|
||||||
// Counts the open rows the tag's own screen shows: a subtask folds under a
|
|
||||||
// parent carrying the same tag, and stands on its own otherwise.
|
|
||||||
val allTasks = openTasks + completedTasks
|
|
||||||
val tags = tagsInUse(openTasks).map { tag ->
|
|
||||||
val tagged = allTasks.filter { tag in it.tags }
|
|
||||||
val ids = tagged.mapTo(HashSet()) { it.taskId }
|
|
||||||
TagOverview(tag, tagged.count { !it.isClosed && (!it.isSubtask || it.parentId !in ids) })
|
|
||||||
}
|
|
||||||
// Same folding as the tags: a subtask counts only when its parent isn't in the filter too.
|
|
||||||
val filters = savedFilters.map { filter ->
|
|
||||||
val matching = openTasks.filter { filter.matches(it, todayStart, todayEnd) }
|
|
||||||
val ids = matching.mapTo(HashSet()) { it.taskId }
|
|
||||||
FilterOverview(filter, matching.count { !it.isSubtask || it.parentId !in ids })
|
|
||||||
}
|
|
||||||
return ListsUiState.Content(
|
return ListsUiState.Content(
|
||||||
smartCounts = smartCounts,
|
smartCounts = smartCounts,
|
||||||
filters = filters,
|
|
||||||
groups = groups,
|
groups = groups,
|
||||||
todayDone = completedDueToday,
|
todayDone = completedDueToday,
|
||||||
todayTotal = todayTotal,
|
todayTotal = todayTotal,
|
||||||
upcoming = upcoming,
|
upcoming = upcoming,
|
||||||
allTasks = allTasks,
|
allTasks = openTasks + completedTasks,
|
||||||
tags = tags,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen
|
|||||||
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel
|
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel
|
||||||
import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen
|
import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel
|
import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel
|
||||||
import de.jeanlucmakiola.agendula.ui.filters.FilterEditScreen
|
|
||||||
import de.jeanlucmakiola.agendula.ui.filters.FilterEditViewModel
|
|
||||||
import de.jeanlucmakiola.agendula.ui.imports.ImportScreen
|
import de.jeanlucmakiola.agendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
|
import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
|
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
|
||||||
@@ -62,9 +60,6 @@ fun AgendulaNavHost(
|
|||||||
is NavRequest.OpenList -> nav.navigate(Dest.TaskList.build(TaskFilter.OfList(navRequest.listId))) {
|
is NavRequest.OpenList -> nav.navigate(Dest.TaskList.build(TaskFilter.OfList(navRequest.listId))) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
is NavRequest.OpenFilter -> nav.navigate(Dest.TaskList.build(TaskFilter.Saved(navRequest.filterId))) {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
|
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
|
||||||
}
|
}
|
||||||
onNavRequestConsumed()
|
onNavRequestConsumed()
|
||||||
@@ -90,7 +85,6 @@ fun AgendulaNavHost(
|
|||||||
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
|
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
|
||||||
onOpenTask = { task -> nav.navigate(task.detailRoute()) },
|
onOpenTask = { task -> nav.navigate(task.detailRoute()) },
|
||||||
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
|
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
|
||||||
onNewFilter = { nav.navigate(Dest.FilterEdit.build()) },
|
|
||||||
onOpenSettings = { nav.navigate(Dest.SETTINGS) },
|
onOpenSettings = { nav.navigate(Dest.SETTINGS) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,8 +114,6 @@ fun AgendulaNavHost(
|
|||||||
val filter = Dest.TaskList.filterOf(
|
val filter = Dest.TaskList.filterOf(
|
||||||
smart = args?.getString(Dest.TaskList.ARG_SMART),
|
smart = args?.getString(Dest.TaskList.ARG_SMART),
|
||||||
listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID,
|
listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID,
|
||||||
tag = args?.getString(Dest.TaskList.ARG_TAG),
|
|
||||||
filterId = args?.getLong(Dest.TaskList.ARG_FILTER_ID) ?: Dest.NO_ID,
|
|
||||||
)
|
)
|
||||||
val vm: TaskListViewModel = hiltViewModel()
|
val vm: TaskListViewModel = hiltViewModel()
|
||||||
LaunchedEffect(filter) { vm.bind(filter) }
|
LaunchedEffect(filter) { vm.bind(filter) }
|
||||||
@@ -132,15 +124,9 @@ fun AgendulaNavHost(
|
|||||||
viewModel = vm,
|
viewModel = vm,
|
||||||
onOpenTask = onOpenTask,
|
onOpenTask = onOpenTask,
|
||||||
onNewTask = {
|
onNewTask = {
|
||||||
nav.navigate(
|
val presetListId = (filter as? TaskFilter.OfList)?.listId
|
||||||
Dest.TaskEdit.buildNew(
|
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId))
|
||||||
presetListId = (filter as? TaskFilter.OfList)?.listId,
|
|
||||||
tag = (filter as? TaskFilter.Tagged)?.tag,
|
|
||||||
filterId = (filter as? TaskFilter.Saved)?.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onEditFilter = { id -> nav.navigate(Dest.FilterEdit.build(id)) },
|
|
||||||
onBack = { nav.popBackStack() },
|
onBack = { nav.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -153,32 +139,6 @@ fun AgendulaNavHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(Dest.FilterEdit.route, arguments = Dest.FilterEdit.arguments) { entry ->
|
|
||||||
val filterId = entry.arguments?.getLong(Dest.FilterEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
|
|
||||||
val vm: FilterEditViewModel = hiltViewModel()
|
|
||||||
LaunchedEffect(filterId) { vm.bind(filterId) }
|
|
||||||
|
|
||||||
ReadableWidth {
|
|
||||||
FilterEditScreen(
|
|
||||||
viewModel = vm,
|
|
||||||
isNew = filterId == null,
|
|
||||||
onSaved = { id ->
|
|
||||||
// A different id means the edited filter was gone and a new one was made.
|
|
||||||
if (id != filterId) {
|
|
||||||
// A new filter opens straight away, with the editor off the stack.
|
|
||||||
nav.navigate(Dest.TaskList.build(TaskFilter.Saved(id))) {
|
|
||||||
popUpTo(Dest.FilterEdit.route) { inclusive = true }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
nav.popBackStack()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDeleted = { nav.popBackStack(Dest.LISTS, inclusive = false) },
|
|
||||||
onBack = { nav.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry ->
|
composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry ->
|
||||||
val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID
|
val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID
|
||||||
val occurrence = entry.arguments?.getLong(Dest.TaskDetail.ARG_OCC)?.takeIf { it != Dest.NO_ID }
|
val occurrence = entry.arguments?.getLong(Dest.TaskDetail.ARG_OCC)?.takeIf { it != Dest.NO_ID }
|
||||||
@@ -206,8 +166,6 @@ fun AgendulaNavHost(
|
|||||||
val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID
|
val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID
|
||||||
val occurrence = args?.getLong(Dest.TaskEdit.ARG_OCCURRENCE)?.takeIf { it != Dest.NO_ID }
|
val occurrence = args?.getLong(Dest.TaskEdit.ARG_OCCURRENCE)?.takeIf { it != Dest.NO_ID }
|
||||||
val title = args?.getString(Dest.TaskEdit.ARG_TITLE)
|
val title = args?.getString(Dest.TaskEdit.ARG_TITLE)
|
||||||
val tag = args?.getString(Dest.TaskEdit.ARG_TAG)
|
|
||||||
val filterId = args?.getLong(Dest.TaskEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
|
|
||||||
val vm: TaskEditViewModel = hiltViewModel()
|
val vm: TaskEditViewModel = hiltViewModel()
|
||||||
LaunchedEffect(taskId, presetListId, parentId) {
|
LaunchedEffect(taskId, presetListId, parentId) {
|
||||||
if (taskId != Dest.NO_ID) {
|
if (taskId != Dest.NO_ID) {
|
||||||
@@ -217,8 +175,6 @@ fun AgendulaNavHost(
|
|||||||
presetListId = presetListId.takeIf { it != Dest.NO_ID },
|
presetListId = presetListId.takeIf { it != Dest.NO_ID },
|
||||||
parentId = parentId.takeIf { it != Dest.NO_ID },
|
parentId = parentId.takeIf { it != Dest.NO_ID },
|
||||||
initialTitle = title,
|
initialTitle = title,
|
||||||
presetTag = tag,
|
|
||||||
presetFilterId = filterId,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,51 +53,30 @@ object Dest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One [TaskFilter]'s tasks. Carries one of `listId` (a real list), `smart`
|
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
|
||||||
* (a [SmartList] name), `tag` or `filterId` (a saved filter); whichever is
|
* `smart` (a [SmartList] name); whichever is set decides the filter.
|
||||||
* set decides the filter.
|
|
||||||
*/
|
*/
|
||||||
object TaskList {
|
object TaskList {
|
||||||
const val ARG_LIST_ID = "listId"
|
const val ARG_LIST_ID = "listId"
|
||||||
const val ARG_SMART = "smart"
|
const val ARG_SMART = "smart"
|
||||||
const val ARG_TAG = "tag"
|
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}"
|
||||||
const val ARG_FILTER_ID = "filterId"
|
|
||||||
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}&$ARG_TAG={$ARG_TAG}" +
|
|
||||||
"&$ARG_FILTER_ID={$ARG_FILTER_ID}"
|
|
||||||
|
|
||||||
val arguments = listOf(
|
val arguments = listOf(
|
||||||
navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||||
navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null },
|
navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||||
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
|
|
||||||
navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun build(filter: TaskFilter): String = when (filter) {
|
fun build(filter: TaskFilter): String = when (filter) {
|
||||||
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
|
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
|
||||||
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
|
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
|
||||||
is TaskFilter.Tagged -> "taskList?$ARG_TAG=${android.net.Uri.encode(filter.tag)}"
|
|
||||||
is TaskFilter.Saved -> "taskList?$ARG_FILTER_ID=${filter.id}"
|
|
||||||
// Several lists at once only exists for the widget, which never routes here.
|
// Several lists at once only exists for the widget, which never routes here.
|
||||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reconstruct the filter from a destination's arguments. */
|
/** Reconstruct the filter from a destination's arguments. */
|
||||||
fun filterOf(smart: String?, listId: Long, tag: String? = null, filterId: Long = NO_ID): TaskFilter = when {
|
fun filterOf(smart: String?, listId: Long): TaskFilter =
|
||||||
smart != null -> TaskFilter.Smart(SmartList.valueOf(smart))
|
if (smart != null) TaskFilter.Smart(SmartList.valueOf(smart))
|
||||||
tag != null -> TaskFilter.Tagged(tag)
|
else TaskFilter.OfList(listId)
|
||||||
filterId != NO_ID -> TaskFilter.Saved(filterId)
|
|
||||||
else -> TaskFilter.OfList(listId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Creates a saved filter, or edits the one `filterId` names. */
|
|
||||||
object FilterEdit {
|
|
||||||
const val ARG_FILTER_ID = "filterId"
|
|
||||||
const val route = "filterEdit?$ARG_FILTER_ID={$ARG_FILTER_ID}"
|
|
||||||
val arguments = listOf(navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID })
|
|
||||||
|
|
||||||
fun build(filterId: Long? = null): String =
|
|
||||||
if (filterId == null) "filterEdit" else "filterEdit?$ARG_FILTER_ID=$filterId"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single task's detail; `occ` (epoch millis) picks one occurrence of a series. */
|
/** A single task's detail; `occ` (epoch millis) picks one occurrence of a series. */
|
||||||
@@ -118,8 +97,7 @@ object Dest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
|
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
|
||||||
* task, `presetListId` / `parentId` optionally seed the form, and `filterId`
|
* task, `presetListId` / `parentId` optionally seed the form.
|
||||||
* fills it so the task lands in that saved filter.
|
|
||||||
*/
|
*/
|
||||||
object TaskEdit {
|
object TaskEdit {
|
||||||
const val ARG_TASK_ID = "taskId"
|
const val ARG_TASK_ID = "taskId"
|
||||||
@@ -127,16 +105,12 @@ object Dest {
|
|||||||
const val ARG_PARENT_ID = "parentId"
|
const val ARG_PARENT_ID = "parentId"
|
||||||
const val ARG_OCCURRENCE = "occ"
|
const val ARG_OCCURRENCE = "occ"
|
||||||
const val ARG_TITLE = "title"
|
const val ARG_TITLE = "title"
|
||||||
const val ARG_TAG = "tag"
|
|
||||||
const val ARG_FILTER_ID = "filterId"
|
|
||||||
const val route =
|
const val route =
|
||||||
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
|
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
|
||||||
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
|
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
|
||||||
"&$ARG_PARENT_ID={$ARG_PARENT_ID}" +
|
"&$ARG_PARENT_ID={$ARG_PARENT_ID}" +
|
||||||
"&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" +
|
"&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" +
|
||||||
"&$ARG_TITLE={$ARG_TITLE}" +
|
"&$ARG_TITLE={$ARG_TITLE}"
|
||||||
"&$ARG_TAG={$ARG_TAG}" +
|
|
||||||
"&$ARG_FILTER_ID={$ARG_FILTER_ID}"
|
|
||||||
|
|
||||||
val arguments = listOf(
|
val arguments = listOf(
|
||||||
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||||
@@ -144,27 +118,17 @@ object Dest {
|
|||||||
navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||||
navArgument(ARG_OCCURRENCE) { type = NavType.LongType; defaultValue = NO_ID },
|
navArgument(ARG_OCCURRENCE) { type = NavType.LongType; defaultValue = NO_ID },
|
||||||
navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null },
|
navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||||
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
|
|
||||||
navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
|
fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
|
||||||
if (occurrenceStart == null) "taskEdit?$ARG_TASK_ID=$taskId"
|
if (occurrenceStart == null) "taskEdit?$ARG_TASK_ID=$taskId"
|
||||||
else "taskEdit?$ARG_TASK_ID=$taskId&$ARG_OCCURRENCE=$occurrenceStart"
|
else "taskEdit?$ARG_TASK_ID=$taskId&$ARG_OCCURRENCE=$occurrenceStart"
|
||||||
|
|
||||||
fun buildNew(
|
fun buildNew(presetListId: Long? = null, parentId: Long? = null, title: String? = null): String {
|
||||||
presetListId: Long? = null,
|
|
||||||
parentId: Long? = null,
|
|
||||||
title: String? = null,
|
|
||||||
tag: String? = null,
|
|
||||||
filterId: Long? = null,
|
|
||||||
): String {
|
|
||||||
val params = buildList {
|
val params = buildList {
|
||||||
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
|
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
|
||||||
if (parentId != null) add("$ARG_PARENT_ID=$parentId")
|
if (parentId != null) add("$ARG_PARENT_ID=$parentId")
|
||||||
if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}")
|
if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}")
|
||||||
if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}")
|
|
||||||
if (filterId != null) add("$ARG_FILTER_ID=$filterId")
|
|
||||||
}
|
}
|
||||||
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
|
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ sealed interface NavRequest {
|
|||||||
/** One real list — a widget header. */
|
/** One real list — a widget header. */
|
||||||
data class OpenList(val listId: Long) : NavRequest
|
data class OpenList(val listId: Long) : NavRequest
|
||||||
|
|
||||||
/** One saved filter — a widget header. */
|
|
||||||
data class OpenFilter(val filterId: Long) : NavRequest
|
|
||||||
|
|
||||||
/** An `.ics` another app handed over. */
|
/** An `.ics` another app handed over. */
|
||||||
data class Import(val uri: Uri) : NavRequest
|
data class Import(val uri: Uri) : NavRequest
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -49,8 +48,6 @@ import androidx.compose.material.icons.rounded.Edit
|
|||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
import androidx.compose.material.icons.rounded.ExpandMore
|
||||||
import androidx.compose.material.icons.rounded.Flag
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Badge
|
|
||||||
import androidx.compose.material3.BadgedBox
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -60,19 +57,14 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.MediumTopAppBar
|
import androidx.compose.material3.MediumTopAppBar
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.Sort
|
||||||
import androidx.compose.material.icons.rounded.Repeat
|
import androidx.compose.material.icons.rounded.Repeat
|
||||||
import androidx.compose.material.icons.rounded.Sell
|
|
||||||
import androidx.compose.material.icons.rounded.Tune
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.SwipeToDismissBox
|
import androidx.compose.material3.SwipeToDismissBox
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||||
import de.jeanlucmakiola.floret.components.revealOnFocus
|
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
|
||||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -140,7 +132,6 @@ fun TaskListScreen(
|
|||||||
onNewTask: () -> Unit,
|
onNewTask: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onEditFilter: (Long) -> Unit = {},
|
|
||||||
viewModel: TaskListViewModel = hiltViewModel(),
|
viewModel: TaskListViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -159,9 +150,6 @@ fun TaskListScreen(
|
|||||||
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
|
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
|
||||||
// The list this screen is about is gone; there is nothing left to show.
|
// The list this screen is about is gone; there is nothing left to show.
|
||||||
LaunchedEffect(listDeleted) { if (listDeleted) onBack() }
|
LaunchedEffect(listDeleted) { if (listDeleted) onBack() }
|
||||||
val savedFilter = content?.savedFilter
|
|
||||||
val filterGone = filter is TaskFilter.Saved && content?.filter == filter && savedFilter == null
|
|
||||||
LaunchedEffect(filterGone) { if (filterGone) onBack() }
|
|
||||||
LaunchedEffect(listWriteFailure) {
|
LaunchedEffect(listWriteFailure) {
|
||||||
if (listWriteFailure != null) {
|
if (listWriteFailure != null) {
|
||||||
delay(4_000)
|
delay(4_000)
|
||||||
@@ -190,7 +178,7 @@ fun TaskListScreen(
|
|||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
title = { Text(titleFor(filter, listName ?: savedFilter?.name)) },
|
title = { Text(titleFor(filter, listName)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -201,14 +189,7 @@ fun TaskListScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { showSort = true }, enabled = content != null) {
|
IconButton(onClick = { showSort = true }, enabled = content != null) {
|
||||||
BadgedBox(badge = { if (content?.selectedTags?.isNotEmpty() == true) Badge() }) {
|
Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title))
|
||||||
Icon(Icons.Rounded.Tune, contentDescription = stringResource(R.string.view_options_title))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (savedFilter != null) {
|
|
||||||
IconButton(onClick = { onEditFilter(savedFilter.id) }) {
|
|
||||||
Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.filter_edit_title))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (list != null) {
|
if (list != null) {
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -296,13 +277,12 @@ fun TaskListScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showSort && content != null) {
|
if (showSort && content != null) {
|
||||||
ViewOptionsPicker(
|
OptionPicker(
|
||||||
sortOrder = content.sortOrder,
|
title = stringResource(R.string.sort_title),
|
||||||
tags = content.availableTags,
|
options = TaskSortOrder.entries,
|
||||||
selectedTags = content.selectedTags,
|
selected = content.sortOrder,
|
||||||
onSortOrder = viewModel::setSortOrder,
|
label = { stringResource(sortLabel(it)) },
|
||||||
onToggleTag = viewModel::toggleTagFilter,
|
onSelect = { viewModel.setSortOrder(it); showSort = false },
|
||||||
onClearTags = viewModel::clearTagFilter,
|
|
||||||
onDismiss = { showSort = false },
|
onDismiss = { showSort = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -344,7 +324,7 @@ fun TaskListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TaskListBody(
|
private fun TaskListBody(
|
||||||
state: TaskListUiState.Content,
|
state: TaskListUiState.Content,
|
||||||
@@ -369,8 +349,7 @@ private fun TaskListBody(
|
|||||||
val listId = (filter as? TaskFilter.OfList)?.listId
|
val listId = (filter as? TaskFilter.OfList)?.listId
|
||||||
// In a mixed view (a smart list) the list colour bar + name disambiguate
|
// In a mixed view (a smart list) the list colour bar + name disambiguate
|
||||||
// rows; inside a single real list both are redundant.
|
// rows; inside a single real list both are redundant.
|
||||||
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged ||
|
val showListName = filter is TaskFilter.Smart
|
||||||
(filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1)
|
|
||||||
// Completed tasks collapse away by default to keep the active list in focus.
|
// Completed tasks collapse away by default to keep the active list in focus.
|
||||||
var completedExpanded by rememberSaveable { mutableStateOf(false) }
|
var completedExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
// Parents the user expanded to reveal their subtasks inline. Children are
|
// Parents the user expanded to reveal their subtasks inline. Children are
|
||||||
@@ -400,16 +379,8 @@ private fun TaskListBody(
|
|||||||
// When the bottom quick-add bar is shown it already lives in the scaffold's
|
// When the bottom quick-add bar is shown it already lives in the scaffold's
|
||||||
// bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB.
|
// bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB.
|
||||||
val bottomBarShown = state.bottomAddBar && listId != null
|
val bottomBarShown = state.bottomAddBar && listId != null
|
||||||
// While typing, the viewport ends at the keyboard (or at the quick-add bar
|
|
||||||
// riding on it), so a focused add-subtask row can scroll fully above it.
|
|
||||||
val imeVisible = WindowInsets.isImeVisible
|
|
||||||
val viewport = when {
|
|
||||||
!imeVisible -> Modifier
|
|
||||||
bottomBarShown -> Modifier.padding(bottom = inner.calculateBottomPadding())
|
|
||||||
else -> Modifier.imePadding()
|
|
||||||
}
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().then(viewport),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
top = inner.calculateTopPadding() + 8.dp,
|
top = inner.calculateTopPadding() + 8.dp,
|
||||||
bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp,
|
bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp,
|
||||||
@@ -688,7 +659,7 @@ internal fun TaskRowContent(
|
|||||||
val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
|
val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
|
||||||
// Everything secondary collapses into one quiet supporting line below the title.
|
// Everything secondary collapses into one quiet supporting line below the title.
|
||||||
val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 ||
|
val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 ||
|
||||||
listName != null || task.isOccurrence || task.tags.isNotEmpty()
|
listName != null || task.isOccurrence
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
@@ -743,7 +714,6 @@ internal fun TaskRowContent(
|
|||||||
subtaskTotal = subtaskTotal,
|
subtaskTotal = subtaskTotal,
|
||||||
listName = listName,
|
listName = listName,
|
||||||
listColor = task.effectiveColor,
|
listColor = task.effectiveColor,
|
||||||
tags = task.tags,
|
|
||||||
dark = dark,
|
dark = dark,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -775,7 +745,6 @@ private fun TaskMetaLine(
|
|||||||
subtaskTotal: Int,
|
subtaskTotal: Int,
|
||||||
listName: String?,
|
listName: String?,
|
||||||
listColor: Int,
|
listColor: Int,
|
||||||
tags: List<String>,
|
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
) {
|
) {
|
||||||
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -831,28 +800,6 @@ private fun TaskMetaLine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (tags.isNotEmpty()) {
|
|
||||||
add {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.Sell,
|
|
||||||
contentDescription = stringResource(R.string.edit_tags_label),
|
|
||||||
tint = muted,
|
|
||||||
modifier = Modifier.size(13.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
tags.joinToString(", "),
|
|
||||||
style = style,
|
|
||||||
color = muted,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (listName != null) {
|
if (listName != null) {
|
||||||
add {
|
add {
|
||||||
Text(
|
Text(
|
||||||
@@ -905,7 +852,7 @@ private fun AddSubtaskRow(
|
|||||||
shape = cardShape(position, 22.dp, 6.dp),
|
shape = cardShape(position, 22.dp, 6.dp),
|
||||||
// Same tone as the subtask rows it closes, so the group reads as one unit.
|
// Same tone as the subtask rows it closes, so the group reads as one unit.
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap).revealOnFocus(),
|
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
|
||||||
@@ -1222,65 +1169,11 @@ private fun CenteredMessage(text: String, inner: PaddingValues) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sort and tag filter in one place. Stays open while choosing, since several
|
|
||||||
* tags can be ticked; back leaves. A task must carry every ticked tag.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ViewOptionsPicker(
|
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
|
||||||
sortOrder: TaskSortOrder,
|
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
|
||||||
tags: List<String>,
|
|
||||||
selectedTags: Set<String>,
|
|
||||||
onSortOrder: (TaskSortOrder) -> Unit,
|
|
||||||
onToggleTag: (String) -> Unit,
|
|
||||||
onClearTags: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
FullScreenPicker(
|
|
||||||
title = stringResource(R.string.view_options_title),
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
actions = {
|
|
||||||
if (selectedTags.isNotEmpty()) {
|
|
||||||
TextButton(onClick = onClearTags) { Text(stringResource(R.string.view_filter_clear)) }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
GroupedSectionHeader(stringResource(R.string.sort_title))
|
|
||||||
TaskSortOrder.entries.forEachIndexed { index, order ->
|
|
||||||
val isSelected = order == sortOrder
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(sortLabel(order)),
|
|
||||||
position = positionOf(index, TaskSortOrder.entries.size),
|
|
||||||
selected = isSelected,
|
|
||||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
|
||||||
onClick = { onSortOrder(order) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (tags.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
GroupedSectionHeader(stringResource(R.string.view_filter_tags))
|
|
||||||
tags.forEachIndexed { index, tag ->
|
|
||||||
val isSelected = tag in selectedTags
|
|
||||||
GroupedRow(
|
|
||||||
title = tag,
|
|
||||||
position = positionOf(index, tags.size),
|
|
||||||
selected = isSelected,
|
|
||||||
leading = { Icon(Icons.Rounded.Sell, contentDescription = null) },
|
|
||||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
|
||||||
onClick = { onToggleTag(tag) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun titleFor(filter: TaskFilter, name: String?): String = when (filter) {
|
|
||||||
is TaskFilter.OfList -> name ?: stringResource(R.string.tasks_title)
|
|
||||||
is TaskFilter.Saved -> name.orEmpty()
|
|
||||||
// Several lists at once only exists for the widget, which never routes here.
|
// Several lists at once only exists for the widget, which never routes here.
|
||||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||||
is TaskFilter.Tagged -> filter.tag
|
|
||||||
is TaskFilter.Smart -> stringResource(
|
is TaskFilter.Smart -> stringResource(
|
||||||
when (filter.list) {
|
when (filter.list) {
|
||||||
SmartList.TODAY -> R.string.smart_today
|
SmartList.TODAY -> R.string.smart_today
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
|||||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
|
||||||
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||||
@@ -58,14 +55,6 @@ sealed interface TaskListUiState {
|
|||||||
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
|
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
|
||||||
/** This real list is kept out of the smart lists. */
|
/** This real list is kept out of the smart lists. */
|
||||||
val hiddenFromSmartLists: Boolean = false,
|
val hiddenFromSmartLists: Boolean = false,
|
||||||
/** Tags the unnarrowed view carries, offered as filter chips. */
|
|
||||||
val availableTags: List<String> = emptyList(),
|
|
||||||
/** The tags [tasks] is narrowed to; a task must carry all of them. */
|
|
||||||
val selectedTags: Set<String> = emptySet(),
|
|
||||||
/** The definition behind a [TaskFilter.Saved]; `null` once it's deleted. */
|
|
||||||
val savedFilter: SavedFilter? = null,
|
|
||||||
/** The filter this content was built for; a reused screen briefly holds the previous one's. */
|
|
||||||
val filter: TaskFilter? = null,
|
|
||||||
) : TaskListUiState
|
) : TaskListUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +83,6 @@ class TaskListViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val filter = MutableStateFlow<TaskFilter?>(null)
|
private val filter = MutableStateFlow<TaskFilter?>(null)
|
||||||
|
|
||||||
private val tagFilter = MutableStateFlow<Set<String>>(emptySet())
|
|
||||||
|
|
||||||
/** Occurrences swiped to delete but not yet committed — hidden until the undo lapses. */
|
/** Occurrences swiped to delete but not yet committed — hidden until the undo lapses. */
|
||||||
private val pendingDeletes = MutableStateFlow<Map<String, Task>>(emptyMap())
|
private val pendingDeletes = MutableStateFlow<Map<String, Task>>(emptyMap())
|
||||||
|
|
||||||
@@ -114,33 +101,20 @@ class TaskListViewModel @Inject constructor(
|
|||||||
combine(tasks, repository.taskLists()) { rows, lists ->
|
combine(tasks, repository.taskLists()) { rows, lists ->
|
||||||
TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId })
|
TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId })
|
||||||
}
|
}
|
||||||
is TaskFilter.Smart, is TaskFilter.Tagged ->
|
is TaskFilter.Smart ->
|
||||||
tasks.map { TaskListUiState.Content(it) }
|
tasks.map { TaskListUiState.Content(it) }
|
||||||
is TaskFilter.Saved ->
|
|
||||||
repository.filteredTasks(f).map {
|
|
||||||
TaskListUiState.Content(it.tasks, savedFilter = it.savedFilter)
|
|
||||||
}
|
|
||||||
// Several lists at once only exists for the widget, which never routes here.
|
// Several lists at once only exists for the widget, which never routes here.
|
||||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||||
}
|
}
|
||||||
// Drop rows pending an undoable delete so the row vanishes on swipe
|
// Drop rows pending an undoable delete so the row vanishes on swipe
|
||||||
// while the actual provider delete waits for the snackbar to commit,
|
// while the actual provider delete waits for the snackbar to commit,
|
||||||
// and fold in the live UI settings (add-subtask row, bottom add bar).
|
// and fold in the live UI settings (add-subtask row, bottom add bar).
|
||||||
combine(content, pendingDeletes, settingsPrefs.settings, tagFilter) { st, pending, settings, tags ->
|
combine(content, pendingDeletes, settingsPrefs.settings) { st, pending, settings ->
|
||||||
if (st is TaskListUiState.Content) {
|
if (st is TaskListUiState.Content) {
|
||||||
val visible = settings.visibleIn(f, st.tasks).filter { it.occurrenceKey !in pending }
|
|
||||||
val available = tagsInUse(visible) - setOfNotNull((f as? TaskFilter.Tagged)?.tag)
|
|
||||||
// A tag that vanished can no longer be unticked, so it is dropped
|
|
||||||
// for good rather than narrowing again when it comes back.
|
|
||||||
val selected = tags.filterTo(LinkedHashSet()) { it in available }
|
|
||||||
if (selected.size != tags.size) tagFilter.value = selected
|
|
||||||
st.copy(
|
st.copy(
|
||||||
filter = f,
|
tasks = settings.visibleIn(f, st.tasks)
|
||||||
tasks = visible
|
.filter { it.occurrenceKey !in pending }
|
||||||
.filter { TaskFiltering.hasAllTags(it, selected) }
|
|
||||||
.sortedWith(settings.sortOrder.comparator()),
|
.sortedWith(settings.sortOrder.comparator()),
|
||||||
availableTags = available,
|
|
||||||
selectedTags = selected,
|
|
||||||
showAddSubtaskRow = settings.showAddSubtaskRow,
|
showAddSubtaskRow = settings.showAddSubtaskRow,
|
||||||
bottomAddBar = settings.bottomAddBar,
|
bottomAddBar = settings.bottomAddBar,
|
||||||
sortOrder = settings.sortOrder,
|
sortOrder = settings.sortOrder,
|
||||||
@@ -189,12 +163,6 @@ class TaskListViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
||||||
|
|
||||||
fun toggleTagFilter(tag: String) {
|
|
||||||
tagFilter.value = if (tag in tagFilter.value) tagFilter.value - tag else tagFilter.value + tag
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearTagFilter() { tagFilter.value = emptySet() }
|
|
||||||
|
|
||||||
fun toggleComplete(task: Task) {
|
fun toggleComplete(task: Task) {
|
||||||
if (task.isCompleted) {
|
if (task.isCompleted) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -169,12 +169,6 @@ private fun TaskWidgetBody(
|
|||||||
val ready = data as? TaskWidgetData.Ready
|
val ready = data as? TaskWidgetData.Ready
|
||||||
val title = when (filter) {
|
val title = when (filter) {
|
||||||
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
|
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
|
||||||
is TaskFilter.Tagged -> filter.tag
|
|
||||||
is TaskFilter.Saved -> if (ready == null) {
|
|
||||||
context.getString(R.string.widget_tasks_label)
|
|
||||||
} else {
|
|
||||||
ready.filterName ?: context.getString(R.string.filter_missing)
|
|
||||||
}
|
|
||||||
is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
|
is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
|
||||||
?: context.getString(R.string.widget_tasks_label)
|
?: context.getString(R.string.widget_tasks_label)
|
||||||
}
|
}
|
||||||
@@ -285,8 +279,6 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo
|
|||||||
// still opens straight to it, otherwise this just raises the app.
|
// still opens straight to it, otherwise this just raises the app.
|
||||||
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
|
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
|
||||||
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
is TaskFilter.Tagged -> Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
is TaskFilter.Saved -> MainActivity.filterIntent(context, filter.id)
|
|
||||||
}
|
}
|
||||||
WidgetHeader(
|
WidgetHeader(
|
||||||
title = title,
|
title = title,
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import de.jeanlucmakiola.agendula.data.prefs.Settings
|
|||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
@@ -119,7 +118,6 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
val settings by settingsPrefs.settings.collectAsState(initial = Settings())
|
val settings by settingsPrefs.settings.collectAsState(initial = Settings())
|
||||||
val lists by remember { listsFlow }.collectAsState(initial = emptyList())
|
val lists by remember { listsFlow }.collectAsState(initial = emptyList())
|
||||||
val savedFilters by settingsPrefs.savedFilters.collectAsState(initial = emptyList())
|
|
||||||
var current by remember { mutableStateOf<TaskFilter?>(null) }
|
var current by remember { mutableStateOf<TaskFilter?>(null) }
|
||||||
var groupByList by remember { mutableStateOf(false) }
|
var groupByList by remember { mutableStateOf(false) }
|
||||||
var background by remember { mutableStateOf(WidgetBackground.SOLID) }
|
var background by remember { mutableStateOf(WidgetBackground.SOLID) }
|
||||||
@@ -145,7 +143,6 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
|||||||
WidgetFilterPicker(
|
WidgetFilterPicker(
|
||||||
kind = kind,
|
kind = kind,
|
||||||
lists = lists.filter { it.isVisible },
|
lists = lists.filter { it.isVisible },
|
||||||
savedFilters = savedFilters,
|
|
||||||
dark = dark,
|
dark = dark,
|
||||||
selected = current ?: DEFAULT_WIDGET_FILTER,
|
selected = current ?: DEFAULT_WIDGET_FILTER,
|
||||||
groupByList = groupByList,
|
groupByList = groupByList,
|
||||||
@@ -196,7 +193,6 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
|||||||
private fun WidgetFilterPicker(
|
private fun WidgetFilterPicker(
|
||||||
kind: WidgetKind,
|
kind: WidgetKind,
|
||||||
lists: List<TaskList>,
|
lists: List<TaskList>,
|
||||||
savedFilters: List<SavedFilter>,
|
|
||||||
selected: TaskFilter,
|
selected: TaskFilter,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
groupByList: Boolean,
|
groupByList: Boolean,
|
||||||
@@ -215,25 +211,21 @@ private fun WidgetFilterPicker(
|
|||||||
var showBackgroundPicker by remember { mutableStateOf(false) }
|
var showBackgroundPicker by remember { mutableStateOf(false) }
|
||||||
var showThemePicker by remember { mutableStateOf(false) }
|
var showThemePicker by remember { mutableStateOf(false) }
|
||||||
var showRowColorPicker by remember { mutableStateOf(false) }
|
var showRowColorPicker by remember { mutableStateOf(false) }
|
||||||
// Smart lists, saved filters and real lists are mutually exclusive; only real
|
// Smart lists and real lists are mutually exclusive; only real lists support
|
||||||
// lists support picking more than one. `selectedSmart` or `selectedFilter`
|
// picking more than one. `selectedLists` empty + `selectedSmart` non-null is
|
||||||
// non-null is that pick; otherwise (or before the stored filter loads) it's
|
// a smart-list pick; the reverse (or both empty, before the stored filter
|
||||||
// a list pick.
|
// loads) is a list pick.
|
||||||
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
|
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
|
||||||
var selectedFilter by remember(selected) { mutableStateOf((selected as? TaskFilter.Saved)?.id) }
|
|
||||||
var selectedLists by remember(selected) {
|
var selectedLists by remember(selected) {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
when (selected) {
|
when (selected) {
|
||||||
is TaskFilter.OfList -> setOf(selected.listId)
|
is TaskFilter.OfList -> setOf(selected.listId)
|
||||||
is TaskFilter.OfLists -> selected.listIds
|
is TaskFilter.OfLists -> selected.listIds
|
||||||
is TaskFilter.Smart, is TaskFilter.Tagged, is TaskFilter.Saved -> emptySet()
|
is TaskFilter.Smart -> emptySet()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// A widget whose filter was deleted keeps the dead id until something else is picked.
|
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || selectedLists.isNotEmpty()
|
||||||
val liveFilter = selectedFilter?.takeIf { id -> savedFilters.any { it.id == id } }
|
|
||||||
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || liveFilter != null ||
|
|
||||||
selectedLists.isNotEmpty()
|
|
||||||
|
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
|
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
|
||||||
@@ -247,11 +239,8 @@ private fun WidgetFilterPicker(
|
|||||||
// a smart list, has to clear it here instead; otherwise it's
|
// a smart list, has to clear it here instead; otherwise it's
|
||||||
// stuck on with no way left to see or undo it.
|
// stuck on with no way left to see or undo it.
|
||||||
if (selectedLists.size <= 1 && groupByList) onGroupByListChange(false)
|
if (selectedLists.size <= 1 && groupByList) onGroupByListChange(false)
|
||||||
when {
|
selectedSmart?.let { onSave(TaskFilter.Smart(it)) }
|
||||||
selectedSmart != null -> onSave(TaskFilter.Smart(selectedSmart!!))
|
?: onSave(TaskFilter.OfLists(selectedLists))
|
||||||
liveFilter != null -> onSave(TaskFilter.Saved(liveFilter))
|
|
||||||
else -> onSave(TaskFilter.OfLists(selectedLists))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { Text(stringResource(android.R.string.ok)) }
|
) { Text(stringResource(android.R.string.ok)) }
|
||||||
},
|
},
|
||||||
@@ -265,22 +254,9 @@ private fun WidgetFilterPicker(
|
|||||||
position = positionOf(index, WIDGET_SMART_LISTS.size),
|
position = positionOf(index, WIDGET_SMART_LISTS.size),
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||||
onClick = { selectedSmart = smart; selectedFilter = null; selectedLists = emptySet() },
|
onClick = { selectedSmart = smart; selectedLists = emptySet() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (savedFilters.isNotEmpty()) {
|
|
||||||
GroupedSectionHeader(stringResource(R.string.filters_header))
|
|
||||||
savedFilters.forEachIndexed { index, filter ->
|
|
||||||
val isSelected = filter.id == selectedFilter
|
|
||||||
GroupedRow(
|
|
||||||
title = filter.name,
|
|
||||||
position = positionOf(index, savedFilters.size),
|
|
||||||
selected = isSelected,
|
|
||||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
|
||||||
onClick = { selectedFilter = filter.id; selectedSmart = null; selectedLists = emptySet() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lists.isNotEmpty()) {
|
if (lists.isNotEmpty()) {
|
||||||
GroupedSectionHeader(stringResource(R.string.lists_header))
|
GroupedSectionHeader(stringResource(R.string.lists_header))
|
||||||
lists.forEachIndexed { index, list ->
|
lists.forEachIndexed { index, list ->
|
||||||
@@ -293,7 +269,6 @@ private fun WidgetFilterPicker(
|
|||||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedSmart = null
|
selectedSmart = null
|
||||||
selectedFilter = null
|
|
||||||
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
|
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -100,8 +100,6 @@ internal fun TaskFilter.widgetStorageValue(): String = when (this) {
|
|||||||
is TaskFilter.OfList -> "list:$listId"
|
is TaskFilter.OfList -> "list:$listId"
|
||||||
is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}"
|
is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}"
|
||||||
is TaskFilter.Smart -> "smart:${list.name}"
|
is TaskFilter.Smart -> "smart:${list.name}"
|
||||||
is TaskFilter.Tagged -> error("A tag has no widget")
|
|
||||||
is TaskFilter.Saved -> "filter:$id"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Anything unreadable, or a smart list the widget doesn't offer, falls back to Today. */
|
/** Anything unreadable, or a smart list the widget doesn't offer, falls back to Today. */
|
||||||
@@ -113,8 +111,6 @@ internal fun parseWidgetFilter(raw: String?): TaskFilter {
|
|||||||
value.startsWith("lists:") ->
|
value.startsWith("lists:") ->
|
||||||
value.removePrefix("lists:").split(",").mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }
|
value.removePrefix("lists:").split(",").mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }
|
||||||
.toSet().takeIf { it.isNotEmpty() }?.let { TaskFilter.OfLists(it) }
|
.toSet().takeIf { it.isNotEmpty() }?.let { TaskFilter.OfLists(it) }
|
||||||
value.startsWith("filter:") ->
|
|
||||||
value.removePrefix("filter:").toLongOrNull()?.takeIf { it > 0 }?.let { TaskFilter.Saved(it) }
|
|
||||||
value.startsWith("smart:") ->
|
value.startsWith("smart:") ->
|
||||||
WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) }
|
WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) }
|
||||||
else -> null
|
else -> null
|
||||||
@@ -165,14 +161,12 @@ sealed interface TaskWidgetData {
|
|||||||
val dynamicColor: Boolean,
|
val dynamicColor: Boolean,
|
||||||
val themeMode: ThemeMode,
|
val themeMode: ThemeMode,
|
||||||
val use24Hour: Boolean,
|
val use24Hour: Boolean,
|
||||||
/** The saved filter's name when [filter] is one; `null` once it's deleted. */
|
|
||||||
val filterName: String? = null,
|
|
||||||
) : TaskWidgetData {
|
) : TaskWidgetData {
|
||||||
/** The one list's id, when [filter] names exactly one real list — seeds a widget's add button. */
|
/** The one list's id, when [filter] names exactly one real list — seeds a widget's add button. */
|
||||||
val singleListId: Long? = when (filter) {
|
val singleListId: Long? = when (filter) {
|
||||||
is TaskFilter.OfList -> filter.listId
|
is TaskFilter.OfList -> filter.listId
|
||||||
is TaskFilter.OfLists -> filter.listIds.singleOrNull()
|
is TaskFilter.OfLists -> filter.listIds.singleOrNull()
|
||||||
is TaskFilter.Smart, is TaskFilter.Tagged, is TaskFilter.Saved -> null
|
is TaskFilter.Smart -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [singleListId]'s name. */
|
/** [singleListId]'s name. */
|
||||||
@@ -277,11 +271,10 @@ internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): F
|
|||||||
val ep = widgetEntryPoint()
|
val ep = widgetEntryPoint()
|
||||||
val repository = ep.tasksRepository()
|
val repository = ep.tasksRepository()
|
||||||
return combine(
|
return combine(
|
||||||
repository.filteredTasks(filter),
|
repository.tasks(filter),
|
||||||
repository.taskLists(),
|
repository.taskLists(),
|
||||||
ep.settingsPrefs().settings,
|
ep.settingsPrefs().settings,
|
||||||
) { filtered, lists, settings ->
|
) { tasks, lists, settings ->
|
||||||
val tasks = filtered.tasks
|
|
||||||
val listNames = lists.associate { it.id to it.name }
|
val listNames = lists.associate { it.id to it.name }
|
||||||
val data: TaskWidgetData = TaskWidgetData.Ready(
|
val data: TaskWidgetData = TaskWidgetData.Ready(
|
||||||
filter = filter,
|
filter = filter,
|
||||||
@@ -298,7 +291,6 @@ internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): F
|
|||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
themeMode = settings.themeMode,
|
themeMode = settings.themeMode,
|
||||||
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
|
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
|
||||||
filterName = filtered.savedFilter?.name,
|
|
||||||
)
|
)
|
||||||
data
|
data
|
||||||
}.catch { emit(TaskWidgetData.Unavailable) }
|
}.catch { emit(TaskWidgetData.Unavailable) }
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
<string name="tasks_title">المهام</string>
|
<string name="tasks_title">المهام</string>
|
||||||
<string name="task_list_empty">لا مهام هنا بعد.</string>
|
<string name="task_list_empty">لا مهام هنا بعد.</string>
|
||||||
<string name="task_list_failure">تعذَّر تحميل هذه المهام.</string>
|
<string name="task_list_failure">تعذَّر تحميل هذه المهام.</string>
|
||||||
<string name="smart_no_date">بلا موعد</string>
|
<string name="smart_no_date">لا تاريخ</string>
|
||||||
<string name="smart_completed">المكتملة</string>
|
<string name="smart_completed">مكتمل</string>
|
||||||
<string name="task_detail_title">المهمة</string>
|
<string name="task_detail_title">المهمة</string>
|
||||||
<string name="task_detail_not_found">لم تعد هذه المهمة موجودة.</string>
|
<string name="task_detail_not_found">لم تعد هذه المهمة موجودة.</string>
|
||||||
<string name="edit">تعديل</string>
|
<string name="edit">تعديل</string>
|
||||||
@@ -26,641 +26,11 @@
|
|||||||
<string name="section_overdue">متأخر</string>
|
<string name="section_overdue">متأخر</string>
|
||||||
<string name="section_today">اليوم</string>
|
<string name="section_today">اليوم</string>
|
||||||
<string name="section_upcoming">القادم</string>
|
<string name="section_upcoming">القادم</string>
|
||||||
<string name="section_no_date">بلا موعد</string>
|
<string name="section_no_date">لا تاريخ</string>
|
||||||
<string name="section_completed">مكتمل</string>
|
<string name="section_completed">مكتمل</string>
|
||||||
<string name="priority_none">لا شئ</string>
|
<string name="priority_none">لا شئ</string>
|
||||||
<string name="priority_low">منخفض</string>
|
<string name="priority_low">منخفض</string>
|
||||||
<string name="priority_medium">متوسط</string>
|
<string name="priority_medium">متوسط</string>
|
||||||
<string name="priority_high">مرتفع</string>
|
<string name="priority_high">مرتفع</string>
|
||||||
<string name="detail_list">القائمة</string>
|
<string name="detail_list">القائمة</string>
|
||||||
<string name="add_account_step_of">الخطوة %1$d من %2$d</string>
|
|
||||||
<string name="onboarding_welcome_title">كل شئ كنت تقصد لفعله</string>
|
|
||||||
<string name="onboarding_welcome_body">مكان هادئ لمهامك، على هاتف يظل ملكًا لك. لنقم بإعداده — سيستغرق الأمر دقيقة.</string>
|
|
||||||
<string name="onboarding_welcome_lists_title">القوائم والمهام الفرعية</string>
|
|
||||||
<string name="onboarding_welcome_lists_body">ابقي الفصل بين العمل والمنزل والمشروع، وقسّم المهام الكبيرة.</string>
|
|
||||||
<string name="onboarding_welcome_reminders_title">التذكيرات التي تصل</string>
|
|
||||||
<string name="onboarding_welcome_reminders_body">إشعار عندما يكون شيء ما مستحق، وفقًا لجدولك الزمني.</string>
|
|
||||||
<string name="onboarding_welcome_sync_title">خادمك، أو لا شيء على الإطلاق</string>
|
|
||||||
<string name="add_account_browser_confirm_body">توجد صفحة تسجيل الدخول على خادمك، ليس في Agendula — لذا من القيّم إلقاء نظرة عليها قبل كتابة كلمة مرور حسابك هناك.</string>
|
|
||||||
<string name="onboarding_welcome_button">ابدأ</string>
|
|
||||||
<string name="reminder_onboarding_title">ابقى على اطلاع بمهامك</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="settings_title">الإعدادات</string>
|
|
||||||
<string name="settings_section_appearance">المظهر</string>
|
|
||||||
<string name="settings_about_source">المصدر</string>
|
|
||||||
<string name="settings_license">الترخيص</string>
|
|
||||||
<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_group_data">البيانات</string>
|
|
||||||
<string name="settings_group_app">التطبيق</string>
|
|
||||||
<string name="settings_group_about">حول</string>
|
|
||||||
<string name="settings_time_format_auto">تلقائي</string>
|
|
||||||
<string name="settings_week_start_auto">تلقائي</string>
|
|
||||||
<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_about_support">دَعم التطوير</string>
|
|
||||||
<string name="settings_about_version">الإصدار %1$s</string>
|
|
||||||
<string name="settings_language">لغة التطبيق</string>
|
|
||||||
<string name="settings_language_auto">افتراضي النظام</string>
|
|
||||||
<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_exact_alarms">التوقيت الدقيق</string>
|
|
||||||
<string name="settings_default_list">القائمة الافتراضية</string>
|
|
||||||
<string name="settings_time_format">تنسيق الوقت</string>
|
|
||||||
<string name="settings_time_format_24h">٢٤-ساعة (١٤:٠٠)</string>
|
|
||||||
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
|
|
||||||
<string name="settings_privacy">سياسة الخصوصية</string>
|
|
||||||
<string name="settings_default_reminder_allday">مهام يوم كامل</string>
|
|
||||||
<string name="settings_reliable_delivery">توصيل موثوق</string>
|
|
||||||
<string name="settings_snooze_duration">مدة التأجيل</string>
|
|
||||||
<string name="settings_task_store">مخزن المهام</string>
|
|
||||||
<string name="settings_store_permission_denied">رُفِض الإذن</string>
|
|
||||||
<string name="settings_copy_running">جارٍ نسخ المهام…</string>
|
|
||||||
<string name="settings_export">تصدير المهام</string>
|
|
||||||
<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_group_look">المظهر و السلوك</string>
|
|
||||||
<string name="settings_bottom_add_bar">شريط الإضافة السريع السفلي</string>
|
|
||||||
<string name="settings_time_format_12h">١٢-ساعة (٢:٠٠ م)</string>
|
|
||||||
<string name="settings_week_start">الأسبوع يبدأ في</string>
|
|
||||||
<string name="settings_licences">تراخيص مفتوحة المصدر</string>
|
|
||||||
<string name="settings_notifications_blocked">الإشعارات متوقفة</string>
|
|
||||||
<string name="settings_store_own">على هذا الجهاز</string>
|
|
||||||
<string name="settings_allday_reminder_fires_at">يتم الإشعار في %1$s</string>
|
|
||||||
<string name="settings_store_external">تطبيق مهام آخر</string>
|
|
||||||
<string name="settings_store_switch_title">تبديل مخزن المهام؟</string>
|
|
||||||
<string name="settings_copy_confirm_title">نسخ المهام؟</string>
|
|
||||||
<string name="settings_legacy_import_retrying">جارٍ نقلهم الآن…</string>
|
|
||||||
<string name="settings_report_problem_hint">افتح متعقب المشكلات</string>
|
|
||||||
<string name="settings_copy_from_external">نسخ المهام من %1$s</string>
|
|
||||||
<string name="task_completed">مكتمل</string>
|
|
||||||
<string name="clear_completed_action">مسح</string>
|
|
||||||
<string name="edit_list_label">القائمة</string>
|
|
||||||
<string name="edit_all_day">طوال اليوم</string>
|
|
||||||
<string name="edit_set">تعيين</string>
|
|
||||||
<string name="edit_clear">مسح</string>
|
|
||||||
<string name="edit_more_fields">المزيد من الخانات</string>
|
|
||||||
<string name="close">إغلاق</string>
|
|
||||||
<string name="dialog_cancel">إلغاء</string>
|
|
||||||
<string name="reminder_none">لا شئ</string>
|
|
||||||
<string name="reminder_custom">مخصص</string>
|
|
||||||
<string name="reminder_custom_set">تعيين</string>
|
|
||||||
<string name="list_color_purple">بنفسجي</string>
|
|
||||||
<string name="list_color_pink">وردي</string>
|
|
||||||
<string name="accounts_lists">القوائم</string>
|
|
||||||
<string name="shortcut_new_task">مهمة جديدة</string>
|
|
||||||
<string name="sort_title">فرز حسب</string>
|
|
||||||
<string name="sort_priority">الأولوية</string>
|
|
||||||
<string name="sort_title_alpha">العنوان</string>
|
|
||||||
<string name="sort_created">المُضاف مؤخرًا</string>
|
|
||||||
<string name="cd_repeats">تكرارات</string>
|
|
||||||
<string name="clear_completed_title">مسح المهام المكتملة؟</string>
|
|
||||||
<plurals name="clear_completed_body">
|
|
||||||
<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="clear_completed_confirm">حذف</string>
|
|
||||||
<string name="sort_due">موعد الاستحقاق</string>
|
|
||||||
<string name="detail_due">مستحق</string>
|
|
||||||
<string name="detail_priority">الأولوية</string>
|
|
||||||
<string name="detail_progress">التقدم</string>
|
|
||||||
<string name="detail_subtasks">المهام الفرعية</string>
|
|
||||||
<string name="detail_parent">المهمة الأم</string>
|
|
||||||
<string name="detail_part_of">جزء من</string>
|
|
||||||
<string name="percent_complete">%1$d%%</string>
|
|
||||||
<string name="edit_start_label">يبدأ</string>
|
|
||||||
<string name="edit_due_label">مستحق</string>
|
|
||||||
<string name="edit_priority_label">الأولوية</string>
|
|
||||||
<string name="edit_progress_label">التقدم</string>
|
|
||||||
<string name="edit_parent_label">المهمة الأم</string>
|
|
||||||
<string name="edit_parent_none">لا شيء (المستوى الأعلى)</string>
|
|
||||||
<string name="edit_parent_search">البحث عن المهام</string>
|
|
||||||
<string name="edit_parent_empty">لا مهام مطابقة في هذه القائمة.</string>
|
|
||||||
<string name="edit_reminder_label">التذكير</string>
|
|
||||||
<string name="edit_title_hint">عنوان المهمة</string>
|
|
||||||
<string name="edit_conflict_title">المهمة غُيِّرت في مكان آخر</string>
|
|
||||||
<string name="edit_conflict_body">حُدِّثت هذه المهمة في مكان ما آخر (مزامنة أو تطبيق آخر) منذ قمت بفتحها. الكتابة فوق هذه التغييرات بتعديلاتك؟</string>
|
|
||||||
<string name="edit_conflict_overwrite">استبدال</string>
|
|
||||||
<string name="reminder_at_due">في وقت المهمة</string>
|
|
||||||
<string name="reminder_on_the_day">في اليوم</string>
|
|
||||||
<string name="detail_pane_empty">اختر مهمة لرؤيتها هنا</string>
|
|
||||||
<string name="reminder_custom_with_value">مخصص (%1$s)</string>
|
|
||||||
<string name="reminder_custom_amount">أدخل قيمةً</string>
|
|
||||||
<string name="reminder_use_default">استخدام الافتراضي</string>
|
|
||||||
<string name="reminder_unit_minutes">دقائق</string>
|
|
||||||
<string name="reminder_unit_hours">ساعات</string>
|
|
||||||
<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="lists_reorder_handle">اسحب لإعادة الترتيب</string>
|
|
||||||
<string name="settings_list_notifications_hint">الصوت، الاهتزاز و الأهمية لتذكيرات هذه القائمة</string>
|
|
||||||
<string name="smart_today">اليوم</string>
|
|
||||||
<string name="home_due_today">اليوم</string>
|
|
||||||
<string name="reminder_day_today">اليوم</string>
|
|
||||||
<string name="recurrence_title">التكرار</string>
|
|
||||||
<string name="recurrence_every">كل</string>
|
|
||||||
<string name="recurrence_daily">كل يوم</string>
|
|
||||||
<string name="recurrence_weekly">كل أسبوع</string>
|
|
||||||
<string name="recurrence_monthly">كل شهر</string>
|
|
||||||
<string name="recurrence_yearly">كل سنة</string>
|
|
||||||
<plurals name="accounts_sync_every_hours">
|
|
||||||
<item quantity="zero">كل %d ساعة</item>
|
|
||||||
<item quantity="one">كل ساعة</item>
|
|
||||||
<item quantity="two">كل ساعتان</item>
|
|
||||||
<item quantity="few">كل %d ساعات</item>
|
|
||||||
<item quantity="many">كل %d ساعة</item>
|
|
||||||
<item quantity="other">كل %d ساعة</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="recurrence_every_n_days">
|
|
||||||
<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="recurrence_every_n_weeks">
|
|
||||||
<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="recurrence_every_n_months">
|
|
||||||
<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="recurrence_every_n_years">
|
|
||||||
<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="accounts_sync_every_minutes">
|
|
||||||
<item quantity="zero">كل %d دقيقة</item>
|
|
||||||
<item quantity="one">كل %d دقيقة</item>
|
|
||||||
<item quantity="two">كل %d دقيقتان</item>
|
|
||||||
<item quantity="few">كل %d دقائق</item>
|
|
||||||
<item quantity="many">كل %d دقيقة</item>
|
|
||||||
<item quantity="other">كل %d دقيقة</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="lists_header">القوائم</string>
|
|
||||||
<string name="smart_upcoming">القادمة</string>
|
|
||||||
<string name="smart_all">الكل</string>
|
|
||||||
<string name="smart_overdue">المتأخرة</string>
|
|
||||||
<string name="home_due_tomorrow">غدًا</string>
|
|
||||||
<string name="recurrence_custom">مخصص</string>
|
|
||||||
<string name="recurrence_unit_days">أيام</string>
|
|
||||||
<string name="recurrence_unit_weeks">أسابيع</string>
|
|
||||||
<string name="recurrence_unit_months">شهور</string>
|
|
||||||
<string name="recurrence_unit_years">سنوات</string>
|
|
||||||
<string name="recurrence_ends">ينتهي</string>
|
|
||||||
<string name="recurrence_end_never">أبدًا</string>
|
|
||||||
<string name="recurrence_times">مرّات</string>
|
|
||||||
<string name="edit_recurrence_label">التكرار</string>
|
|
||||||
<string name="edit_location_label">الموقع</string>
|
|
||||||
<string name="edit_url_label">الرابط</string>
|
|
||||||
<string name="edit_url_hint">https://…</string>
|
|
||||||
<string name="edit_discard_confirm">تجاهل</string>
|
|
||||||
<string name="progress_fraction_compact">%1$d/%2$d</string>
|
|
||||||
<string name="detail_status_cancelled">ملغي</string>
|
|
||||||
<string name="detail_duplicate">تكرار</string>
|
|
||||||
<string name="list_color">اللون</string>
|
|
||||||
<string name="list_color_mauve">بنفسجي باهت</string>
|
|
||||||
<string name="list_color_red">أحمر</string>
|
|
||||||
<string name="list_color_orange">برتقالي</string>
|
|
||||||
<string name="list_color_amber">أصفر كهرماني</string>
|
|
||||||
<string name="list_color_olive">زيتوني</string>
|
|
||||||
<string name="list_color_green">أخضر</string>
|
|
||||||
<string name="list_color_teal">أخضر مزرق</string>
|
|
||||||
<string name="list_color_cyan">سماوي</string>
|
|
||||||
<string name="list_color_blue">أزرق</string>
|
|
||||||
<string name="list_color_indigo">نيلي</string>
|
|
||||||
<string name="reminder_day_tomorrow">غدًا</string>
|
|
||||||
<string name="reminder_day_yesterday">يوم أمس</string>
|
|
||||||
<string name="reminder_action_done">تم</string>
|
|
||||||
<string name="onboarding_list_default_name">شخصي</string>
|
|
||||||
<string name="onboarding_backup_continue">متابعة</string>
|
|
||||||
<string name="onboarding_quick_add_done">إنهاء</string>
|
|
||||||
<string name="settings_store_switch_confirm">تبديل</string>
|
|
||||||
<string name="settings_copy_confirm_action">نسخ</string>
|
|
||||||
<string name="export_running">جارٍ التصدير…</string>
|
|
||||||
<string name="export_open">فتح</string>
|
|
||||||
<string name="import_open_storage">التخزين</string>
|
|
||||||
<string name="import_running">جارٍ الاستيراد…</string>
|
|
||||||
<string name="import_button">استيراد</string>
|
|
||||||
<string name="accounts_removing">جارٍ الإزالة…</string>
|
|
||||||
<string name="add_account_summary_done">تم</string>
|
|
||||||
<string name="sync_notice_body">%1$s</string>
|
|
||||||
<string name="sync_notice_retry">إعادة المحاولة</string>
|
|
||||||
<string name="add_account_server_hint">https://cloud.example.com</string>
|
|
||||||
<string name="add_account_continue">متابعة</string>
|
|
||||||
<string name="new_task">مهمة جديدة</string>
|
|
||||||
<string name="home_upcoming_view_all">عرض الكل</string>
|
|
||||||
<string name="home_search_hint">ابحث عن المهام</string>
|
|
||||||
<string name="home_search_clear">مسح البحث</string>
|
|
||||||
<string name="reminder_channel_name">تذكيرات المهمة</string>
|
|
||||||
<string name="reminder_onboarding_enable_button">تفعيل التذكيرات</string>
|
|
||||||
<string name="reminder_onboarding_skip_button">ليس الآن</string>
|
|
||||||
<string name="app_name">Agendula</string>
|
|
||||||
<string name="recurrence_generic">مهمة متكرّرة</string>
|
|
||||||
<string name="recurrence_incomplete">أدخل رقمًا من ١ إلى ٩٩٩</string>
|
|
||||||
<string name="recurrence_next">التالي: %1$s</string>
|
|
||||||
<string name="recurrence_next_none">هذه القاعدة لا تتكرر أبدًا</string>
|
|
||||||
<string name="recurrence_end_until">في موعد</string>
|
|
||||||
<string name="recurrence_end_count">بعد عدد من المرّات</string>
|
|
||||||
<string name="edit_discard_title">تجاهل التغييرات؟</string>
|
|
||||||
<string name="edit_keep_editing">متابعة التعديل</string>
|
|
||||||
<string name="edit_reminder_add">إضافة تذكير</string>
|
|
||||||
<string name="edit_reminder_remove">إزالة التذكير</string>
|
|
||||||
<string name="detail_delete_title">حذف المهمة؟</string>
|
|
||||||
<string name="detail_status_in_process">قيد التقدم</string>
|
|
||||||
<string name="more_options">المزيد من الخيارات</string>
|
|
||||||
<string name="detail_cancel_task">إلغاء المهمة</string>
|
|
||||||
<string name="detail_restore">استعادة المهمة</string>
|
|
||||||
<string name="list_add">قائمة جديدة</string>
|
|
||||||
<string name="list_new_title">قائمة جديدة</string>
|
|
||||||
<string name="list_edit_title">تعديل القائمة</string>
|
|
||||||
<string name="list_name_hint">اسم القائمة</string>
|
|
||||||
<string name="list_delete">حذف القائمة</string>
|
|
||||||
<string name="list_delete_confirm_title">حذف القائمة؟</string>
|
|
||||||
<string name="onboarding_sync_skip">ليس الآن</string>
|
|
||||||
<string name="onboarding_list_create">إنشاء قائمة</string>
|
|
||||||
<string name="licences_title">التراخيص مفتوحة المصدر</string>
|
|
||||||
<string name="sync_notification_channel">جارٍ المزامنة</string>
|
|
||||||
<string name="add_account_password_label">كلمة المرور</string>
|
|
||||||
<string name="add_account_lists_read_only">للقراءة فقط</string>
|
|
||||||
<string name="widget_tasks_label">مهام</string>
|
|
||||||
<string name="filter_section_priority">الأولوية</string>
|
|
||||||
<string name="filter_delete_confirm">حذف</string>
|
|
||||||
<string name="widget_today_label">اليوم</string>
|
|
||||||
<string name="edit_when">متى</string>
|
|
||||||
<string name="crash_report_issue_title">تقرير العطل</string>
|
|
||||||
<string name="import_title">استيراد المهام</string>
|
|
||||||
<string name="import_picked_file">الملف المُختار</string>
|
|
||||||
<string name="import_target_label">استيراد إلى</string>
|
|
||||||
<string name="import_new_list_default_name">المهام المستوردة</string>
|
|
||||||
<string name="accounts_remove">إزالة الحساب</string>
|
|
||||||
<string name="accounts_never_synced">لم تتم المزامنة مطلقًا</string>
|
|
||||||
<string name="accounts_last_sync">آخر مزامنة</string>
|
|
||||||
<string name="accounts_generic_provider">حساب CalDAV</string>
|
|
||||||
<string name="accounts_sync_now">زامن الآن</string>
|
|
||||||
<string name="add_account_email_label">عنوان البريد الإلكتروني</string>
|
|
||||||
<string name="add_account_credentials_title">تسجيل الدخول</string>
|
|
||||||
<string name="add_account_working_signing_in">جارٍ تسجيل الدخول</string>
|
|
||||||
<string name="sync_notice_channel_name">تقارير المزامنة</string>
|
|
||||||
<string name="sync_notices_dismiss">فهمت</string>
|
|
||||||
<string name="sync_notification_title">جارٍ مزامنة المهام</string>
|
|
||||||
<string name="add_account_server_label">عنوان الخادم</string>
|
|
||||||
<string name="add_account_start_over">البدء من جديد</string>
|
|
||||||
<string name="add_account_username_label">اسم المستخدم</string>
|
|
||||||
<string name="add_account_sign_in">تسجيل الدخول</string>
|
|
||||||
<string name="add_account_browser_retry">حاول مرة أخرى</string>
|
|
||||||
<string name="add_account_save">إضافة حساب</string>
|
|
||||||
<string name="edit_tags_label">الوسوم</string>
|
|
||||||
<string name="tags_header">الوسوم</string>
|
|
||||||
<string name="filter_section_tags">الوسوم</string>
|
|
||||||
<string name="edit_tags_hint">أضف وسمًا</string>
|
|
||||||
<string name="home_today_empty">لا شيء مستحق اليوم 🎉</string>
|
|
||||||
<plurals name="duration_minutes">
|
|
||||||
<item quantity="zero">(%d) لا دقائق</item>
|
|
||||||
<item quantity="one">%d دقيقة واحده</item>
|
|
||||||
<item quantity="two">%d دقيقتان</item>
|
|
||||||
<item quantity="few">%d دقائق</item>
|
|
||||||
<item quantity="many">%d دقيقة</item>
|
|
||||||
<item quantity="other">%d دقيقة</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="reminder_minutes">
|
|
||||||
<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="widget_up_next_in_hours">
|
|
||||||
<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="widget_up_next_in_days">
|
|
||||||
<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="duration_hours">
|
|
||||||
<item quantity="zero">(%d) لا ساعات</item>
|
|
||||||
<item quantity="one">%d ساعة واحده</item>
|
|
||||||
<item quantity="two">%d ساعتان</item>
|
|
||||||
<item quantity="few">%d ساعات</item>
|
|
||||||
<item quantity="many">%d ساعة</item>
|
|
||||||
<item quantity="other">%d ساعة</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="reminder_hours">
|
|
||||||
<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="widget_up_next_overdue_hours">
|
|
||||||
<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="widget_up_next_overdue_days">
|
|
||||||
<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="widget_up_next_soon">قريباً</string>
|
|
||||||
<string name="widget_up_next_tomorrow">غدًا</string>
|
|
||||||
<string name="widget_up_next_then">ثم</string>
|
|
||||||
<string name="widget_up_next_empty">لا شئ مستحق</string>
|
|
||||||
<string name="widget_up_next_any_list">جميع القوائم</string>
|
|
||||||
<string name="widget_today_nothing_due">لا شيء مستحق اليوم</string>
|
|
||||||
<plurals name="reminder_days">
|
|
||||||
<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="error_due_before_start">الاستحقاق لا يمكنه أن يكون قبل البدء.</string>
|
|
||||||
<string name="filter_section_due">الاستحقاق</string>
|
|
||||||
<string name="widget_section_overdue">متأخرة</string>
|
|
||||||
<string name="reminder_due_at">مستحق %1$s</string>
|
|
||||||
<string name="filter_any_due">أي موعد استحقاق</string>
|
|
||||||
<string name="view_options_title">الفرز و التصفية</string>
|
|
||||||
<string name="view_filter_clear">مسح عامل التصفية</string>
|
|
||||||
<string name="filters_header">عوامل التصفية</string>
|
|
||||||
<string name="filter_new_title">عامل تصفية جديد</string>
|
|
||||||
<string name="filter_edit_title">تعديل عامل تصفية</string>
|
|
||||||
<string name="filter_name_hint">اسم عامل التصفية</string>
|
|
||||||
<string name="filter_any_list">أي قائمة</string>
|
|
||||||
<string name="filter_any_tag">أي وسم، أو بلا</string>
|
|
||||||
<string name="filter_priority_any">أي أولوية</string>
|
|
||||||
<string name="filter_priority_low">منخفض وأعلى</string>
|
|
||||||
<string name="filter_priority_medium">متوسط وأعلى</string>
|
|
||||||
<string name="filter_priority_high">عالي فقط</string>
|
|
||||||
<string name="filter_delete_title">حذف ”%1$s“؟</string>
|
|
||||||
<string name="filter_delete">حذف عامل التصفية</string>
|
|
||||||
<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="recurrence_on_days">%1$s في %2$s</string>
|
|
||||||
<string name="recurrence_with_until">%1$s حتى %2$s</string>
|
|
||||||
<plurals name="recurrence_with_count">
|
|
||||||
<item quantity="zero">%1$s، %2$d مرة</item>
|
|
||||||
<item quantity="one">%1$s، %2$d مرة</item>
|
|
||||||
<item quantity="two">%1$s، %2$d مرتان</item>
|
|
||||||
<item quantity="few">%1$s، %2$d مرّات</item>
|
|
||||||
<item quantity="many">%1$s، %2$d مرة</item>
|
|
||||||
<item quantity="other">%1$s، %2$d مرة</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="recurrence_none">لا يتكرر</string>
|
|
||||||
<string name="recurring_scope_occurrence">فقط هذه المهمة</string>
|
|
||||||
<string name="recurring_scope_following">هذه والمهام اللاحقة</string>
|
|
||||||
<string name="recurring_scope_series">جميع المهام في السلسلة</string>
|
|
||||||
<string name="error_blank_title">أدخل عنوانًا.</string>
|
|
||||||
<string name="error_no_list">اختر قائمة.</string>
|
|
||||||
<string name="error_reminder_without_due">عيِّن موعد استحقاق لاستخدام التذكير.</string>
|
|
||||||
<string name="error_recurrence_without_date">تحتاج مهمة متكررة لموعد بدء أو استحقاق.</string>
|
|
||||||
<string name="edit_recurrence_none">لا يتكرر</string>
|
|
||||||
<string name="edit_tag_remove">إزالة الوسم %1$s</string>
|
|
||||||
<string name="edit_discard_body">لم يتم حفظ تغييراتك لهذه المهمة.</string>
|
|
||||||
<string name="edit_scope_title">حفظ التغييرات لمهمة متكررة</string>
|
|
||||||
<string name="edit_reminder_add_first">أضف تذكيرًا (بخلاف ذلك الافتراضي ينطبق)</string>
|
|
||||||
<string name="detail_reminder_before_start">%1$s (قبل البدء)</string>
|
|
||||||
<string name="subtask_progress">%1$d / %2$d</string>
|
|
||||||
<string name="detail_delete_body">سيتم حذف هذه المهمة.</string>
|
|
||||||
<plurals name="detail_delete_body_subtasks">
|
|
||||||
<item quantity="zero">سيتم حذف هذه المهمة و (%1$d) لا مهام الفرعية خاصّتها.</item>
|
|
||||||
<item quantity="one">سيتم حذف هذه المهمة و المهمة الفرعية خاصّتها.</item>
|
|
||||||
<item quantity="two">سيتم حذف هذه المهمة و المهمتان الفرعيتان خاصّتها.</item>
|
|
||||||
<item quantity="few">سيتم حذف هذه المهمة و الـ %1$d مهام الفرعية خاصّتها.</item>
|
|
||||||
<item quantity="many">سيتم حذف هذه المهمة و %1$d المهمة الفرعية خاصّتها.</item>
|
|
||||||
<item quantity="other">سيتم حذف هذه المهمة و %1$d المهمة الفرعية خاصّتها.</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="detail_delete_recurring_title">حذف المهمة المتكررة</string>
|
|
||||||
<string name="filters_empty_hint">اجمع بين مواعيد الاستحقاق، القوائم، الوسوم والأولوية إلى طريقة عرض من خاصّتك</string>
|
|
||||||
<string name="filter_match_all_off">تحتاج مهمة إلى إحدى الوسوم التي اخترتها</string>
|
|
||||||
<string name="filter_match_all_tags">مطابقة كل الوسوم</string>
|
|
||||||
<string name="home_today_all_done">انتهى كل شيء 🎉</string>
|
|
||||||
<string name="lists_failure">تعذّر قراءة مهامك.</string>
|
|
||||||
<string name="list_save_failed">تعذّر حفظ القائمة.</string>
|
|
||||||
<string name="list_delete_failed">تعذّر حذف القائمة.</string>
|
|
||||||
<string name="settings_copy_failed">تعذّر نسخ المهام. لا شيء تغيّر — لم يتم المساس بالنسخ الأصلية خاصّتك.</string>
|
|
||||||
<string name="settings_legacy_import_failed">تعذّر نقل مهامك السابقة</string>
|
|
||||||
<string name="export_failed">تعذّر كتابة التصدير</string>
|
|
||||||
<string name="export_failed_folder">تعذّر فتح المجلد المُختار</string>
|
|
||||||
<string name="export_failed_create">تعذّر إنشاء ملف في المجلد المُختار</string>
|
|
||||||
<string name="import_failed_unreadable">تعذّر قراءة الملف كـ iCalendar</string>
|
|
||||||
<string name="import_failed_write">تعذّر حفظ المهام</string>
|
|
||||||
<string name="add_account_error_not_saved">تعذّر حفظ الحساب.</string>
|
|
||||||
<string name="list_save_failed_offline">تعذّر الوصول للخادم. حاول مرة أخرى عندما تعود متصلًا.</string>
|
|
||||||
<string name="list_save_failed_read_only">تتم مشاركة هذه القائمة معك للقراءة فقط.</string>
|
|
||||||
<string name="list_save_failed_unsupported">لا يسمح هذا الحساب التطبيقات بإنشاء قوائم مهام.</string>
|
|
||||||
<string name="settings_legacy_import_failed_hint">لا تزال محفوظة ولا شئ فُقد. اضغط لمحاولة نقلهم مرة أخرى.</string>
|
|
||||||
<string name="export_failed_read_only">المجلد المختار غير قابل للكتابة</string>
|
|
||||||
<string name="export_failed_access">فُقد الوصول إلى الموقع المُختار</string>
|
|
||||||
<string name="import_failed_no_tasks">الملف لا يحتوي مهام</string>
|
|
||||||
<string name="accounts_sync_failed">لم تنتهي آخر مزامنة</string>
|
|
||||||
<string name="accounts_failed_sign_in">فشلت المزامنة — لم يقبل الخادم كلمة المرور</string>
|
|
||||||
<string name="accounts_failed_unreachable">فشلت المزامنة — تعذّر الوصول للخادم</string>
|
|
||||||
<string name="accounts_failed_certificate">فشلت المزامنة — شهادة الخادم غير موثوقة</string>
|
|
||||||
<string name="accounts_failed_server">فشلت المزامنة — أبلغ الخادم عن خطأ</string>
|
|
||||||
<string name="accounts_failed_server_code">فشلت المزامنة — أبلغ الخادم عن خطأ (%1$d)</string>
|
|
||||||
<string name="accounts_failed_misconfigured">فشلت المزامنة — الحساب غير مكتمل؛ أزله وأضفه مجددًا</string>
|
|
||||||
<string name="add_account_browser_failed_title">لم تنتهي عملية تسجيل الدخول</string>
|
|
||||||
<string name="add_account_browser_error_failed">لم ينتهي الخادم تسجيل دخولك. حاول مجددًا بعد لحظة.</string>
|
|
||||||
<string name="accounts_push_account_on">تصل التغييرات فورًا</string>
|
|
||||||
<string name="add_account_provider_other_header">أي خادم CalDAV</string>
|
|
||||||
<string name="add_account_provider_other">خادم CalDAV آخر</string>
|
|
||||||
<string name="add_account_lists_shared">تم مُشارك معك</string>
|
|
||||||
<string name="add_account_provider_title">أين مهامك؟</string>
|
|
||||||
<string name="add_account_email_title">ما عنوانك هناك؟</string>
|
|
||||||
<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_open">فتح صفحة تسجيل الدخول</string>
|
|
||||||
<string name="add_account_lists_title">أيّ القوائم يجب المزامنة؟</string>
|
|
||||||
<string name="add_account_provider_unsupported">لا دعم للمهام عبر CalDAV</string>
|
|
||||||
<string name="add_account_working_discovering">جارٍ البحث عن خادم CalDAV</string>
|
|
||||||
<string name="add_account_setup_icloud_title">يحتاج iCloud كلمة مرور خاصة بتطبيق</string>
|
|
||||||
<string name="add_account_setup_fastmail_title">يحتاج Fastmail كلمة مرور تطبيق</string>
|
|
||||||
<string name="add_account_no_lists_selected">اختر على الأقل قائمة واحدة</string>
|
|
||||||
<string name="add_account_error_already_exists">تم إعداد هذا الحساب بالفعل.</string>
|
|
||||||
<string name="add_account_error_keystore">لن يقوم هذا الجهاز بتخزين كلمة المرور.</string>
|
|
||||||
<string name="add_account_browser_confirm_title">تحقق من هذا قبل تسجيل الدخول</string>
|
|
||||||
<string name="add_account_error_credentials_rejected">لم يتم قبول اسم المستخدم أو كلمة المرور هذه.</string>
|
|
||||||
<plurals name="add_account_summary_lists">
|
|
||||||
<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="accounts_push_account_partial">
|
|
||||||
<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>
|
|
||||||
<string name="add_account_provider_body">اختر الخدمة التي تعيش عليها قوائم مهامك. يتزامن Agendula مع أي شيء يدعم CalDAV.</string>
|
|
||||||
<string name="add_account_email_body">عنوان البريد الإلكتروني الذي تستخدمه مع هذه الخدمة. يعثر Agendula على الخادم بنفسه.</string>
|
|
||||||
<string name="add_account_summary_body">ستبقي Agendula هذه القوائم في مزامنة من الآن فصاعدًا. يمكنك تغيير ما يُزامن في الإعدادات.</string>
|
|
||||||
<string name="add_account_server_body">أدخل عنوان الخادم CalDAV خاصّتك. عنوان بريد إلكتروني يعمل أيضًا، إذا كان الخادم ينشر نفسه تحت نطاقه.</string>
|
|
||||||
<string name="add_account_credentials_body">استخدم كلمة مرور تطبيق إذا كان المزود يقدم واحدة — يمكن إلغاؤها دون تغيير كلمة مرور حسابك.</string>
|
|
||||||
<string name="add_account_lists_body">اختر قوائم المهام للإبقاء في مزامنة. يمكنك تغيير هذا لاحقًا.</string>
|
|
||||||
<string name="add_account_error_not_an_address">هذا لا يبدو كعنوان ويب. جرّب شيئًا مثل cloud.example.com.</string>
|
|
||||||
<string name="add_account_error_not_dav">استجاب هذا العنوان، لكنه ليس خادم CalDAV. إذا قمت بلصق موقع الويب، فجرّب عنوان الخادم الذي يوفره المزود للتقويمات.</string>
|
|
||||||
<string name="add_account_error_no_calendar">لا يوفر هذا الخادم تقويمات. تحقق من العنوان مع مزودك.</string>
|
|
||||||
<string name="add_account_error_unreachable">تعذّر الوصول لهذا الخادم. تحقق من العنوان واتصالك.</string>
|
|
||||||
<string name="add_account_error_insecure">هذا العنوان غير مشفر. يرسل Agendula كلمة مرورك فقط عبر https.</string>
|
|
||||||
<string name="add_account_error_no_calendars">سُجِّل الدخول، لكن هذا الحساب لا يحتوي قوائم مهام.</string>
|
|
||||||
<string name="add_account_error_server">واجه الخادم مشكلة. حاول مجددًا بعد لحظة.</string>
|
|
||||||
<string name="add_account_working_saving">جارٍ إضافة الحساب</string>
|
|
||||||
<string name="add_account_error_google_unsupported">لا يدعم تقويم Google المهام عبر CalDAV — كما هو مذكور في مستنداته الخاصة — لذلك Agendula لا يمكنه المزامنة معه.</string>
|
|
||||||
<string name="add_account_error_cross_domain">يحتفظ هذا الخادم ببعض قوائم المهام على %1$s، التي لا يمكن لـ Agendula تسجيل الدخول إليها بعد.</string>
|
|
||||||
<string name="add_account_error_no_usable_lists">سُجِّل الدخول، لكن هذا الحساب لا يحتوي قوائم مهام يمكن أن يستخدمها Agendula.</string>
|
|
||||||
<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_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>
|
|
||||||
<string name="add_account_setup_icloud_footnote">لن تظهر المهام المحفوظة في iCloud في تطبيق تذكيرات Apple.</string>
|
|
||||||
<string name="add_account_setup_fastmail_footnote">لا يتم تضمين CalDAV في الخطة الأساسية لـ Fastmail.</string>
|
|
||||||
<string name="add_account_quirk_google">لا تدعم تقويم Google المهام عبر CalDAV.</string>
|
|
||||||
<string name="add_account_password_hint">استخدم كلمة مرور تطبيق إذا كان خادمك يوفر واحدة — يمكن إلغاؤها دون تغيير كلمة مرور حسابك.</string>
|
|
||||||
<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="settings_licences_subtitle">المشاريع التي بنيت Agendula عليها</string>
|
|
||||||
<string name="licences_intro">يتضمن Agendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به.</string>
|
|
||||||
<string name="licences_footer">يتم تضمين بعض من هذا الكود في شكل معدل. أين يكون هذا هو الحال، يتم إدراج كل تغيير في مستودع المصدر المرتبط أعلاه.</string>
|
|
||||||
<string name="import_pick">اختر ملفًا</string>
|
|
||||||
<string name="import_pick_other">اختر ملف آخر</string>
|
|
||||||
<string name="import_pick_another">اِسترد ملف آخر</string>
|
|
||||||
<string name="import_reading">جارٍ قراءة الملف…</string>
|
|
||||||
<plurals name="import_found">
|
|
||||||
<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="import_events_left_out">
|
|
||||||
<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="import_new_list">قائمة جديدة: %1$s</string>
|
|
||||||
<string name="import_new_list_hint">قائمة جديدة على هذا الجهاز</string>
|
|
||||||
<plurals name="import_done">
|
|
||||||
<item quantity="zero">تم استيراد (%1$d) لا مهام إلى %2$s</item>
|
|
||||||
<item quantity="one">تم استيراد %1$d مهمة إلى %2$s</item>
|
|
||||||
<item quantity="two">تم استيراد %1$d مهمتان إلى %2$s</item>
|
|
||||||
<item quantity="few">تم استيراد %1$d مهام إلى %2$s</item>
|
|
||||||
<item quantity="many">تم استيراد %1$d مهمة إلى %2$s</item>
|
|
||||||
<item quantity="other">تم استيراد %1$d مهمة إلى %2$s</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="import_done_skipped_existing">
|
|
||||||
<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="reminder_action_snooze">تأجيل %1$d دقيقة</string>
|
|
||||||
<string name="reminder_channel_desc">الإشعارات للمهام التي حان موعدها.</string>
|
|
||||||
<string name="reminder_onboarding_body">يقوم Agendula بإشعارك عندما يحين موعد شيء ما، لذلك لا داعٍ لأن تحتفظ بأي شيء في رأسك.</string>
|
|
||||||
<plurals name="reminder_weeks">
|
|
||||||
<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="reminder_benefit_delivery_body">كل مهمة مع موعد استحقاق تحصل على إشعارها الخاص.</string>
|
|
||||||
<string name="reminder_benefit_reversible_body">إيقاف تشغيل التذكيرات وقتما تشاء — أنه فقط مفتاح تبديل.</string>
|
|
||||||
<string name="lists_empty">لا قوائم مهام بعد.</string>
|
|
||||||
<string name="accounts_empty_title">لا حسابات حتى الآن</string>
|
|
||||||
<string name="filter_match_all_on">مهمة تحتاج كل وسم اخترته</string>
|
|
||||||
<string name="filter_delete_body">فقط عامل التصفية تذهب. تظل مهامه أينما هي.</string>
|
|
||||||
<string name="widget_empty">على آخر اطلاع بالكل</string>
|
|
||||||
<string name="widget_lists_empty">لا قوائم للعرض</string>
|
|
||||||
<string name="widget_section_later">لاحقة</string>
|
|
||||||
<string name="widget_section_no_date">بلا موعد</string>
|
|
||||||
<string name="widget_config_smart_lists">القوائم الذكية</string>
|
|
||||||
<string name="list_in_smart_lists">إظهار في القوائم الذكية</string>
|
|
||||||
<string name="list_in_smart_lists_hint">تضمين مهام هذه القائمة في اليوم، القادمة، الكل و القوائم الذكية الأخرى</string>
|
|
||||||
<string name="widget_tasks_description">مهام اليوم، أو أي قائمة، بضغطة لوضع علامة صح عليهم</string>
|
|
||||||
<string name="widget_empty_hint">لا شئ متبقى للقيام، استمتع به</string>
|
|
||||||
<string name="settings_time_format_auto_summary">اتباع النظام: %1$s</string>
|
|
||||||
<plurals name="settings_copy_done">
|
|
||||||
<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>
|
|
||||||
<string name="home_search_empty">لا مهام مطابقة لـ ”%1$s“</string>
|
|
||||||
<string name="list_save_failed_server">الخادم لن يقبل هذا التغيير.</string>
|
|
||||||
<string name="list_delete_confirm_message">سيتم حذف ”%1$s“ وجميع مهامه. هذا لا يمكن التراجع عنه.</string>
|
|
||||||
<string name="lists_empty_action">إنشاء قائمة</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">Agendula</string>
|
|
||||||
<string name="back">Zpět</string>
|
|
||||||
<string name="save">Uložit</string>
|
|
||||||
<string name="delete">Smazat</string>
|
|
||||||
<string name="tasks_title">Úkoly</string>
|
|
||||||
<string name="smart_completed">Dokončené</string>
|
|
||||||
<string name="task_detail_title">Úkol</string>
|
|
||||||
<string name="task_detail_not_found">Tento úkol již neexistuje.</string>
|
|
||||||
<string name="edit">Upravit</string>
|
|
||||||
<string name="field_description">Popis</string>
|
|
||||||
<string name="edit_save_failed">Nemohu uložit. Zkuste to znovu.</string>
|
|
||||||
<string name="add_task_hint">Přidat úkol</string>
|
|
||||||
<string name="task_list_empty">Zatím zde nejsou žádné úkoly.</string>
|
|
||||||
<string name="task_list_failure">Tyto úkoly se nepodařilo načíst.</string>
|
|
||||||
<string name="add_subtask_hint">Přidat dílčí úkol</string>
|
|
||||||
<string name="cd_add_task">Přidat úkol</string>
|
|
||||||
<string name="cd_complete">Hotovo</string>
|
|
||||||
<string name="section_overdue">Po termínu</string>
|
|
||||||
<string name="smart_overdue">Po termínu</string>
|
|
||||||
<string name="widget_section_overdue">Po termínu</string>
|
|
||||||
<string name="home_today_empty">Na dnešek žádné úkoly 🎉</string>
|
|
||||||
<string name="widget_today_nothing_due">Na dnešek žádné úkoly</string>
|
|
||||||
<string name="edit_parent_search">Hledat úkoly</string>
|
|
||||||
<string name="home_search_hint">Hledat úkoly</string>
|
|
||||||
<string name="home_search_empty">Nic nenalezeno pro “%1$s”</string>
|
|
||||||
<string name="smart_no_date">Bez termínu</string>
|
|
||||||
<string name="section_no_date">Bez termínu</string>
|
|
||||||
<string name="edit_conflict_body">Tento úkol byl mezitím změněn jinde (například v jiné aplikaci nebo při synchronizaci). Chcete tyto změny přepsat svými úpravami?</string>
|
|
||||||
<string name="reminder_benefit_delivery_body">Pro každý úkol s termínem splnění se vytvoří samostatné oznámení.</string>
|
|
||||||
<string name="edit_all_day">Celý den</string>
|
|
||||||
<string name="settings_allday_reminder_time">Čas upozornění u celodenních úkolů</string>
|
|
||||||
<string name="settings_default_reminder_allday">Celodenní úkoly</string>
|
|
||||||
<string name="edit_discard_body">Změny tohoto úkolu nebyly uloženy.</string>
|
|
||||||
<string name="edit_tags_label">Tagy</string>
|
|
||||||
<string name="edit_tags_hint">Přidat tag</string>
|
|
||||||
<string name="tags_header">Tagy</string>
|
|
||||||
<string name="filter_section_tags">Tagy</string>
|
|
||||||
<string name="edit_url_label">Odkaz</string>
|
|
||||||
<string name="edit_location_label">Poloha</string>
|
|
||||||
<string name="sort_priority">Priorita</string>
|
|
||||||
<string name="priority_none">Žádná</string>
|
|
||||||
<string name="priority_low">Nízká</string>
|
|
||||||
<string name="priority_medium">Střední</string>
|
|
||||||
<string name="priority_high">Vysoká</string>
|
|
||||||
<string name="detail_priority">Priorita</string>
|
|
||||||
<string name="edit_priority_label">Priorita</string>
|
|
||||||
<string name="filter_section_priority">Priorita</string>
|
|
||||||
<string name="filter_priority_high">Jen vysoká</string>
|
|
||||||
<string name="edit_more_fields">Další pole</string>
|
|
||||||
<string name="settings_form_fields_hint">Pole, která se zobrazí při vytváření nového úkolu. Ostatní jsou dostupná pod položkou „Další pole“.</string>
|
|
||||||
<string name="detail_progress">Stav dokončení</string>
|
|
||||||
<string name="edit_progress_label">Stav dokončení</string>
|
|
||||||
<string name="edit_discard_title">Zahodit změny?</string>
|
|
||||||
<string name="edit_discard_confirm">Zahodit</string>
|
|
||||||
<string name="edit_keep_editing">Pokračovat v úpravách</string>
|
|
||||||
<string name="settings_privacy">Ochrana soukromí</string>
|
|
||||||
<string name="settings_license">Licence</string>
|
|
||||||
<string name="settings_licences">Licence open source</string>
|
|
||||||
<string name="licences_title">Licence open source</string>
|
|
||||||
<string name="licences_intro">Agendula obsahuje níže uvedené knihovny a další komponenty. Klepnutím na kteroukoli položku zobrazíte její zdrojový kód.</string>
|
|
||||||
<string name="settings_about_source">Zdrojový kód</string>
|
|
||||||
<string name="settings_report_problem">Nahlásit problém</string>
|
|
||||||
<string name="settings_report_problem_hint">Otevřít systém pro hlášení chyb</string>
|
|
||||||
<string name="settings_language">Jazyk aplikace</string>
|
|
||||||
<string name="settings_language_auto">Podle systému</string>
|
|
||||||
<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_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>
|
|
||||||
<string name="settings_store_own_hint">Vlastní úložiště aplikace Agendula. Není třeba nic dalšího instalovat.</string>
|
|
||||||
<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_section_appearance">Vzhled</string>
|
|
||||||
<string name="settings_appearance_subtitle">Motiv, barva a 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>
|
|
||||||
<string name="settings_theme_dark">Tmavý</string>
|
|
||||||
<string name="widget_config_background_solid_hint">Použije pro widget barvu pozadí aktuálního motivu</string>
|
|
||||||
<string name="widget_config_theme">Motiv</string>
|
|
||||||
<string name="widget_config_theme_follow_app">Podle aplikace</string>
|
|
||||||
<string name="widget_config_theme_light">Světlý</string>
|
|
||||||
<string name="widget_config_theme_dark">Tmavý</string>
|
|
||||||
<string name="settings_dynamic_color">Dynamické barvy</string>
|
|
||||||
<string name="settings_dynamic_color_hint">Používat barvy z tapety</string>
|
|
||||||
<string name="settings_time_format">Formát času</string>
|
|
||||||
<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_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>
|
|
||||||
<string name="add_account_error_not_an_address">To nevypadá jako webová adresa. Zkuste zadat něco jako cloud.example.com.</string>
|
|
||||||
<string name="add_account_browser_confirm_body">Přihlašovací stránka se nachází na vašem serveru, ne v aplikaci Agendula. Než na ní zadáte heslo ke svému účtu, raději si ověřte, že jí můžete důvěřovat.</string>
|
|
||||||
<string name="add_account_working_discovering">Vyhledávání CalDAV serveru</string>
|
|
||||||
<string name="list_add">Nový seznam</string>
|
|
||||||
<string name="list_new_title">Nový seznam</string>
|
|
||||||
<string name="list_name_hint">Název seznamu</string>
|
|
||||||
<string name="list_read_only">Tento sdílený seznam je pouze pro čtení. Jeho název a barvu může měnit jen vlastník.</string>
|
|
||||||
<string name="onboarding_list_body">Seznamy vám pomohou udržet úkoly přehledně oddělené. Začněte s jedním – další můžete přidávat a tento kdykoli přejmenovat.</string>
|
|
||||||
<string name="import_new_list_default_name">Importované úkoly</string>
|
|
||||||
<string name="list_where">Kde</string>
|
|
||||||
<string name="list_where_device">Jen v tomto zařízení</string>
|
|
||||||
<string name="settings_copy_from_external_hint">Zkopírujte je do vlastního úložiště Agenduly. Jde o jednorázovou kopii – originály zůstanou tam, kde jsou.</string>
|
|
||||||
<string name="add_account_server_title">Kde jsou vaše úkoly?</string>
|
|
||||||
<string name="add_account_provider_title">Kde jsou vaše úkoly?</string>
|
|
||||||
<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>
|
|
||||||
</resources>
|
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
<string name="reminder_onboarding_body">Agendula benachrichtigt dich, wenn etwas fällig wird, also muss nichts in deinem Kopf leben.</string>
|
<string name="reminder_onboarding_body">Agendula benachrichtigt dich, wenn etwas fällig wird, also muss nichts in deinem Kopf leben.</string>
|
||||||
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
|
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
|
||||||
<string name="reminder_onboarding_skip_button">Nicht jetzt</string>
|
<string name="reminder_onboarding_skip_button">Nicht jetzt</string>
|
||||||
<string name="reminder_benefit_delivery_title">Ein kleiner Schubs, wenn es darauf ankommt</string>
|
<string name="reminder_benefit_delivery_title">Agendula erinnert dich</string>
|
||||||
<string name="reminder_benefit_delivery_body">Jede Aufgaben mit Fälligkeitsdatum bekommt seine eigene Benachrichtigung.</string>
|
<string name="reminder_benefit_delivery_body">Jede Aufgaben mit Fälligkeitsdatum bekommt seine eigene Benachrichtigung.</string>
|
||||||
<string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string>
|
<string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string>
|
||||||
<string name="reminder_benefit_timing_body">Wähle, wie weit im Voraus du erinnert werden möchtest — für alles oder pro Liste.</string>
|
<string name="reminder_benefit_timing_body">Wähle, wie weit im Voraus du erinnert werden möchtest — für alles oder pro Liste.</string>
|
||||||
@@ -409,38 +409,4 @@
|
|||||||
<string name="sync_notice_body">%1$s</string>
|
<string name="sync_notice_body">%1$s</string>
|
||||||
<string name="sync_notices_title">Änderungen durch letzte Synchronisierung</string>
|
<string name="sync_notices_title">Änderungen durch letzte Synchronisierung</string>
|
||||||
<string name="sync_notices_dismiss">Verstanden</string>
|
<string name="sync_notices_dismiss">Verstanden</string>
|
||||||
<string name="recurrence_title">Wiederholung</string>
|
|
||||||
<plurals name="recurrence_with_count">
|
|
||||||
<item quantity="one">%1$s, %2$d Mal</item>
|
|
||||||
<item quantity="other">%1$s, %2$d Mal</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="onboarding_exact_alarms_title">Genau rechtzeitig</string>
|
|
||||||
<string name="onboarding_exact_alarms_body">Android frägt dich, bevor dich eine App minutengenau benachrichtigen darf. Erlaube es und die Erinnerungen kommen, wenn sie fällig sind — ohne die Erlaubnis, könnten sich die Erinnerungen ein paar Minuten verspäten.</string>
|
|
||||||
<string name="onboarding_exact_alarms_allow">Erlaube genaues Timing</string>
|
|
||||||
<string name="settings_time_format_12h">12-Stunden (2:00 PM)</string>
|
|
||||||
<string name="settings_time_format_24h">24-Stunden (14:00)</string>
|
|
||||||
<string name="settings_time_format_auto_summary">Folgend dem System: %1$s</string>
|
|
||||||
<string name="settings_notifications_blocked_hint">Erinnerungen können dich nicht erreichen. Drücke, um Benachrichtigungen von Agendula zu erlauben.</string>
|
|
||||||
<string name="list_delete_confirm_message_synced">\"%1$s\" und alle Aufgaben davon werden von dem Server und jedem anderen Gerät, das damit verbunden ist, gelöscht. Das kann nicht rückgängig gemacht werden.</string>
|
|
||||||
<string name="onboarding_sync_devices_title">Jedes Gerät auf einmal</string>
|
|
||||||
<string name="onboarding_quick_add_bar">Schnell-Hinzufügen-Leiste</string>
|
|
||||||
<string name="onboarding_quick_add_button_hint">Ein schwebender Button, der das ganze Formular öffnet.</string>
|
|
||||||
<string name="settings_default_reminder_allday">Ganztägige Aufgaben</string>
|
|
||||||
<string name="onboarding_use_own_store">Stattdessen lokalen Speicher dieses Geräts nutzen</string>
|
|
||||||
<string name="settings_section_storage">Speicher</string>
|
|
||||||
<string name="settings_store_own">Auf diesem Gerät</string>
|
|
||||||
<string name="settings_store_own_hint">Agendulas eigener Speicher. Nichts weiteres zu installieren nötig.</string>
|
|
||||||
<string name="settings_store_external">Eine andere Aufgaben-App</string>
|
|
||||||
<string name="settings_store_permission_denied">Erlaubnis verweigert</string>
|
|
||||||
<string name="widget_week_label">Woche</string>
|
|
||||||
<string name="widget_lists_label">Listen</string>
|
|
||||||
<string name="widget_up_next_any_list">Alle Listen</string>
|
|
||||||
<string name="widget_up_next_tomorrow">Morgen</string>
|
|
||||||
<string name="view_options_title">Sortieren und Filter</string>
|
|
||||||
<string name="view_filter_tags">Filtern nach Tag</string>
|
|
||||||
<string name="view_filter_clear">Filter zurücksetzen</string>
|
|
||||||
<string name="recurrence_ends">Endet</string>
|
|
||||||
<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>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -39,9 +39,6 @@
|
|||||||
<string name="lists_reorder_done">Done reordering</string>
|
<string name="lists_reorder_done">Done reordering</string>
|
||||||
<string name="lists_reorder_handle">Drag to reorder</string>
|
<string name="lists_reorder_handle">Drag to reorder</string>
|
||||||
<string name="sort_title">Sort by</string>
|
<string name="sort_title">Sort by</string>
|
||||||
<string name="view_options_title">Sort and filter</string>
|
|
||||||
<string name="view_filter_tags">Filter by tag</string>
|
|
||||||
<string name="view_filter_clear">Clear filter</string>
|
|
||||||
<string name="sort_due">Due date</string>
|
<string name="sort_due">Due date</string>
|
||||||
<string name="sort_priority">Priority</string>
|
<string name="sort_priority">Priority</string>
|
||||||
<string name="sort_title_alpha">Title</string>
|
<string name="sort_title_alpha">Title</string>
|
||||||
@@ -188,9 +185,6 @@
|
|||||||
<string name="edit_location_label">Location</string>
|
<string name="edit_location_label">Location</string>
|
||||||
<string name="edit_url_label">Link</string>
|
<string name="edit_url_label">Link</string>
|
||||||
<string name="edit_url_hint">https://…</string>
|
<string name="edit_url_hint">https://…</string>
|
||||||
<string name="edit_tags_label">Tags</string>
|
|
||||||
<string name="edit_tags_hint">Add a tag</string>
|
|
||||||
<string name="edit_tag_remove">Remove tag %1$s</string>
|
|
||||||
<string name="edit_discard_title">Discard changes?</string>
|
<string name="edit_discard_title">Discard changes?</string>
|
||||||
<string name="edit_discard_body">Your changes to this task haven’t been saved.</string>
|
<string name="edit_discard_body">Your changes to this task haven’t been saved.</string>
|
||||||
<string name="edit_discard_confirm">Discard</string>
|
<string name="edit_discard_confirm">Discard</string>
|
||||||
@@ -218,31 +212,6 @@
|
|||||||
|
|
||||||
<!-- Lists overview -->
|
<!-- Lists overview -->
|
||||||
<string name="lists_header">Lists</string>
|
<string name="lists_header">Lists</string>
|
||||||
<string name="tags_header">Tags</string>
|
|
||||||
<string name="filters_header">Filters</string>
|
|
||||||
<string name="filters_empty_hint">Combine due dates, lists, tags and priority into a view of your own</string>
|
|
||||||
<string name="filter_new_title">New filter</string>
|
|
||||||
<string name="filter_edit_title">Edit filter</string>
|
|
||||||
<string name="filter_name_hint">Filter name</string>
|
|
||||||
<string name="filter_section_due">Due</string>
|
|
||||||
<string name="filter_section_priority">Priority</string>
|
|
||||||
<string name="filter_section_lists">Lists</string>
|
|
||||||
<string name="filter_section_tags">Tags</string>
|
|
||||||
<string name="filter_any_due">Any due date</string>
|
|
||||||
<string name="filter_any_list">Any list</string>
|
|
||||||
<string name="filter_any_tag">Any tag, or none</string>
|
|
||||||
<string name="filter_priority_any">Any priority</string>
|
|
||||||
<string name="filter_priority_low">Low and higher</string>
|
|
||||||
<string name="filter_priority_medium">Medium and higher</string>
|
|
||||||
<string name="filter_priority_high">High only</string>
|
|
||||||
<string name="filter_match_all_tags">Match all tags</string>
|
|
||||||
<string name="filter_match_all_on">A task needs every tag you picked</string>
|
|
||||||
<string name="filter_match_all_off">A task needs one of the tags you picked</string>
|
|
||||||
<string name="filter_delete">Delete filter</string>
|
|
||||||
<string name="filter_delete_title">Delete “%1$s”?</string>
|
|
||||||
<string name="filter_delete_body">Only the filter goes. Its tasks stay where they are.</string>
|
|
||||||
<string name="filter_delete_confirm">Delete</string>
|
|
||||||
<string name="filter_missing">Filter deleted</string>
|
|
||||||
<string name="new_task">New task</string>
|
<string name="new_task">New task</string>
|
||||||
<string name="lists_failure">Could not read your tasks.</string>
|
<string name="lists_failure">Could not read your tasks.</string>
|
||||||
<string name="lists_empty">No task lists yet.</string>
|
<string name="lists_empty">No task lists yet.</string>
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.prefs
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import de.jeanlucmakiola.agendula.domain.DueWindow
|
|
||||||
import de.jeanlucmakiola.agendula.domain.Priority
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
|
||||||
import de.jeanlucmakiola.agendula.domain.TagMatch
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
class SavedFilterCodecTest {
|
|
||||||
|
|
||||||
private val filter = SavedFilter(
|
|
||||||
id = 3,
|
|
||||||
name = "Work | today, 100%",
|
|
||||||
due = setOf(DueWindow.TODAY, DueWindow.OVERDUE),
|
|
||||||
listIds = setOf(4L, 9L),
|
|
||||||
tags = setOf("a,b", "größe", "x|y"),
|
|
||||||
tagMatch = TagMatch.ALL,
|
|
||||||
minPriority = Priority.MEDIUM,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a filter round-trips, separators and all`() {
|
|
||||||
val raw = SavedFilterCodec.upsert(null, filter, "OWN")
|
|
||||||
assertThat(SavedFilterCodec.decode(raw, "OWN")).containsExactly(filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `upsert replaces by id and keeps the order`() {
|
|
||||||
val first = filter.copy(id = 1, name = "one")
|
|
||||||
var raw = SavedFilterCodec.upsert(null, first, "OWN")
|
|
||||||
raw = SavedFilterCodec.upsert(raw, filter, "OWN")
|
|
||||||
raw = SavedFilterCodec.upsert(raw, first.copy(name = "renamed"), "OWN")
|
|
||||||
assertThat(SavedFilterCodec.decode(raw, "OWN").map { it.name }).containsExactly("renamed", filter.name).inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `list ids belong to their store`() {
|
|
||||||
val raw = SavedFilterCodec.upsert(null, filter, "OWN")
|
|
||||||
val elsewhere = SavedFilterCodec.decode(raw, "EXTERNAL").single()
|
|
||||||
assertThat(elsewhere.listIds).isEmpty()
|
|
||||||
assertThat(elsewhere.listsElsewhere).isTrue()
|
|
||||||
assertThat(SavedFilterCodec.decode(raw, "OWN").single().listsElsewhere).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `choosing any list in one store drops the other store's lists`() {
|
|
||||||
var raw = SavedFilterCodec.upsert(null, filter, "OWN")
|
|
||||||
raw = SavedFilterCodec.upsert(raw, filter.copy(listIds = emptySet(), listsElsewhere = false), "EXTERNAL")
|
|
||||||
assertThat(SavedFilterCodec.decode(raw, "OWN").single().anyList).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `unticking the last list here keeps the other store's lists`() {
|
|
||||||
var raw = SavedFilterCodec.upsert(null, filter, "OWN")
|
|
||||||
raw = SavedFilterCodec.upsert(raw, filter.copy(listIds = setOf(1L)), "EXTERNAL")
|
|
||||||
val external = SavedFilterCodec.decode(raw, "EXTERNAL").single()
|
|
||||||
assertThat(external.listsElsewhere).isTrue()
|
|
||||||
raw = SavedFilterCodec.upsert(raw, external.copy(listIds = emptySet()), "EXTERNAL")
|
|
||||||
assertThat(SavedFilterCodec.decode(raw, "OWN").single().listIds).containsExactly(4L, 9L)
|
|
||||||
assertThat(SavedFilterCodec.decode(raw, "EXTERNAL").single().anyList).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `saving untouched in the other store keeps the lists`() {
|
|
||||||
var raw = SavedFilterCodec.upsert(null, filter, "OWN")
|
|
||||||
val elsewhere = SavedFilterCodec.decode(raw, "EXTERNAL").single()
|
|
||||||
raw = SavedFilterCodec.upsert(raw, elsewhere.copy(name = "renamed"), "EXTERNAL")
|
|
||||||
assertThat(SavedFilterCodec.decode(raw, "OWN").single().listIds).containsExactly(4L, 9L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `editing in one store keeps the other store's lists`() {
|
|
||||||
var raw = SavedFilterCodec.upsert(null, filter, "OWN")
|
|
||||||
raw = SavedFilterCodec.upsert(raw, filter.copy(listIds = setOf(1L)), "EXTERNAL")
|
|
||||||
assertThat(SavedFilterCodec.decode(raw, "OWN").single().listIds).containsExactly(4L, 9L)
|
|
||||||
assertThat(SavedFilterCodec.decode(raw, "EXTERNAL").single().listIds).containsExactly(1L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `remove drops only that filter`() {
|
|
||||||
var raw = SavedFilterCodec.upsert(null, filter.copy(id = 1), "OWN")
|
|
||||||
raw = SavedFilterCodec.upsert(raw, filter, "OWN")
|
|
||||||
assertThat(SavedFilterCodec.decode(SavedFilterCodec.remove(raw, 1), "OWN").map { it.id }).containsExactly(3L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `broken lines are skipped`() {
|
|
||||||
val raw = "nonsense\n" + SavedFilterCodec.upsert(null, filter, "OWN") + "\n7|short\n8|bad%zz||||ANY|NONE"
|
|
||||||
assertThat(SavedFilterCodec.decode(raw, "OWN")).containsExactly(filter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,59 +60,6 @@ class ReminderPlannerTest {
|
|||||||
assertThat(plan).containsExactly(ScheduledReminder(1, millis(date.atTime(7, 0), berlin)))
|
assertThat(plan).containsExactly(ScheduledReminder(1, millis(date.atTime(7, 0), berlin)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a reminder another client already dismissed is not fired again`() {
|
|
||||||
val due = Instant.parse("2026-07-20T10:00:00Z")
|
|
||||||
val task = testTask(id = 3, due = due)
|
|
||||||
val trigger = due.toEpochMilliseconds() - 15 * 60_000L
|
|
||||||
// Inside the missed-reminder grace, which is where a dismissal elsewhere matters.
|
|
||||||
val now = trigger + 60_000L
|
|
||||||
fun plan(acknowledged: Instant?) = ReminderPlanner.plan(
|
|
||||||
tasks = listOf(task),
|
|
||||||
perTask = mapOf(3L to listOf(TaskReminder(15, acknowledgedAt = acknowledged))),
|
|
||||||
settings = Settings(),
|
|
||||||
now = now,
|
|
||||||
zone = berlin,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertThat(plan(acknowledged = Instant.fromEpochMilliseconds(trigger + 1))).isEmpty()
|
|
||||||
// Dismissed before this trigger came due: an earlier one, still to fire.
|
|
||||||
assertThat(plan(acknowledged = Instant.fromEpochMilliseconds(trigger - 1)))
|
|
||||||
.containsExactly(ScheduledReminder(3, trigger))
|
|
||||||
assertThat(plan(acknowledged = null)).containsExactly(ScheduledReminder(3, trigger))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a task whose alarms were turned off elsewhere has none, not the default`() {
|
|
||||||
val due = Instant.parse("2026-07-20T10:00:00Z")
|
|
||||||
val now = due.toEpochMilliseconds() - 60 * 60_000L
|
|
||||||
fun plan(perTask: Map<Long, List<TaskReminder>>) = ReminderPlanner.plan(
|
|
||||||
tasks = listOf(testTask(id = 4, due = due)),
|
|
||||||
perTask = perTask,
|
|
||||||
settings = Settings(defaultReminderMinutes = listOf(15)),
|
|
||||||
now = now,
|
|
||||||
zone = berlin,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertThat(plan(mapOf(4L to emptyList()))).isEmpty()
|
|
||||||
assertThat(plan(emptyMap())).hasSize(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an all-day reminder dismissed elsewhere before this device's hour stays dismissed`() {
|
|
||||||
val task = testTask(id = 5, due = allDayInstantOf(date)).copy(isAllDay = true)
|
|
||||||
// Rung at midnight on the Mac, dismissed at 07:00; this device rings at 09:00.
|
|
||||||
val dismissed = date.atTime(7, 0).atZone(berlin).toInstant()
|
|
||||||
val plan = ReminderPlanner.plan(
|
|
||||||
tasks = listOf(task),
|
|
||||||
perTask = mapOf(5L to listOf(TaskReminder(0, acknowledgedAt = Instant.fromEpochMilliseconds(dismissed.toEpochMilli())))),
|
|
||||||
settings = Settings(allDayReminderMinuteOfDay = 9 * 60),
|
|
||||||
now = millis(date.atTime(8, 0), berlin),
|
|
||||||
zone = berlin,
|
|
||||||
)
|
|
||||||
assertThat(plan).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an all-day task takes the all-day defaults, not the timed ones`() {
|
fun `an all-day task takes the all-day defaults, not the timed ones`() {
|
||||||
val task = testTask(id = 6, due = allDayInstantOf(date)).copy(isAllDay = true, listId = 7)
|
val task = testTask(id = 6, due = allDayInstantOf(date)).copy(isAllDay = true, listId = 7)
|
||||||
|
|||||||
@@ -148,17 +148,6 @@ class TaskWriteMapperTest {
|
|||||||
assertThat(undone[Tasks.COMPLETED]).isNull()
|
assertThat(undone[Tasks.COMPLETED]).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `category row carries only the name, so the provider resolves the category itself`() {
|
|
||||||
val values = TaskWriteMapper.categoryValues(taskId = 12L, name = "Work")
|
|
||||||
|
|
||||||
assertThat(values).containsExactly(
|
|
||||||
TasksContract.Properties.TASK_ID, 12L,
|
|
||||||
TasksContract.Properties.MIMETYPE, "vnd.android.cursor.item/category",
|
|
||||||
TasksContract.Category.NAME, "Work",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `alarm carries every column the provider's validator demands`() {
|
fun `alarm carries every column the provider's validator demands`() {
|
||||||
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
|
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks.ical
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
|
||||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
|
||||||
import org.junit.jupiter.api.Nested
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import java.time.LocalDate
|
|
||||||
import kotlin.time.Duration.Companion.hours
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
class AlarmCodecTest {
|
|
||||||
|
|
||||||
private val due = Instant.parse("2026-10-01T15:00:00Z")
|
|
||||||
private val start = Instant.parse("2026-10-01T09:00:00Z")
|
|
||||||
private val timed = TaskEntity(listId = 1, uid = "t", due = due)
|
|
||||||
private val withStart = timed.copy(dtstart = start)
|
|
||||||
private val allDay = TaskEntity(
|
|
||||||
listId = 1,
|
|
||||||
uid = "t",
|
|
||||||
due = allDayInstantOf(LocalDate.of(2026, 10, 1)),
|
|
||||||
isAllDay = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun alarm(vararg lines: String): ICalComponent =
|
|
||||||
ICalParser.parse((listOf("BEGIN:VALARM") + lines + "END:VALARM").joinToString("\r\n"))
|
|
||||||
|
|
||||||
private fun lead(minutes: Int, reference: AlarmReference = AlarmReference.DUE) = AlarmCodec.Lead(minutes, reference)
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class Claiming {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a display alarm before due is claimed`() {
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15M"), timed))
|
|
||||||
.isEqualTo(lead(15))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an audio alarm is claimed too, an email one is not`() {
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:AUDIO", "TRIGGER;RELATED=END:-PT5M"), timed)).isEqualTo(lead(5))
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:EMAIL", "TRIGGER;RELATED=END:-PT5M"), timed)).isNull()
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("TRIGGER;RELATED=END:-PT5M"), timed)).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `alarms that are somebody else's to fire stay in the residue`() {
|
|
||||||
val unclaimable = listOf(
|
|
||||||
alarm("ACTION:DISPLAY", "X-APPLE-DEFAULT-ALARM:TRUE", "TRIGGER;RELATED=END:-PT15M"),
|
|
||||||
alarm("ACTION:DISPLAY", "X-APPLE-PROXIMITY:ARRIVE", "TRIGGER;VALUE=DATE-TIME:19760401T005545Z"),
|
|
||||||
alarm("ACTION:DISPLAY", "PROXIMITY:DEPART", "TRIGGER;RELATED=END:-PT15M"),
|
|
||||||
alarm("ACTION:DISPLAY", "REPEAT:2", "DURATION:PT5M", "TRIGGER;RELATED=END:-PT15M"),
|
|
||||||
alarm("ACTION:DISPLAY", "RELATED-TO;RELTYPE=SNOOZE:other", "TRIGGER;RELATED=END:-PT15M"),
|
|
||||||
alarm("ACTION:DISPLAY", "TRIGGER;VALUE=DATE-TIME:19760401T005545Z"),
|
|
||||||
)
|
|
||||||
unclaimable.forEach { assertThat(AlarmCodec.resolve(it, timed)).isNull() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a trigger after its anchor is not claimed, because the planner would fire it early`() {
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:PT10M"), timed)).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a lead in seconds is not one the planner can reproduce`() {
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT90S"), timed)).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `RELATED=END without a DUE has nothing to count from`() {
|
|
||||||
val noDue = timed.copy(due = null, dtstart = start)
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15M"), noDue)).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an absolute UTC trigger becomes a lead before due`() {
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;VALUE=DATE-TIME:20261001T143000Z"), timed))
|
|
||||||
.isEqualTo(lead(30))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an absolute trigger on a recurring task has no single lead`() {
|
|
||||||
val recurring = timed.copy(rrule = "FREQ=DAILY")
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;VALUE=DATE-TIME:20261001T143000Z"), recurring))
|
|
||||||
.isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the identity is taken from X-WR-ALARMUID, then UID`() {
|
|
||||||
val apple = AlarmCodec.fromVAlarm(alarm("ACTION:DISPLAY", "X-WR-ALARMUID:apple", "UID:rfc", "TRIGGER:-PT5M"), timed)
|
|
||||||
val rfc = AlarmCodec.fromVAlarm(alarm("ACTION:DISPLAY", "UID:rfc", "TRIGGER:-PT5M"), timed)
|
|
||||||
assertThat(apple?.uid).isEqualTo("apple")
|
|
||||||
assertThat(rfc?.uid).isEqualTo("rfc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class Anchors {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `RELATED=START with a distinct DTSTART is a before-start reminder`() {
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=START:-PT10M"), withStart))
|
|
||||||
.isEqualTo(lead(10, AlarmReference.START))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `RELATED is START by default`() {
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER:-PT10M"), withStart))
|
|
||||||
.isEqualTo(lead(10, AlarmReference.START))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `START on a task whose start is its due is a before-due reminder`() {
|
|
||||||
val same = timed.copy(dtstart = due)
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER:-PT10M"), same)).isEqualTo(lead(10))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `START on a task with only a DUE is Apple's shape, and reads as before-due`() {
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=START:-PT30M"), timed))
|
|
||||||
.isEqualTo(lead(30))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class AllDay {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a relative trigger rounds to whole days`() {
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-P1D"), allDay))
|
|
||||||
.isEqualTo(lead(24 * 60))
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15H"), allDay))
|
|
||||||
.isEqualTo(lead(24 * 60))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a time on the day itself is "on the day", not an after-due alarm`() {
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:PT9H"), allDay))
|
|
||||||
.isEqualTo(lead(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an absolute trigger counts back from the due date`() {
|
|
||||||
// 09:00 Berlin the day before, as Apple writes it.
|
|
||||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;VALUE=DATE-TIME:20260930T070000Z"), allDay))
|
|
||||||
.isEqualTo(lead(24 * 60))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an authored all-day alarm is written in days`() {
|
|
||||||
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 2 * 24 * 60, uid = "a")
|
|
||||||
assertThat(AlarmCodec.toVAlarm(row, allDay, due)?.property("TRIGGER")?.value).isEqualTo("-P2D")
|
|
||||||
val onTheDay = row.copy(minutesBefore = 0)
|
|
||||||
assertThat(AlarmCodec.toVAlarm(onTheDay, allDay, due)?.property("TRIGGER")?.value).isEqualTo("PT0S")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class Writing {
|
|
||||||
|
|
||||||
private val earlier = due - 48.hours
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an authored alarm names itself both ways and counts back from due`() {
|
|
||||||
val written = AlarmCodec.toVAlarm(TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a-1"), timed, earlier)!!
|
|
||||||
assertThat(ICalSerializer.serialize(written)).isEqualTo(
|
|
||||||
"BEGIN:VALARM\r\n" +
|
|
||||||
"UID:a-1\r\n" +
|
|
||||||
"X-WR-ALARMUID:a-1\r\n" +
|
|
||||||
"ACTION:DISPLAY\r\n" +
|
|
||||||
"DESCRIPTION:Reminder\r\n" +
|
|
||||||
"TRIGGER;RELATED=END:-PT15M\r\n" +
|
|
||||||
"END:VALARM\r\n",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a before-start row is written against START`() {
|
|
||||||
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 10, reference = AlarmReference.START, uid = "a")
|
|
||||||
val trigger = AlarmCodec.toVAlarm(row, withStart, earlier)?.property("TRIGGER")
|
|
||||||
assertThat(trigger?.param("RELATED")).isEqualTo("START")
|
|
||||||
assertThat(trigger?.value).isEqualTo("-PT10M")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a row whose anchor is gone is not written, so the resource stays valid`() {
|
|
||||||
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a")
|
|
||||||
assertThat(AlarmCodec.toVAlarm(row, timed.copy(due = null), earlier)).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an alarm long past is written acknowledged`() {
|
|
||||||
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a")
|
|
||||||
val muchLater = due + 7.hours
|
|
||||||
assertThat(AlarmCodec.toVAlarm(row, timed, muchLater)?.property("ACKNOWLEDGED")?.value)
|
|
||||||
.isEqualTo("20261001T220000Z")
|
|
||||||
assertThat(AlarmCodec.toVAlarm(row, timed, earlier)?.property("ACKNOWLEDGED")).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `one still inside the missed-reminder grace is not, or it would silence the late reminder here`() {
|
|
||||||
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a")
|
|
||||||
assertThat(AlarmCodec.toVAlarm(row, timed, due + 1.hours)?.property("ACKNOWLEDGED")).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a before-start row on a task that lost its start goes out before due`() {
|
|
||||||
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 10, reference = AlarmReference.START, uid = "a")
|
|
||||||
val trigger = AlarmCodec.toVAlarm(row, timed, earlier)?.property("TRIGGER")
|
|
||||||
assertThat(trigger?.param("RELATED")).isEqualTo("END")
|
|
||||||
assertThat(trigger?.value).isEqualTo("-PT10M")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the wire lead is what the row reads back as`() {
|
|
||||||
val fifteen = TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a")
|
|
||||||
assertThat(AlarmCodec.wireLead(fifteen, allDay)).isEqualTo(lead(0))
|
|
||||||
assertThat(AlarmCodec.wireLead(fifteen, timed)).isEqualTo(lead(15))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class Unchanged {
|
|
||||||
|
|
||||||
private fun claimed(text: String, task: TaskEntity) =
|
|
||||||
checkNotNull(AlarmCodec.fromVAlarm(alarm(*text.split("|").toTypedArray()), task))
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an unchanged row is written back verbatim, whatever spelling it arrived in`() {
|
|
||||||
val row = claimed("ACTION:DISPLAY|TRIGGER;RELATED=END:-P0DT0H15M0S|X-VENDOR:keep", timed)
|
|
||||||
assertThat(row.minutesBefore).isEqualTo(15)
|
|
||||||
val written = AlarmCodec.toVAlarm(row, timed, due)!!
|
|
||||||
assertThat(written.property("TRIGGER")?.value).isEqualTo("-P0DT0H15M0S")
|
|
||||||
assertThat(written.property("X-VENDOR")?.value).isEqualTo("keep")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a relative original survives a due-date edit`() {
|
|
||||||
val row = claimed("ACTION:DISPLAY|TRIGGER;RELATED=END:-PT15M|X-VENDOR:keep", timed)
|
|
||||||
val moved = timed.copy(due = due + 24.hours)
|
|
||||||
assertThat(AlarmCodec.toVAlarm(row, moved, due)?.property("X-VENDOR")).isNotNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an absolute original stops matching once due moves, and the row is authored fresh`() {
|
|
||||||
val row = claimed("ACTION:DISPLAY|TRIGGER;VALUE=DATE-TIME:20261001T143000Z|X-WR-ALARMUID:abs", timed)
|
|
||||||
val moved = timed.copy(due = due + 24.hours)
|
|
||||||
val written = AlarmCodec.toVAlarm(row, moved, due)!!
|
|
||||||
assertThat(written.property("TRIGGER")?.value).isEqualTo("-PT30M")
|
|
||||||
// Under its old identity, so other clients see it change, not appear.
|
|
||||||
assertThat(written.property("X-WR-ALARMUID")?.value).isEqualTo("abs")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an edited lead is authored fresh`() {
|
|
||||||
val row = claimed("ACTION:DISPLAY|TRIGGER;RELATED=END:-PT15M|X-VENDOR:keep", timed).copy(minutesBefore = 60)
|
|
||||||
val written = AlarmCodec.toVAlarm(row, timed, due)!!
|
|
||||||
assertThat(written.property("TRIGGER")?.value).isEqualTo("-PT1H")
|
|
||||||
assertThat(written.property("X-VENDOR")).isNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks.ical
|
package de.jeanlucmakiola.agendula.data.tasks.ical
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
@@ -10,7 +8,6 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
|||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||||
import org.junit.jupiter.api.Nested
|
import org.junit.jupiter.api.Nested
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import kotlin.time.Duration.Companion.days
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class VTodoMapperTest {
|
class VTodoMapperTest {
|
||||||
@@ -306,65 +303,6 @@ class VTodoMapperTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class Categories {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a single plain line is claimed into the column`() {
|
|
||||||
val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work,Home\\, Garden"))
|
|
||||||
assertThat(mapped.entity.categories).containsExactly("Work", "Home, Garden").inOrder()
|
|
||||||
assertThat(mapped.entity.unknownProperties.orEmpty()).doesNotContain("CATEGORIES")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `several lines are read but round-trip from the residue`() {
|
|
||||||
val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work", "CATEGORIES:Errands"))
|
|
||||||
assertThat(mapped.entity.categories).containsExactly("Work", "Errands").inOrder()
|
|
||||||
|
|
||||||
val written = VTodoMapper.write(mapped.entity).properties("CATEGORIES").map { it.value }
|
|
||||||
assertThat(written).containsExactly("Work", "Errands")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a line the column would not reproduce exactly stays in the residue`() {
|
|
||||||
val mapped = VTodoMapper.read(vtodo("CATEGORIES;LANGUAGE=de:Arbeit", "SUMMARY:x"))
|
|
||||||
assertThat(mapped.entity.categories).containsExactly("Arbeit")
|
|
||||||
assertThat(VTodoMapper.read(vtodo("CATEGORIES:a, b")).entity.unknownProperties)
|
|
||||||
.contains("CATEGORIES:a, b")
|
|
||||||
|
|
||||||
val written = VTodoMapper.write(mapped.entity).properties("CATEGORIES")
|
|
||||||
assertThat(written.single().param("LANGUAGE")).isEqualTo("de")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `editing the tags evicts the residue copies and authors one line`() {
|
|
||||||
val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work", "CATEGORIES:Errands"))
|
|
||||||
val edited = mapped.entity.copy(categories = listOf("Work", "Home"))
|
|
||||||
|
|
||||||
val written = VTodoMapper.write(edited).properties("CATEGORIES")
|
|
||||||
assertThat(written.single().value).isEqualTo("Work,Home")
|
|
||||||
assertThat(written.single().params).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `no tags writes no line`() {
|
|
||||||
assertThat(VTodoMapper.write(entity()).properties("CATEGORIES")).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a duplicated or blank tag is dropped and the line stays in the residue`() {
|
|
||||||
val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work, ,Work"))
|
|
||||||
assertThat(mapped.entity.categories).containsExactly("Work")
|
|
||||||
assertThat(VTodoMapper.write(mapped.entity).properties("CATEGORIES").single().value).isEqualTo("Work, ,Work")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an unclean column is cleaned on the way out`() {
|
|
||||||
val written = VTodoMapper.write(entity().copy(categories = listOf(" a ", "", "a", "b")))
|
|
||||||
assertThat(written.properties("CATEGORIES").single().value).isEqualTo("a,b")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
inner class Validation {
|
inner class Validation {
|
||||||
|
|
||||||
@@ -445,64 +383,4 @@ class VTodoMapperTest {
|
|||||||
timezone = timezone,
|
timezone = timezone,
|
||||||
unknownProperties = unknownProperties,
|
unknownProperties = unknownProperties,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class Alarms {
|
|
||||||
|
|
||||||
private val due = Instant.parse("2026-10-01T15:00:00Z")
|
|
||||||
private val task = TaskEntity(listId = 1, uid = "t-1", title = "Pay rent", due = due)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `claimed alarms leave the residue and unclaimed ones stay`() {
|
|
||||||
val mapped = VTodoMapper.read(
|
|
||||||
vtodo(
|
|
||||||
"DUE:20261001T150000Z",
|
|
||||||
"BEGIN:VALARM", "ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15M", "END:VALARM",
|
|
||||||
"BEGIN:VALARM", "ACTION:EMAIL", "TRIGGER;RELATED=END:-PT1H", "END:VALARM",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertThat(mapped.alarms.map { it.minutesBefore }).containsExactly(15)
|
|
||||||
assertThat(mapped.entity.unknownProperties).contains("ACTION:EMAIL")
|
|
||||||
assertThat(mapped.entity.unknownProperties).doesNotContain("ACTION:DISPLAY")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the task's reminders are written ahead of the residue's alarms`() {
|
|
||||||
val withResidue = task.copy(
|
|
||||||
unknownProperties = "BEGIN:VALARM\r\nACTION:EMAIL\r\nTRIGGER;RELATED=END:-PT1H\r\nEND:VALARM\r\n",
|
|
||||||
)
|
|
||||||
val written = VTodoMapper.write(
|
|
||||||
withResidue,
|
|
||||||
now = due - 1.days,
|
|
||||||
alarms = listOf(TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a-1")),
|
|
||||||
)
|
|
||||||
val alarms = written.components("VALARM")
|
|
||||||
assertThat(alarms.map { it.property("ACTION")?.value }).containsExactly("DISPLAY", "EMAIL").inOrder()
|
|
||||||
assertThat(alarms.first().property("TRIGGER")?.value).isEqualTo("-PT15M")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a reminder left behind by a cleared due date does not make the task unuploadable`() {
|
|
||||||
val cleared = task.copy(due = null)
|
|
||||||
val written = VTodoMapper.write(
|
|
||||||
cleared,
|
|
||||||
alarms = listOf(TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a-1")),
|
|
||||||
)
|
|
||||||
assertThat(written.components("VALARM")).isEmpty()
|
|
||||||
assertThat(VTodoMapper.validate(written)).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a reminder survives write and read with its identity`() {
|
|
||||||
val written = VTodoMapper.write(
|
|
||||||
task,
|
|
||||||
now = due - 1.days,
|
|
||||||
alarms = listOf(TaskAlarmEntity(taskId = 1, minutesBefore = 30, uid = "a-1")),
|
|
||||||
)
|
|
||||||
val back = VTodoMapper.read(written).alarms.single()
|
|
||||||
assertThat(back.minutesBefore).isEqualTo(30)
|
|
||||||
assertThat(back.reference).isEqualTo(AlarmReference.DUE)
|
|
||||||
assertThat(back.uid).isEqualTo("a-1")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks.ical
|
package de.jeanlucmakiola.agendula.data.tasks.ical
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||||
@@ -24,7 +23,6 @@ class VTodoRoundTripTest {
|
|||||||
"nextcloud-shared-confidential",
|
"nextcloud-shared-confidential",
|
||||||
"recurring-master-overrides",
|
"recurring-master-overrides",
|
||||||
"valarm-unknown-nested",
|
"valarm-unknown-nested",
|
||||||
"valarm-claimed-apple",
|
|
||||||
"unknown-component",
|
"unknown-component",
|
||||||
"unknown-tzid",
|
"unknown-tzid",
|
||||||
"uid-special-chars",
|
"uid-special-chars",
|
||||||
@@ -41,8 +39,6 @@ class VTodoRoundTripTest {
|
|||||||
"mixed-value-types",
|
"mixed-value-types",
|
||||||
"malformed-scalars",
|
"malformed-scalars",
|
||||||
"two-timezones",
|
"two-timezones",
|
||||||
"categories-shapes",
|
|
||||||
"categories-split",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@TestFactory
|
@TestFactory
|
||||||
@@ -72,29 +68,6 @@ class VTodoRoundTripTest {
|
|||||||
assertThat(alarm.property("ACKNOWLEDGED")?.value).isEqualTo("20260901T113000Z")
|
assertThat(alarm.property("ACKNOWLEDGED")?.value).isEqualTo("20260901T113000Z")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a claimed alarm becomes a reminder and comes back exactly as it arrived`() {
|
|
||||||
val original = vtodosOf("valarm-claimed-apple").single()
|
|
||||||
val mapped = VTodoMapper.read(original)
|
|
||||||
|
|
||||||
val alarm = mapped.alarms.single()
|
|
||||||
// RELATED=START with only a DUE: Apple's shape, read as before-due.
|
|
||||||
assertThat(alarm.minutesBefore).isEqualTo(30)
|
|
||||||
assertThat(alarm.reference).isEqualTo(AlarmReference.DUE)
|
|
||||||
assertThat(alarm.uid).isEqualTo("8E6C4A1E-0000-4C1B-9B1A-3F5F4C2D9A11")
|
|
||||||
assertThat(mapped.entity.unknownProperties.orEmpty()).doesNotContain("VALARM")
|
|
||||||
|
|
||||||
val written = roundTrip(original).components("VALARM").single()
|
|
||||||
assertThat(written).isEqualTo(original.components("VALARM").single())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an Apple default alarm is left in the residue`() {
|
|
||||||
val mapped = VTodoMapper.read(vtodosOf("valarm-unknown-nested").single())
|
|
||||||
assertThat(mapped.alarms).isEmpty()
|
|
||||||
assertThat(mapped.entity.unknownProperties).contains("X-APPLE-DEFAULT-ALARM:TRUE")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an entirely unknown sub-component survives`() {
|
fun `an entirely unknown sub-component survives`() {
|
||||||
val roundTripped = roundTrip(vtodosOf("unknown-component").single())
|
val roundTripped = roundTrip(vtodosOf("unknown-component").single())
|
||||||
@@ -170,7 +143,6 @@ class VTodoRoundTripTest {
|
|||||||
entity = mapped.entity,
|
entity = mapped.entity,
|
||||||
parentUid = mapped.parentUid,
|
parentUid = mapped.parentUid,
|
||||||
now = Instant.fromEpochMilliseconds(0),
|
now = Instant.fromEpochMilliseconds(0),
|
||||||
alarms = mapped.alarms,
|
|
||||||
)
|
)
|
||||||
// Serialise and re-parse rather than comparing the tree directly: folding
|
// Serialise and re-parse rather than comparing the tree directly: folding
|
||||||
// and parameter quoting are where a serialiser loses data, and skipping
|
// and parameter quoting are where a serialiser loses data, and skipping
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
class CategoriesLiftTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `claims a lone plain line and keeps the rest of the residue`() {
|
|
||||||
val result = CategoriesLift.lift("X-FOO:bar\r\nCATEGORIES:Work,Home\r\n")
|
|
||||||
assertThat(result.categories).containsExactly("Work", "Home").inOrder()
|
|
||||||
assertThat(result.claimed).isTrue()
|
|
||||||
assertThat(result.residue).contains("X-FOO:bar")
|
|
||||||
assertThat(result.residue).doesNotContain("CATEGORIES")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a residue that was only the line becomes null`() {
|
|
||||||
assertThat(CategoriesLift.lift("CATEGORIES:Work\r\n").residue).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `several lines are read but not claimed`() {
|
|
||||||
val residue = "CATEGORIES:a\r\nCATEGORIES:b\r\n"
|
|
||||||
val result = CategoriesLift.lift(residue)
|
|
||||||
assertThat(result.categories).containsExactly("a", "b").inOrder()
|
|
||||||
assertThat(result.claimed).isFalse()
|
|
||||||
assertThat(result.residue).isEqualTo(residue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `nothing to lift from an empty residue`() {
|
|
||||||
assertThat(CategoriesLift.lift(null)).isEqualTo(CategoriesLift.Result(emptyList(), null, claimed = false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -50,12 +50,4 @@ class ConvertersTest {
|
|||||||
}
|
}
|
||||||
assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE)
|
assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `categories round-trip with escaped commas and come back cleaned`() {
|
|
||||||
val stored = CategoriesConverter.toText(listOf("a,b", " c ", "", "c"))
|
|
||||||
assertThat(stored).isEqualTo("a\\,b,c")
|
|
||||||
assertThat(CategoriesConverter.fromText(stored)).containsExactly("a,b", "c").inOrder()
|
|
||||||
assertThat(CategoriesConverter.fromText("")).isEmpty()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ private fun form(
|
|||||||
start: Instant? = null,
|
start: Instant? = null,
|
||||||
due: Instant? = null,
|
due: Instant? = null,
|
||||||
isAllDay: Boolean = false,
|
isAllDay: Boolean = false,
|
||||||
tags: List<String> = emptyList(),
|
|
||||||
) = TaskForm(
|
) = TaskForm(
|
||||||
title = title,
|
title = title,
|
||||||
listId = 1,
|
listId = 1,
|
||||||
@@ -37,7 +36,6 @@ private fun form(
|
|||||||
start = start,
|
start = start,
|
||||||
due = due,
|
due = due,
|
||||||
isAllDay = isAllDay,
|
isAllDay = isAllDay,
|
||||||
tags = tags,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class TaskFormWriterTest {
|
class TaskFormWriterTest {
|
||||||
@@ -53,12 +51,6 @@ class TaskFormWriterTest {
|
|||||||
assertThat(entity.isDirty).isTrue()
|
assertThat(entity.isDirty).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `tags are trimmed and blanks dropped`() {
|
|
||||||
val entity = TaskFormWriter.apply(task(), form(tags = listOf(" Work ", "", " ", "Home")), NOW, ZONE)
|
|
||||||
assertThat(entity.categories).containsExactly("Work", "Home").inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `progress and status move together in both directions`() {
|
fun `progress and status move together in both directions`() {
|
||||||
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status)
|
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks.transfer
|
package de.jeanlucmakiola.agendula.data.tasks.transfer
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
@@ -26,20 +24,12 @@ class IcsImportTest {
|
|||||||
|
|
||||||
override fun hasUid(listId: Long, uid: String) = rows.any { it.listId == listId && it.uid == uid }
|
override fun hasUid(listId: Long, uid: String) = rows.any { it.listId == listId && it.uid == uid }
|
||||||
|
|
||||||
override fun hasUidAnywhere(uid: String) = rows.any { it.uid == uid }
|
|
||||||
|
|
||||||
override fun insert(row: TaskEntity): Long {
|
override fun insert(row: TaskEntity): Long {
|
||||||
val id = nextId++
|
val id = nextId++
|
||||||
rows += row.copy(id = id)
|
rows += row.copy(id = id)
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
val alarms = mutableListOf<TaskAlarmEntity>()
|
|
||||||
|
|
||||||
override fun insertAlarms(taskId: Long, alarms: List<TaskAlarmEntity>) {
|
|
||||||
this.alarms += alarms.map { it.copy(taskId = taskId) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun masterByUid(listId: Long, uid: String) =
|
override fun masterByUid(listId: Long, uid: String) =
|
||||||
rows.firstOrNull { it.listId == listId && it.uid == uid && it.recurrenceId == null }
|
rows.firstOrNull { it.listId == listId && it.uid == uid && it.recurrenceId == null }
|
||||||
|
|
||||||
@@ -97,55 +87,12 @@ class IcsImportTest {
|
|||||||
val child = rows.single { it.recurrenceId != null }
|
val child = rows.single { it.recurrenceId != null }
|
||||||
assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH")
|
assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH")
|
||||||
assertThat(master.unknownProperties).contains("X-APPLE-SORT-ORDER:7")
|
assertThat(master.unknownProperties).contains("X-APPLE-SORT-ORDER:7")
|
||||||
// The alarm is a reminder now, carrying the original for the upload.
|
assertThat(master.unknownProperties).contains("BEGIN:VALARM")
|
||||||
assertThat(master.unknownProperties).doesNotContain("BEGIN:VALARM")
|
|
||||||
val alarm = store.alarms.single()
|
|
||||||
assertThat(alarm.taskId).isEqualTo(master.id)
|
|
||||||
assertThat(alarm.minutesBefore).isEqualTo(15)
|
|
||||||
assertThat(alarm.reference).isEqualTo(AlarmReference.START)
|
|
||||||
assertThat(alarm.raw).contains("DESCRIPTION:Water")
|
|
||||||
assertThat(child.masterId).isEqualTo(master.id)
|
assertThat(child.masterId).isEqualTo(master.id)
|
||||||
assertThat(child.title).isEqualTo("Water plants (balcony)")
|
assertThat(child.title).isEqualTo("Water plants (balcony)")
|
||||||
assertThat(rows.all { it.listId == 1L && it.isDirty && it.href == null }).isTrue()
|
assertThat(rows.all { it.listId == 1L && it.isDirty && it.href == null }).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an override keeps its own alarms and an Apple default alarm stays in the residue`() {
|
|
||||||
val withAlarm = vtodo(
|
|
||||||
"series", "Water plants (balcony)",
|
|
||||||
"RECURRENCE-ID:20260604T080000Z",
|
|
||||||
"DTSTART:20260604T100000Z",
|
|
||||||
"DUE:20260604T110000Z",
|
|
||||||
"BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:-PT1H\r\nEND:VALARM",
|
|
||||||
"BEGIN:VALARM\r\nACTION:DISPLAY\r\nX-APPLE-DEFAULT-ALARM:TRUE\r\nTRIGGER;RELATED=END:-PT5M\r\nEND:VALARM",
|
|
||||||
)
|
|
||||||
val parsed = IcsImport.parse(listOf(IcsSource("plants.ics", calendar(series, withAlarm))))
|
|
||||||
val store = FakeImportStore()
|
|
||||||
|
|
||||||
IcsImport.write(parsed, ImportTarget.Existing(1L), store)
|
|
||||||
|
|
||||||
val child = store.byUid("series").single { it.recurrenceId != null }
|
|
||||||
val own = store.alarms.filter { it.taskId == child.id }
|
|
||||||
assertThat(own.map { it.minutesBefore to it.reference }).containsExactly(60 to AlarmReference.DUE)
|
|
||||||
assertThat(child.unknownProperties).contains("X-APPLE-DEFAULT-ALARM:TRUE")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the same file in a second list gives the copy alarm identities of its own`() {
|
|
||||||
val parsed = IcsImport.parse(listOf(IcsSource("plants.ics", calendar(series))))
|
|
||||||
val store = FakeImportStore()
|
|
||||||
|
|
||||||
IcsImport.write(parsed, ImportTarget.Existing(1L), store)
|
|
||||||
IcsImport.write(parsed, ImportTarget.Existing(2L), store)
|
|
||||||
|
|
||||||
val (first, second) = store.alarms
|
|
||||||
assertThat(first.raw).isNotNull()
|
|
||||||
assertThat(second.uid).isNotEqualTo(first.uid)
|
|
||||||
// Fresh, so an untouched copy is not re-sent as the original text either.
|
|
||||||
assertThat(second.raw).isNull()
|
|
||||||
assertThat(second.minutesBefore).isEqualTo(first.minutesBefore)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a parent later in the file is still linked`() {
|
fun `a parent later in the file is still linked`() {
|
||||||
val child = vtodo("child", "Buy soil", "RELATED-TO;RELTYPE=PARENT:parent")
|
val child = vtodo("child", "Buy soil", "RELATED-TO;RELTYPE=PARENT:parent")
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.domain
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
class SavedFilterTest {
|
|
||||||
|
|
||||||
private val dayMs = 86_400_000L
|
|
||||||
private val todayStart = Instant.fromEpochMilliseconds(1000 * dayMs)
|
|
||||||
private val todayEnd = Instant.fromEpochMilliseconds(1001 * dayMs)
|
|
||||||
private val overdue = Instant.fromEpochMilliseconds(999 * dayMs)
|
|
||||||
private val today = Instant.fromEpochMilliseconds(1000 * dayMs + 1)
|
|
||||||
private val upcoming = Instant.fromEpochMilliseconds(1002 * dayMs)
|
|
||||||
|
|
||||||
private fun SavedFilter.matches(task: Task) = matches(task, todayStart, todayEnd)
|
|
||||||
|
|
||||||
private val empty = SavedFilter(id = 1, name = "f")
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an empty filter is every open task`() {
|
|
||||||
assertThat(empty.matches(testTask(due = null))).isTrue()
|
|
||||||
assertThat(empty.matches(testTask(due = overdue))).isTrue()
|
|
||||||
assertThat(empty.matches(testTask(status = TaskStatus.COMPLETED))).isFalse()
|
|
||||||
assertThat(empty.matches(testTask(status = TaskStatus.CANCELLED))).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `today and overdue together`() {
|
|
||||||
val filter = empty.copy(due = setOf(DueWindow.TODAY, DueWindow.OVERDUE))
|
|
||||||
assertThat(filter.matches(testTask(due = overdue))).isTrue()
|
|
||||||
assertThat(filter.matches(testTask(due = today))).isTrue()
|
|
||||||
assertThat(filter.matches(testTask(due = upcoming))).isFalse()
|
|
||||||
assertThat(filter.matches(testTask(due = null))).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `lists narrow by membership`() {
|
|
||||||
val filter = empty.copy(listIds = setOf(2L, 3L))
|
|
||||||
assertThat(filter.matches(testTask(listId = 2))).isTrue()
|
|
||||||
assertThat(filter.matches(testTask(listId = 1))).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `lists from another store match nothing here`() {
|
|
||||||
val filter = empty.copy(listsElsewhere = true)
|
|
||||||
assertThat(filter.matches(testTask(listId = 1))).isFalse()
|
|
||||||
assertThat(filter.copy(listIds = setOf(1L)).matches(testTask(listId = 1))).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `priority is a floor`() {
|
|
||||||
val filter = empty.copy(minPriority = Priority.MEDIUM)
|
|
||||||
assertThat(filter.matches(testTask(priority = Priority.HIGH))).isTrue()
|
|
||||||
assertThat(filter.matches(testTask(priority = Priority.MEDIUM))).isTrue()
|
|
||||||
assertThat(filter.matches(testTask(priority = Priority.LOW))).isFalse()
|
|
||||||
assertThat(filter.matches(testTask(priority = Priority.NONE))).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `tags match any or all`() {
|
|
||||||
val any = empty.copy(tags = setOf("work", "home"))
|
|
||||||
val all = any.copy(tagMatch = TagMatch.ALL)
|
|
||||||
val one = testTask(tags = listOf("work"))
|
|
||||||
val both = testTask(tags = listOf("home", "work", "x"))
|
|
||||||
assertThat(any.matches(one)).isTrue()
|
|
||||||
assertThat(any.matches(testTask(tags = listOf("x")))).isFalse()
|
|
||||||
assertThat(all.matches(one)).isFalse()
|
|
||||||
assertThat(all.matches(both)).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `criteria combine with and`() {
|
|
||||||
val filter = empty.copy(due = setOf(DueWindow.TODAY), listIds = setOf(1L), tags = setOf("work"))
|
|
||||||
assertThat(filter.matches(testTask(listId = 1, due = today, tags = listOf("work")))).isTrue()
|
|
||||||
assertThat(filter.matches(testTask(listId = 2, due = today, tags = listOf("work")))).isFalse()
|
|
||||||
assertThat(filter.matches(testTask(listId = 1, due = today))).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an all-day due is read as its date west of utc`() {
|
|
||||||
val zone = ZoneId.of("America/New_York")
|
|
||||||
val date = LocalDate.of(2026, 10, 10)
|
|
||||||
val start = Instant.fromEpochMilliseconds(date.atStartOfDay(zone).toInstant().toEpochMilli())
|
|
||||||
val end = Instant.fromEpochMilliseconds(date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli())
|
|
||||||
fun window(d: LocalDate) = dueWindowOf(testTask(due = allDayInstantOf(d), isAllDay = true), start, end, zone)
|
|
||||||
assertThat(window(date)).isEqualTo(DueWindow.TODAY)
|
|
||||||
assertThat(window(date.plusDays(1))).isEqualTo(DueWindow.UPCOMING)
|
|
||||||
assertThat(window(date.minusDays(1))).isEqualTo(DueWindow.OVERDUE)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a new task preset lands in its filter`() {
|
|
||||||
val day = LocalDate.of(2026, 10, 10)
|
|
||||||
val filter = empty.copy(
|
|
||||||
due = setOf(DueWindow.UPCOMING),
|
|
||||||
tags = setOf("work", "home"),
|
|
||||||
tagMatch = TagMatch.ALL,
|
|
||||||
minPriority = Priority.HIGH,
|
|
||||||
)
|
|
||||||
val preset = filter.newTaskPreset(day)
|
|
||||||
assertThat(preset.dueDate).isEqualTo(day.plusDays(1))
|
|
||||||
assertThat(preset.tags).containsExactly("home", "work")
|
|
||||||
assertThat(preset.priority).isEqualTo(Priority.HIGH)
|
|
||||||
assertThat(empty.copy(due = setOf(DueWindow.TODAY, DueWindow.UPCOMING)).newTaskPreset(day).dueDate).isEqualTo(day)
|
|
||||||
assertThat(empty.copy(due = setOf(DueWindow.TODAY, DueWindow.NO_DATE)).newTaskPreset(day).dueDate).isNull()
|
|
||||||
assertThat(empty.copy(tags = setOf("b", "a")).newTaskPreset(day).tags).containsExactly("a")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `lists since deleted drop out`() {
|
|
||||||
val filter = empty.copy(listIds = setOf(1L, 5L))
|
|
||||||
assertThat(filter.withListsIn(setOf(1L, 2L)).listIds).containsExactly(1L)
|
|
||||||
assertThat(filter.withListsIn(setOf(2L)).anyList).isTrue()
|
|
||||||
assertThat(filter.copy(listsElsewhere = true).withListsIn(setOf(2L)).anyList).isFalse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -58,32 +58,4 @@ class TaskFilteringTest {
|
|||||||
assertThat(TaskFiltering.matches(task, TaskFilter.OfList(7), todayStart, todayEnd)).isTrue()
|
assertThat(TaskFiltering.matches(task, TaskFilter.OfList(7), todayStart, todayEnd)).isTrue()
|
||||||
assertThat(TaskFiltering.matches(task, TaskFilter.OfList(8), todayStart, todayEnd)).isFalse()
|
assertThat(TaskFiltering.matches(task, TaskFilter.OfList(8), todayStart, todayEnd)).isFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Tagged matches open and completed tasks carrying the tag`() {
|
|
||||||
val filter = TaskFilter.Tagged("Work")
|
|
||||||
assertThat(TaskFiltering.matches(testTask(tags = listOf("Home", "Work")), filter, todayStart, todayEnd)).isTrue()
|
|
||||||
assertThat(
|
|
||||||
TaskFiltering.matches(testTask(tags = listOf("Work"), status = TaskStatus.COMPLETED), filter, todayStart, todayEnd),
|
|
||||||
).isTrue()
|
|
||||||
assertThat(TaskFiltering.matches(testTask(tags = listOf("work")), filter, todayStart, todayEnd)).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `narrowing by tags needs every one of them`() {
|
|
||||||
val task = testTask(tags = listOf("Home", "Work"))
|
|
||||||
assertThat(TaskFiltering.hasAllTags(task, emptySet())).isTrue()
|
|
||||||
assertThat(TaskFiltering.hasAllTags(task, setOf("Work"))).isTrue()
|
|
||||||
assertThat(TaskFiltering.hasAllTags(task, setOf("Work", "Home"))).isTrue()
|
|
||||||
assertThat(TaskFiltering.hasAllTags(task, setOf("Work", "Errands"))).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `tags in use are listed once each, alphabetically ignoring case`() {
|
|
||||||
val tasks = listOf(
|
|
||||||
testTask(id = 1, tags = listOf("work", "Home")),
|
|
||||||
testTask(id = 2, tags = listOf("Home", "errands")),
|
|
||||||
)
|
|
||||||
assertThat(tagsInUse(tasks)).containsExactly("errands", "Home", "work").inOrder()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,16 +44,4 @@ class TaskFormTest {
|
|||||||
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields())
|
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields())
|
||||||
.containsExactly(TaskFormField.Progress, TaskFormField.Parent)
|
.containsExactly(TaskFormField.Progress, TaskFormField.Parent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `tags auto-reveal when the task has any`() {
|
|
||||||
assertThat(TaskForm(title = "x", listId = 1, tags = listOf("Work")).populatedFields())
|
|
||||||
.containsExactly(TaskFormField.Tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `tags are normalised to trimmed, non-blank, unique spellings in order`() {
|
|
||||||
assertThat(normalizeTags(listOf(" Work", "", "Home ", "Work", "work")))
|
|
||||||
.containsExactly("Work", "Home", "work").inOrder()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ fun testTask(
|
|||||||
isAllDay: Boolean = false,
|
isAllDay: Boolean = false,
|
||||||
completedAt: Instant? = null,
|
completedAt: Instant? = null,
|
||||||
parentId: Long? = null,
|
parentId: Long? = null,
|
||||||
tags: List<String> = emptyList(),
|
|
||||||
): Task = Task(
|
): Task = Task(
|
||||||
taskId = id,
|
taskId = id,
|
||||||
listId = listId,
|
listId = listId,
|
||||||
@@ -39,5 +38,4 @@ fun testTask(
|
|||||||
distanceFromCurrent = 0,
|
distanceFromCurrent = 0,
|
||||||
created = null,
|
created = null,
|
||||||
lastModified = null,
|
lastModified = null,
|
||||||
tags = tags,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,10 +35,9 @@ class ICalendarWriterTest {
|
|||||||
rrule: String? = null,
|
rrule: String? = null,
|
||||||
rdate: String? = null,
|
rdate: String? = null,
|
||||||
parentId: Long? = null,
|
parentId: Long? = null,
|
||||||
tags: List<String> = emptyList(),
|
|
||||||
) = ExportTask(
|
) = ExportTask(
|
||||||
taskId, uid, title, description, location, url, priority, status, percentComplete,
|
taskId, uid, title, description, location, url, priority, status, percentComplete,
|
||||||
start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId, tags,
|
start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String =
|
private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String =
|
||||||
@@ -60,13 +59,6 @@ class ICalendarWriterTest {
|
|||||||
assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO")
|
assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `writes the tags as one CATEGORIES line, escaped`() {
|
|
||||||
val lines = linesOf(write(task(tags = listOf("Work", "Home, Garden"))))
|
|
||||||
assertThat(lines).contains("CATEGORIES:Work,Home\\, Garden")
|
|
||||||
assertThat(linesOf(write(task())).none { it.startsWith("CATEGORIES") }).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `uses CRLF line endings`() {
|
fun `uses CRLF line endings`() {
|
||||||
// RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected
|
// RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.domain.ical
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
/** RFC 5545 §3.3.6 `dur-value`, as alarm triggers use it. */
|
|
||||||
class ICalDurationTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the forms other clients write`() {
|
|
||||||
assertThat(ICalValues.parseDuration("-PT15M")).isEqualTo(-900L)
|
|
||||||
assertThat(ICalValues.parseDuration("+PT5M")).isEqualTo(300L)
|
|
||||||
assertThat(ICalValues.parseDuration("PT0S")).isEqualTo(0L)
|
|
||||||
assertThat(ICalValues.parseDuration("P1W")).isEqualTo(7 * 86_400L)
|
|
||||||
assertThat(ICalValues.parseDuration("-P1DT2H")).isEqualTo(-(86_400L + 7_200L))
|
|
||||||
assertThat(ICalValues.parseDuration("-P0DT0H15M0S")).isEqualTo(-900L)
|
|
||||||
assertThat(ICalValues.parseDuration("-PT90S")).isEqualTo(-90L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `what is not a duration is null`() {
|
|
||||||
listOf("", "P", "PT", "-P1DT", "15M", "PT15", "P1H", "20261001T090000Z", "P99999999999999999999D")
|
|
||||||
.forEach { assertThat(ICalValues.parseDuration(it)).isNull() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `written in days and time, and read back the same`() {
|
|
||||||
assertThat(ICalValues.formatDuration(-900)).isEqualTo("-PT15M")
|
|
||||||
assertThat(ICalValues.formatDuration(0)).isEqualTo("PT0S")
|
|
||||||
assertThat(ICalValues.formatDuration(-86_400)).isEqualTo("-P1D")
|
|
||||||
assertThat(ICalValues.formatDuration(-(86_400L + 5_400L))).isEqualTo("-P1DT1H30M")
|
|
||||||
listOf(-900L, 0L, -86_400L, -93_784L, 3_600L).forEach {
|
|
||||||
assertThat(ICalValues.parseDuration(ICalValues.formatDuration(it))).isEqualTo(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.navigation
|
package de.jeanlucmakiola.agendula.ui.navigation
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class DestinationsTest {
|
class DestinationsTest {
|
||||||
@@ -18,13 +17,4 @@ class DestinationsTest {
|
|||||||
assertThat(Dest.Settings.build()).isEqualTo(Dest.SETTINGS)
|
assertThat(Dest.Settings.build()).isEqualTo(Dest.SETTINGS)
|
||||||
assertThat(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)).isEqualTo("settings?section=Accounts")
|
assertThat(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)).isEqualTo("settings?section=Accounts")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a saved filter routes by id and comes back from its arguments`() {
|
|
||||||
assertThat(Dest.TaskList.build(TaskFilter.Saved(5))).isEqualTo("taskList?filterId=5")
|
|
||||||
assertThat(Dest.TaskList.filterOf(smart = null, listId = Dest.NO_ID, filterId = 5)).isEqualTo(TaskFilter.Saved(5))
|
|
||||||
assertThat(Dest.FilterEdit.build()).isEqualTo("filterEdit")
|
|
||||||
assertThat(Dest.FilterEdit.build(5)).isEqualTo("filterEdit?filterId=5")
|
|
||||||
assertThat(Dest.TaskEdit.buildNew(filterId = 5)).isEqualTo("taskEdit?filterId=5")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ class WidgetDataTest {
|
|||||||
fun `every offered filter round-trips through storage`() {
|
fun `every offered filter round-trips through storage`() {
|
||||||
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } +
|
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } +
|
||||||
TaskFilter.OfList(42) +
|
TaskFilter.OfList(42) +
|
||||||
TaskFilter.OfLists(setOf(1L, 2L, 3L)) +
|
TaskFilter.OfLists(setOf(1L, 2L, 3L))
|
||||||
TaskFilter.Saved(7)
|
|
||||||
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
|
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +45,7 @@ class WidgetDataTest {
|
|||||||
fun `garbage and smart lists the widget does not offer fall back to Today`() {
|
fun `garbage and smart lists the widget does not offer fall back to Today`() {
|
||||||
listOf(
|
listOf(
|
||||||
"", "list:", "list:-3", "list:abc", "lists:", "lists:abc", "lists:-1,-2",
|
"", "list:", "list:-3", "list:abc", "lists:", "lists:abc", "lists:-1,-2",
|
||||||
"smart:COMPLETED", "smart:nope", "filter:", "filter:0", "filter:x", "whatever",
|
"smart:COMPLETED", "smart:nope", "whatever",
|
||||||
).forEach {
|
).forEach {
|
||||||
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
|
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ VTIMEZONE bodies, fold positions, parameter quoting).
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `nextcloud-shared-confidential.ics` | A `CLASS:CONFIDENTIAL` task as Nextcloud rewrites it on GET from a **shared** calendar — `DUE`, `STATUS`, `COMPLETED`, `PERCENT-COMPLETE`, `PRIORITY` and `RELATED-TO` already stripped, ETag left untouched. Re-PUTting this destroys the owner's task |
|
| `nextcloud-shared-confidential.ics` | A `CLASS:CONFIDENTIAL` task as Nextcloud rewrites it on GET from a **shared** calendar — `DUE`, `STATUS`, `COMPLETED`, `PERCENT-COMPLETE`, `PRIORITY` and `RELATED-TO` already stripped, ETag left untouched. Re-PUTting this destroys the owner's task |
|
||||||
| `recurring-master-overrides.ics` | Master plus two `RECURRENCE-ID` overrides in one resource, sharing a UID |
|
| `recurring-master-overrides.ics` | Master plus two `RECURRENCE-ID` overrides in one resource, sharing a UID |
|
||||||
| `valarm-unknown-nested.ics` | Unknown properties **inside** a `VALARM` — the case a flat property-per-row model cannot represent. An Apple *default* alarm, so it stays in the residue |
|
| `valarm-unknown-nested.ics` | Unknown properties **inside** a `VALARM` — the case a flat property-per-row model cannot represent |
|
||||||
| `valarm-claimed-apple.ics` | The same Apple alarm without `X-APPLE-DEFAULT-ALARM`, so it is **claimed** as a reminder: `RELATED=START` on a task with only `DUE` (Apple's non-conformant shape) reads as a before-due lead, and the original must come back verbatim through the row's `raw` |
|
|
||||||
| `unknown-component.ics` | An entirely unknown sub-component |
|
| `unknown-component.ics` | An entirely unknown sub-component |
|
||||||
| `unknown-tzid.ics` | A `TZID` no device tzdb knows, with its own `VTIMEZONE` |
|
| `unknown-tzid.ics` | A `TZID` no device tzdb knows, with its own `VTIMEZONE` |
|
||||||
| `uid-special-chars.ics` | A UID containing `/` and `@` — the href sanitiser must not assume it is filename-safe |
|
| `uid-special-chars.ics` | A UID containing `/` and `@` — the href sanitiser must not assume it is filename-safe |
|
||||||
@@ -31,5 +30,3 @@ VTIMEZONE bodies, fold positions, parameter quoting).
|
|||||||
| `malformed-scalars.ics` | `PRIORITY:11`, `PERCENT-COMPLETE:150`, `SEQUENCE:x`, `STATUS:X-DEFERRED`, `CLASS:X-INTERNAL` — values no spec allows. Clamping or defaulting any of them is a silent rewrite of somebody's data |
|
| `malformed-scalars.ics` | `PRIORITY:11`, `PERCENT-COMPLETE:150`, `SEQUENCE:x`, `STATUS:X-DEFERRED`, `CLASS:X-INTERNAL` — values no spec allows. Clamping or defaulting any of them is a silent rewrite of somebody's data |
|
||||||
| `two-timezones.ics` | `DTSTART` and `DUE` in different zones. One `timezone` column cannot author both |
|
| `two-timezones.ics` | `DTSTART` and `DUE` in different zones. One `timezone` column cannot author both |
|
||||||
| `folded-utf8.ics` | A line folded mid-emoji-adjacent, to prove folding counts octets and splits on character boundaries |
|
| `folded-utf8.ics` | A line folded mid-emoji-adjacent, to prove folding counts octets and splits on character boundaries |
|
||||||
| `categories-shapes.ics` | One `CATEGORIES` line with an escaped comma inside a value — the one shape the column claims, so it is re-authored and must come out identical |
|
|
||||||
| `categories-split.ics` | One line per tag, a `LANGUAGE` parameter and a space after a comma. Read for display, but none of it is ours to rewrite |
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Several Clients//EN
|
|
||||||
BEGIN:VTODO
|
|
||||||
UID:categories-001
|
|
||||||
DTSTAMP:20260901T120000Z
|
|
||||||
SUMMARY:One line, an escaped comma
|
|
||||||
CATEGORIES:Work,Home\, Garden
|
|
||||||
END:VTODO
|
|
||||||
END:VCALENDAR
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Several Clients//EN
|
|
||||||
BEGIN:VTODO
|
|
||||||
UID:categories-002
|
|
||||||
DTSTAMP:20260901T120000Z
|
|
||||||
SUMMARY:One line per tag, one with a language
|
|
||||||
CATEGORIES:Errands
|
|
||||||
CATEGORIES;LANGUAGE=de:Einkauf
|
|
||||||
CATEGORIES:a, b
|
|
||||||
END:VTODO
|
|
||||||
END:VCALENDAR
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Apple Inc.//iOS 18.2//EN
|
|
||||||
BEGIN:VTODO
|
|
||||||
UID:alarm-claimed-001
|
|
||||||
DTSTAMP:20260901T120000Z
|
|
||||||
SUMMARY:Renew passport
|
|
||||||
DUE;TZID=Europe/Berlin:20261001T170000
|
|
||||||
BEGIN:VALARM
|
|
||||||
ACTION:DISPLAY
|
|
||||||
DESCRIPTION:Reminder
|
|
||||||
TRIGGER;RELATED=START:-PT30M
|
|
||||||
ACKNOWLEDGED:20260901T113000Z
|
|
||||||
X-WR-ALARMUID:8E6C4A1E-0000-4C1B-9B1A-3F5F4C2D9A11
|
|
||||||
END:VALARM
|
|
||||||
END:VTODO
|
|
||||||
END:VCALENDAR
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.sync
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskMove
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskMove
|
||||||
@@ -972,137 +970,6 @@ class CollectionSyncerTest {
|
|||||||
assertThat(report.discardedEdits.single().cause).isEqualTo(DiscardedEdit.Cause.DELETE_LOST)
|
assertThat(report.discardedEdits.single().cause).isEqualTo(DiscardedEdit.Cause.DELETE_LOST)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- reminders
|
|
||||||
|
|
||||||
private val due = Instant.parse("2026-03-05T09:00:00Z")
|
|
||||||
private val alarm15 = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nX-WR-ALARMUID:srv-1\r\n" +
|
|
||||||
"TRIGGER;RELATED=END:-PT15M\r\nEND:VALARM"
|
|
||||||
|
|
||||||
private fun alarm(taskId: Long, minutes: Int, uid: String = "a-$minutes") =
|
|
||||||
TaskAlarmEntity(taskId = taskId, minutesBefore = minutes, uid = uid)
|
|
||||||
|
|
||||||
@Test fun `an upload carries the task's reminders as VALARMs`() {
|
|
||||||
store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true)
|
|
||||||
store.alarms += alarm(1, 15, uid = "a-1")
|
|
||||||
|
|
||||||
sync()
|
|
||||||
|
|
||||||
val body = remote.resources.values.single().body
|
|
||||||
assertThat(body).contains("BEGIN:VALARM")
|
|
||||||
assertThat(body).contains("TRIGGER;RELATED=END:-PT15M")
|
|
||||||
assertThat(body).contains("X-WR-ALARMUID:a-1")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `the alarm keeps its identity across uploads`() {
|
|
||||||
store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true)
|
|
||||||
store.alarms += alarm(1, 15, uid = "a-1")
|
|
||||||
sync()
|
|
||||||
val first = remote.resources.values.single().body
|
|
||||||
store.rows[0] = store.rows[0].copy(title = "Pay the rent", isDirty = true)
|
|
||||||
|
|
||||||
sync()
|
|
||||||
|
|
||||||
val second = remote.resources.values.single().body
|
|
||||||
assertThat(second).isNotEqualTo(first)
|
|
||||||
assertThat(second).contains("X-WR-ALARMUID:a-1")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `a downloaded alarm becomes a reminder and leaves the residue`() {
|
|
||||||
remote.put("one.ics", vtodo("a", "Pay rent", extra = "DUE:20260305T090000Z\r\n$alarm15"))
|
|
||||||
|
|
||||||
sync()
|
|
||||||
|
|
||||||
val row = store.rows.single()
|
|
||||||
val reminder = store.alarms.single()
|
|
||||||
assertThat(reminder.taskId).isEqualTo(row.id)
|
|
||||||
assertThat(reminder.minutesBefore).isEqualTo(15)
|
|
||||||
assertThat(reminder.uid).isEqualTo("srv-1")
|
|
||||||
assertThat(row.unknownProperties.orEmpty()).doesNotContain("VALARM")
|
|
||||||
assertThat(row.isDirty).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `a re-download keeps the reminder's row, and a removal on the server clears it`() {
|
|
||||||
remote.put("one.ics", vtodo("a", "Pay rent", extra = "DUE:20260305T090000Z\r\n$alarm15"))
|
|
||||||
sync()
|
|
||||||
val id = store.alarms.single().id
|
|
||||||
remote.put("one.ics", vtodo("a", "Pay the rent", extra = "DUE:20260305T090000Z\r\n$alarm15"), eTag = "e-2")
|
|
||||||
|
|
||||||
sync()
|
|
||||||
|
|
||||||
// Matched by identity, so the row is updated in place rather than churned.
|
|
||||||
assertThat(store.alarms.single().id).isEqualTo(id)
|
|
||||||
|
|
||||||
remote.put("one.ics", vtodo("a", "Pay the rent", extra = "DUE:20260305T090000Z"), eTag = "e-3")
|
|
||||||
sync()
|
|
||||||
|
|
||||||
assertThat(store.alarms).isEmpty()
|
|
||||||
// Turned off elsewhere: "none", so the device default does not take over.
|
|
||||||
assertThat(store.rows.single().remindersOff).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `a task that never had alarms still takes the default`() {
|
|
||||||
remote.put("one.ics", vtodo("a", "Pay rent", extra = "DUE:20260305T090000Z"))
|
|
||||||
|
|
||||||
sync()
|
|
||||||
|
|
||||||
assertThat(store.rows.single().remindersOff).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `an alarm coming back as we sent it keeps the lead the wire could not carry`() {
|
|
||||||
// A 15-minute lead on an all-day task goes out as "on the day".
|
|
||||||
store.rows += task(id = 1, uid = "a", title = "Pay rent")
|
|
||||||
.copy(due = Instant.parse("2026-03-05T00:00:00Z"), isAllDay = true, isDirty = true)
|
|
||||||
store.alarms += alarm(1, 15, uid = "a-1")
|
|
||||||
sync()
|
|
||||||
val sent = remote.resources.values.single()
|
|
||||||
assertThat(sent.body).contains("TRIGGER;RELATED=END:PT0S")
|
|
||||||
|
|
||||||
// The server hands it back under a new ETag, unchanged.
|
|
||||||
sent.eTag = de.jeanlucmakiola.caldav.ETag("e-2", weak = false)
|
|
||||||
sync()
|
|
||||||
|
|
||||||
assertThat(store.alarms.single().minutesBefore).isEqualTo(15)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `a before-start reminder on a task without a start survives the round trip`() {
|
|
||||||
store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true)
|
|
||||||
store.alarms += alarm(1, 10, uid = "s-1").copy(reference = AlarmReference.START)
|
|
||||||
sync()
|
|
||||||
val sent = remote.resources.values.single()
|
|
||||||
// Sent from DUE, which is what the planner fires it from.
|
|
||||||
assertThat(sent.body).contains("TRIGGER;RELATED=END:-PT10M")
|
|
||||||
|
|
||||||
sent.eTag = de.jeanlucmakiola.caldav.ETag("e-2", weak = false)
|
|
||||||
sync()
|
|
||||||
|
|
||||||
assertThat(store.alarms.single().reference).isEqualTo(AlarmReference.START)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `an override without alarms has none, whatever its master carries`() {
|
|
||||||
val body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" +
|
|
||||||
"BEGIN:VTODO\r\nUID:s\r\nSUMMARY:Water\r\nDUE:20260305T090000Z\r\nRRULE:FREQ=DAILY\r\n" +
|
|
||||||
"$alarm15\r\nEND:VTODO\r\n" +
|
|
||||||
"BEGIN:VTODO\r\nUID:s\r\nSUMMARY:Water (late)\r\nRECURRENCE-ID:20260306T090000Z\r\n" +
|
|
||||||
"DUE:20260306T120000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
|
||||||
remote.put("s.ics", body)
|
|
||||||
|
|
||||||
sync()
|
|
||||||
|
|
||||||
val master = store.rows.single { it.recurrenceId == null }
|
|
||||||
assertThat(store.alarms.map { it.taskId }).containsExactly(master.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `a 412 discards a local reminder edit along with the rest`() {
|
|
||||||
remote.put("one.ics", vtodo("a", "Pay rent", extra = "DUE:20260305T090000Z\r\n$alarm15"), eTag = "newer")
|
|
||||||
store.rows += task(id = 1, uid = "a", title = "Pay rent")
|
|
||||||
.copy(href = "http://server/dav/tasks/one.ics", etag = "stale", due = due, isDirty = true)
|
|
||||||
store.alarms += alarm(1, 60)
|
|
||||||
|
|
||||||
sync()
|
|
||||||
|
|
||||||
assertThat(store.alarms.map { it.minutesBefore }).containsExactly(15)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun task(
|
private fun task(
|
||||||
id: Long,
|
id: Long,
|
||||||
uid: String,
|
uid: String,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.sync
|
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.TaskEntity
|
||||||
import de.jeanlucmakiola.caldav.CalendarCollection
|
import de.jeanlucmakiola.caldav.CalendarCollection
|
||||||
import de.jeanlucmakiola.caldav.ChangeSet
|
import de.jeanlucmakiola.caldav.ChangeSet
|
||||||
@@ -20,10 +19,6 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
|
|||||||
class FakeStore(rows: List<TaskEntity> = emptyList()) : SyncStore {
|
class FakeStore(rows: List<TaskEntity> = emptyList()) : SyncStore {
|
||||||
|
|
||||||
val rows = rows.toMutableList()
|
val rows = rows.toMutableList()
|
||||||
|
|
||||||
/** `task_alarms`, cascading with [rows] as the foreign key does. */
|
|
||||||
val alarms = mutableListOf<TaskAlarmEntity>()
|
|
||||||
private var nextAlarmId = 1L
|
|
||||||
val readOnlyWrites = mutableListOf<Pair<Long, Boolean>>()
|
val readOnlyWrites = mutableListOf<Pair<Long, Boolean>>()
|
||||||
|
|
||||||
/** In order, so a test can prove the token was stored *after* its bodies. */
|
/** In order, so a test can prove the token was stored *after* its bodies. */
|
||||||
@@ -32,7 +27,7 @@ class FakeStore(rows: List<TaskEntity> = emptyList()) : SyncStore {
|
|||||||
|
|
||||||
override fun rowsIn(listId: Long) = this.rows.filter { it.listId == listId }
|
override fun rowsIn(listId: Long) = this.rows.filter { it.listId == listId }
|
||||||
|
|
||||||
fun insert(row: TaskEntity): Long {
|
override fun insert(row: TaskEntity): Long {
|
||||||
// Computed against the rows as they stand, not at construction: tests
|
// Computed against the rows as they stand, not at construction: tests
|
||||||
// seed `rows` directly after building this, and an id fixed up front
|
// seed `rows` directly after building this, and an id fixed up front
|
||||||
// collides with the first seeded row — which then makes a delete of one
|
// collides with the first seeded row — which then makes a delete of one
|
||||||
@@ -50,30 +45,6 @@ class FakeStore(rows: List<TaskEntity> = emptyList()) : SyncStore {
|
|||||||
|
|
||||||
override fun deleteAll(taskIds: List<Long>) {
|
override fun deleteAll(taskIds: List<Long>) {
|
||||||
rows.removeAll { it.id in taskIds }
|
rows.removeAll { it.id in taskIds }
|
||||||
alarms.removeAll { it.taskId in taskIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun alarmsFor(taskIds: List<Long>) = alarms.filter { it.taskId in taskIds }.groupBy { it.taskId }
|
|
||||||
|
|
||||||
override fun upsertWithAlarms(row: TaskEntity, alarms: List<TaskAlarmEntity>): Long {
|
|
||||||
if (row.id != 0L && rows.none { it.id == row.id }) return row.id
|
|
||||||
val id = if (row.id == 0L) insert(row) else row.id.also { update(row) }
|
|
||||||
val hadAny = this.alarms.any { it.taskId == id }
|
|
||||||
// Matched by uid, then lead, as `TaskAlarmDao.replaceFromServer` does.
|
|
||||||
val existing = this.alarms.filter { it.taskId == id }.toMutableList()
|
|
||||||
this.alarms.removeAll { it.taskId == id }
|
|
||||||
alarms.forEach { alarm ->
|
|
||||||
val match = existing.firstOrNull { it.uid == alarm.uid }
|
|
||||||
?: existing.firstOrNull { it.minutesBefore == alarm.minutesBefore && it.reference == alarm.reference }
|
|
||||||
match?.let(existing::remove)
|
|
||||||
this.alarms += alarm.copy(id = match?.id ?: nextAlarmId++, taskId = id)
|
|
||||||
}
|
|
||||||
val index = rows.indexOfFirst { it.id == id }
|
|
||||||
when {
|
|
||||||
alarms.isNotEmpty() -> rows[index] = rows[index].copy(remindersOff = false)
|
|
||||||
hadAny -> rows[index] = rows[index].copy(remindersOff = true)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
|
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
|
||||||
|
|||||||
+4
-5
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Privacy Policy — Agendula
|
title: Privacy Policy — Agendula
|
||||||
description: What Agendula does with your data. No servers, no account, no analytics — your tasks stay on your device unless you add a CalDAV server yourself.
|
description: What Agendula does with your data. No servers, no account, no analytics — your tasks stay on your device unless you add a CalDAV server yourself.
|
||||||
updated: 2026-10-19
|
updated: 2026-09-21
|
||||||
---
|
---
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -21,7 +21,7 @@ updated: 2026-10-19
|
|||||||
render on the published page.
|
render on the published page.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
**Last updated:** 19 October 2026
|
**Last updated:** 21 September 2026
|
||||||
Applies to the Android app **Agendula** (package `de.jeanlucmakiola.agendula`),
|
Applies to the Android app **Agendula** (package `de.jeanlucmakiola.agendula`),
|
||||||
all versions and all distribution channels.
|
all versions and all distribution channels.
|
||||||
|
|
||||||
@@ -76,9 +76,8 @@ exported from the device.
|
|||||||
|
|
||||||
### What is transmitted, and to whom
|
### What is transmitted, and to whom
|
||||||
|
|
||||||
- The tasks in the synchronised lists, as standard iCalendar (`VTODO`) data —
|
- The tasks in the synchronised lists, as standard iCalendar (`VTODO`) data,
|
||||||
including their reminders, as `VALARM`s — and the credentials needed to
|
and the credentials needed to authenticate.
|
||||||
authenticate.
|
|
||||||
- Requests carry the user agent `Agendula (Android)` — a fixed string, so that
|
- Requests carry the user agent `Agendula (Android)` — a fixed string, so that
|
||||||
you can recognise and revoke the session on your server. No device identifier
|
you can recognise and revoke the session on your server. No device identifier
|
||||||
is sent.
|
is sent.
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
### جديد
|
|
||||||
- الوسوم: أضفها إلى المهام، وتصفحها من الشاشة الرئيسية، وصفِّ أي قائمة بها. تُزامَن كـ CATEGORIES.
|
|
||||||
- عوامل التصفية: احفظ عرضك الخاص للمهام المفتوحة حسب الاستحقاق والقائمة والوسم والأولوية، وفي الأداة أيضًا.
|
|
||||||
- أدوات جديدة: Today وUp next وLists وWeek. يمكن للأداة عرض عدة قوائم.
|
|
||||||
|
|
||||||
### تغييرات
|
|
||||||
- صفوف بنمط الأجندة في أداة المهام.
|
|
||||||
- يمكن الآن إعادة تسمية القوائم المتزامنة وحذفها.
|
|
||||||
|
|
||||||
### إصلاحات
|
|
||||||
- لم تعد مهام اليوم الكامل تنزاح يومًا غرب UTC.
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
### Neu
|
### Neu
|
||||||
- Tags: an Aufgaben vergeben, auf dem Startbildschirm durchsuchen, jede Liste danach filtern.
|
- Neue Widgets: Today, Up next, Lists und Week.
|
||||||
- Filter: eigene Ansicht offener Aufgaben nach Fälligkeit, Liste, Tag und Priorität, auch im Widget.
|
- Ein Widget kann mehrere Listen zeigen, auf Wunsch nach Liste gruppiert.
|
||||||
- Neue Widgets: Today, Up next, Lists und Week. Ein Widget kann mehrere Listen zeigen.
|
- Neue Aufgaben können heute beginnen (Einstellungen → Aufgaben-Formular).
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
- Agenda-Zeilen im Aufgaben-Widget.
|
- Das Aufgaben-Widget zeigt Zeilen im Agenda-Stil, optional farbig.
|
||||||
- Synchronisierte Listen lassen sich umbenennen und löschen.
|
- „At a glance“ entfällt; die Widgets Aufgaben und Today decken es ab.
|
||||||
|
- Synchronisierte Listen lassen sich jetzt auch umbenennen und löschen.
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
- Ganztägige Aufgaben landen westlich von UTC nicht mehr einen Tag daneben.
|
- Doppelter Abstand im Listen-Editor.
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
### Added
|
### Added
|
||||||
- Tags: add them to tasks, browse them from the home screen and filter any list by them. They sync as CATEGORIES.
|
- New home-screen widgets: Today, Up next, Lists and Week.
|
||||||
- Filters: save your own view of open tasks by due date, list, tag and priority, and show it in a widget.
|
- A widget can show tasks from several lists, optionally grouped by list.
|
||||||
- New widgets: Today, Up next, Lists and Week. A widget can show several lists.
|
- Settings → Task form can pre-fill a new task's start with today.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Agenda-style rows in the Tasks widget.
|
- The Tasks widget has agenda-style rows and an optional row colour.
|
||||||
- Synced lists can be renamed and deleted.
|
- "At a glance" is gone; the Tasks and Today widgets cover it.
|
||||||
|
- Synced lists can now be renamed and deleted too.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- All-day tasks no longer land a day off west of UTC.
|
- Double padding in the list editor.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user