Author SHA1 Message Date
makiolaj aca6438a31 DOCS: Add a section for which CalDAV servers have been tested
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-09 18:24:35 +02:00
makiolaj ee0250faf9 chore: 1.1.0-beta.3
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 11m51s
2026-10-09 12:21:05 +02:00
Jean-Luc Makiolaandmakiolaj aa2aade930 feat: tags (#35) (#46)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Adds tags, stored as the standard `CATEGORIES` property so they stay compatible with Nextcloud Tasks, tasks.org and DAVx⁵.

## What changed

- **Storage and sync:** tags are a new `categories` column (database v2). The migration lifts `CATEGORIES` out of the stored unknown properties for tasks that were synced before, so existing tags show up without a re-download. A line Agendula can't write back exactly as it came (e.g. a duplicated value, or the property split across several lines) stays where it was and still syncs byte for byte.
- **Provider mode (OpenTasks / tasks.org):** tags are read and written as the provider's category properties. Writes are a single batch, so a refused tag can't leave half the tags behind or create a task twice.
- **Imports:** the copy from OpenTasks/tasks.org and the legacy one-shot import both bring tags across.
- **Editor:** a new optional Tags field. Type a tag and press Done or type a comma; tags already in use are suggested while typing, and text left in the field is still saved.
- **Display:** tags show as pills on the detail screen and in each row's info line in task lists.
- **Using tags:** the home screen has a Tags section listing every tag on an open task with its count. Tapping one opens that tag's tasks across all lists, and "New task" there starts with the tag set. Every task list's sort button is now a "Sort and filter" picker, where you can tick one or more tags to narrow the list.

## Also in here

- **Keyboard:** a focused input now scrolls fully above the keyboard, not just its text line. This uses floret-kit's new `revealOnFocus` (jlmakiola/floret-kit#12), and the pointer moves to floret-kit main.
- **Edit screen:** once the title scrolls out of view, it shows in the top bar.
- **main merged in:** the branch brings main's #40 and #44 (floret-kit on its shared main, Gradle 9.8), which `release/v1.1.0` doesn't have yet.

## Where this differs from the issue

- **Filtering by several tags** shows tasks that carry *all* of them, since ticking more tags is meant to narrow the list.
- **Tags are case-sensitive:** "Work" and "work" are two tags, matching how they are stored and synced.
- **The overview** lists tags on open tasks only, the same set the editor suggests.
- **Widgets** can't be set to a tag yet.

Closes #35

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/46
2026-10-09 12:20:21 +02:00
Jean-Luc Makiolaandmakiolaj 89775e44a7 chore: merge main into release/v1.1.0 (#42)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Successful in 26m27s
Brings the exact tag checks from #40 into the release branch. beta.yaml runs from the branch's own files, and the old prefix-matching lookup treated v1.1.0-beta.1 as a shipped v1.1.0, so the 1.1.0-beta.2 run was refused.

The one conflict was in publish_codeberg_release.sh: kept #40's stricter upload handling plus the offline APK assets from #41. scripts/release_gate.sh now reports cut=true for 1.1.0-beta.2.

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