Compare commits

..
Author SHA1 Message Date
makiolaj aabc24a472 fix: keep a non-default port in the account name (#105) 2026-10-28 12:59:12 +01:00
makiolaj a8bc3f0084 feat: let the user name an account (#105) 2026-10-28 12:18:04 +01:00
Jean-Luc Makiolaandmakiolaj 08c297f30a Release 1.2.0 (#103)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 1m11s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 18m4s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m33s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Stable 1.2.0, after 1.2.0-beta.1 and beta.2.

- Version bumped to 1.2.0 (1020099); the CHANGELOG entries moved from Unreleased to 1.2.0.
- What's New for every shipped locale, now including cs-CZ.
- Full store listing (title, short and full description) for every locale; new for cs-CZ, ar and fr-FR. Play only gets these through the `listing` lane.
- `main` is merged in with the branch side kept. #81 and the Weblate updates already came in through the #88 squash, and the only lines unique to `main` were string keys that #88 removed.

The #75 fix landed after beta.2, so no reporter has tested it yet.

Closes #72, closes #78, closes #75, closes #92

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/103
2026-10-26 20:41:58 +01:00
Jean-Luc Makiolaandmakiolaj 1d6c2a0d59 fix: sync task reminders as VALARMs (#52) (#81)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m1s
Reminders set in Agendula never reached the server, so a Mac or any other client saw none, and alarms set there never rang here. Reminders now sync as VALARMs in both directions.

**What syncs**
- Alarms Agendula can fire exactly as written become reminders: DISPLAY/AUDIO, relative to start or due, or absolute on a non-recurring task.
- Everything else stays in the residue and round-trips untouched: Apple default alarms, location alarms, repeating alarms, RFC 9074 snoozes, and triggers after their anchor.
- An unchanged alarm is re-emitted verbatim. An edited one is authored fresh under its existing uid. All-day leads go out as whole days.
- `ACKNOWLEDGED` is only added once a reminder is past the planner's missed-reminder grace period.

**Behaviour changes**
- Default reminders are stored on the task when it first gets a due date, so other clients see them. While untouched, they swap with the other default when the task flips between timed and all-day.
- Removing a task's alarms in another app turns them off here instead of falling back to the default. Tasks from clients that never write alarms keep the default.
- "All occurrences" reminder edits now reach occurrences that still had the series' old reminders.
- `.ics` import keeps VALARMs as reminders.

**Database v3**
- Alarms gain a `uid` and `raw` VALARM. Tasks gain `reminders_off`.
- The migration lifts claimable alarms out of the residue. It queues a one-time upload for tasks with local-only reminders on writable, non-confidential lists.

**Testing**
- Unit tests: full 662, offline 489. All instrumented tests pass on the emulator, including the migration. Lint is clean for both flavours.
- Not covered: the repository has no unit-test harness, so storing defaults at creation and the timed/all-day swap have no automated tests. Nothing has been checked against a real Mac yet.

Closes #52

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/81
2026-10-19 20:09:55 +02:00
Jean-Luc Makiola 2a1230c0ca Translations update from Weblate (#67)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m14s
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/67
2026-10-19 17:14:47 +02:00
Knee8811 cd3b33c959 Translated using Weblate (Czech)
Currently translated at 18.5% (121 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-19 01:54:42 +00:00
Knee8811 8175d58dcd Translated using Weblate (Czech)
Currently translated at 12.9% (85 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-18 18:45:06 +00:00
Knee8811 05696b2991 Translated using Weblate (Czech)
Currently translated at 12.0% (79 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-17 06:45:09 +00:00
Weblate 23f6156e71 Translated using Weblate (Arabic)
Currently translated at 74.3% (486 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-17 06:45:08 +00:00
Sup99999 c1b65c0c86 Translated using Weblate (Arabic)
Currently translated at 74.3% (486 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-17 06:45:08 +00:00
Weblate b5ac279b94 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/
2026-10-15 21:45:14 +00:00
Knee8811 8fba3f01a4 Translated using Weblate (Czech)
Currently translated at 9.3% (61 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-15 21:45:14 +00:00
Weblate 3b73dd4c53 Translated using Weblate (Czech)
Currently translated at 9.3% (61 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-15 21:45:14 +00:00
Sup99999 01850a7b74 Translated using Weblate (Arabic)
Currently translated at 52.1% (341 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-15 21:45:14 +00:00
Weblate 28e646fb31 Translated using Weblate (German)
Currently translated at 59.6% (390 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-15 21:45:14 +00:00
Sonja Kraus 6e3cc4bdbd Translated using Weblate (German)
Currently translated at 59.6% (390 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-15 21:45:14 +00:00
Jean-Luc Makiola b6185ed53c Translations update from Weblate (#53)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m6s
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/53
2026-10-15 13:26:10 +02:00
Knee8811 9338c64dc7 Added translation using Weblate (Czech) 2026-10-14 21:41:45 +00:00
Weblate 9a72fb0880 Translated using Weblate (Arabic)
Currently translated at 51.8% (339 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-14 21:41:45 +00:00
Sup99999 24e978efc3 Translated using Weblate (Arabic)
Currently translated at 51.8% (339 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-14 21:41:45 +00:00
Robin Schanbacher 2740dd5562 Translated using Weblate (German)
Currently translated at 58.5% (383 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-14 21:41:45 +00:00
Sup99999 8dbfd07d10 Translated using Weblate (Arabic)
Currently translated at 46.0% (265 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-14 21:41:45 +00:00
Weblate 3e1a84ab7d Translated using Weblate (Arabic)
Currently translated at 46.0% (265 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-14 21:41:45 +00:00
41 changed files with 839 additions and 23 deletions
+2
View File
@@ -7,6 +7,8 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
## [1.2.0] - 2026-10-26
### Added
- A reminder can count from the task's start as well as from its due date, and
tasks with only a start date get reminders too. Default reminders can mix
+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 = 1020002
versionName = "1.2.0-beta.2"
versionCode = 1020099
versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -0,0 +1,553 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "47f3bedbfbf4ddd1db32864c718de83a",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT, `label` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principalUrl",
"columnName": "principal_url",
"affinity": "TEXT"
},
{
"fieldPath": "homeSetUrl",
"columnName": "home_set_url",
"affinity": "TEXT"
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "lastSyncAt",
"columnName": "last_sync_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSyncError",
"columnName": "last_sync_error",
"affinity": "TEXT"
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "task_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "INTEGER"
},
{
"fieldPath": "isVisible",
"columnName": "is_visible",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "isSynced",
"columnName": "is_synced",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "owner",
"columnName": "owner",
"affinity": "TEXT"
},
{
"fieldPath": "isReadOnly",
"columnName": "is_read_only",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "ctag",
"columnName": "ctag",
"affinity": "TEXT"
},
{
"fieldPath": "syncToken",
"columnName": "sync_token",
"affinity": "TEXT"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_lists_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"account_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, `reminders_off` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "listId",
"columnName": "list_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "etag",
"columnName": "etag",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "percentComplete",
"columnName": "percent_complete",
"affinity": "INTEGER"
},
{
"fieldPath": "completedAt",
"columnName": "completed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "classification",
"columnName": "classification",
"affinity": "INTEGER"
},
{
"fieldPath": "dtstart",
"columnName": "dtstart",
"affinity": "INTEGER"
},
{
"fieldPath": "due",
"columnName": "due",
"affinity": "INTEGER"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT"
},
{
"fieldPath": "isAllDay",
"columnName": "is_all_day",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT"
},
{
"fieldPath": "rrule",
"columnName": "rrule",
"affinity": "TEXT"
},
{
"fieldPath": "rdate",
"columnName": "rdate",
"affinity": "TEXT"
},
{
"fieldPath": "exdate",
"columnName": "exdate",
"affinity": "TEXT"
},
{
"fieldPath": "recurrenceId",
"columnName": "recurrence_id",
"affinity": "INTEGER"
},
{
"fieldPath": "masterId",
"columnName": "master_id",
"affinity": "INTEGER"
},
{
"fieldPath": "parentId",
"columnName": "parent_id",
"affinity": "INTEGER"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastModified",
"columnName": "last_modified",
"affinity": "INTEGER"
},
{
"fieldPath": "sequence",
"columnName": "sequence",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDeleted",
"columnName": "is_deleted",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "unknownProperties",
"columnName": "unknown_properties",
"affinity": "TEXT"
},
{
"fieldPath": "remindersOff",
"columnName": "reminders_off",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_tasks_list_id_is_deleted",
"unique": false,
"columnNames": [
"list_id",
"is_deleted"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
},
{
"name": "index_tasks_parent_id",
"unique": false,
"columnNames": [
"parent_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
},
{
"name": "index_tasks_master_id_recurrence_id",
"unique": false,
"columnNames": [
"master_id",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
},
{
"name": "index_tasks_is_dirty",
"unique": false,
"columnNames": [
"is_dirty"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
},
{
"name": "index_tasks_list_id_uid_recurrence_id",
"unique": true,
"columnNames": [
"list_id",
"uid",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
}
],
"foreignKeys": [
{
"table": "task_lists",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"list_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"master_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"parent_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "task_alarms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, `uid` TEXT NOT NULL DEFAULT '', `raw` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskId",
"columnName": "task_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minutesBefore",
"columnName": "minutes_before",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reference",
"columnName": "reference",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DUE'"
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT"
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_alarms_task_id",
"unique": false,
"columnNames": [
"task_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
}
],
"foreignKeys": [
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"task_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"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, '47f3bedbfbf4ddd1db32864c718de83a')"
]
}
}
@@ -147,6 +147,21 @@ class TasksDatabaseMigrationTest {
}
}
@Test
fun addsAnEmptyAccountLabel() {
helper.createDatabase(TEST_DB, 3).use { db ->
db.execSQL("INSERT INTO accounts (id, display_name) VALUES (1, 'me@example.org')")
}
helper.runMigrationsAndValidate(TEST_DB, 4, true, TasksDatabase.MIGRATION_3_4).use { db ->
db.query("SELECT display_name, label FROM accounts").use { c ->
assertThat(c.moveToFirst()).isTrue()
assertThat(c.getString(0)).isEqualTo("me@example.org")
assertThat(c.isNull(1)).isTrue()
}
}
}
private companion object {
const val TEST_DB = "migration-test.db"
}
@@ -13,6 +13,7 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.AccountSyncRequests
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
@@ -79,7 +80,7 @@ abstract class SyncBindModule {
@Binds
@Singleton
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
abstract fun bindSyncRequests(impl: AccountSyncRequests): SyncRequests
@Binds
@IntoSet
@@ -97,6 +97,11 @@ class AccountRepository @Inject constructor(
/** The accounts, observed, so a sync landing updates a screen that is open. */
fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll()
/** Names the account on screen; blank goes back to the default (#105). */
suspend fun setLabel(accountId: Long, label: String) = withContext(io) {
database.accounts().setLabel(accountId, label.trim().ifEmpty { null })
}
/** Every account's synced lists. */
fun observeSyncedLists(): Flow<List<TaskListEntity>> = database.taskLists().observeSynced()
@@ -0,0 +1,20 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import javax.inject.Inject
/** [SyncRequests] by account id; [SyncTrigger] keys on the account's stable display name. */
class AccountSyncRequests @Inject constructor(
private val database: TasksDatabase,
private val trigger: SyncTrigger,
@IoDispatcher private val io: CoroutineDispatcher,
) : SyncRequests {
override suspend fun syncNow(accountId: Long) {
val name = withContext(io) { database.accounts().account(accountId)?.displayName } ?: return
trigger.enqueue(name)
}
}
@@ -35,11 +35,7 @@ import kotlin.time.Duration.Companion.seconds
@Singleton
class SyncTrigger @Inject constructor(
@ApplicationContext private val context: Context,
) : SyncRequests {
override fun syncNow(accountName: String) {
enqueue(accountName)
}
) {
/**
* Starts a sync now.
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.HistoryToggleOff
import androidx.compose.material.icons.rounded.Inventory2
@@ -26,6 +27,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -46,9 +48,11 @@ import de.jeanlucmakiola.agendula.data.sync.DiscardedEdit
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.ui.accounts.add.message
import de.jeanlucmakiola.agendula.ui.common.NameField
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
@@ -90,12 +94,34 @@ internal fun AccountDetailScreen(
// The confirmation sheet. The removal it starts outlives this screen, and is
// shown on the accounts list rather than here.
var confirming by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf(false) }
if (renaming) {
AccountNamePicker(
current = account.label.orEmpty(),
defaultTitle = account.defaultTitle(),
onSave = { name ->
viewModel.rename(account, name)
renaming = false
},
onDismiss = { renaming = false },
)
}
CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) {
AccountHero(identity)
Spacer(Modifier.height(24.dp))
val actions = buildList<@Composable (Position) -> Unit> {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_name),
summary = identity.title,
position = position,
leading = { Icon(Icons.Rounded.Edit, contentDescription = null) },
onClick = { renaming = true },
)
}
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_last_sync),
@@ -480,3 +506,30 @@ private fun errorTitle(text: String): AnnotatedString {
buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } }
}
}
/** Names an account on screen (#105); left empty, it goes back to [defaultTitle]. */
@Composable
private fun AccountNamePicker(
current: String,
defaultTitle: String,
onSave: (String) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember { mutableStateOf(current) }
FullScreenPicker(
title = stringResource(R.string.accounts_name),
onDismiss = onDismiss,
actions = {
TextButton(onClick = { onSave(name) }) { Text(stringResource(R.string.save)) }
},
) {
NameField(
name = name,
placeholder = defaultTitle,
autoFocus = true,
onNameChange = { name = it },
onImeAction = { onSave(name) },
)
GroupedHint(stringResource(R.string.accounts_name_hint, defaultTitle))
}
}
@@ -44,6 +44,14 @@ internal data class AccountIdentity(
val user: String?,
)
/** The title an account has without a [AccountEntity.label]. */
internal fun AccountEntity.defaultTitle(): String {
val url = principalUrl?.toHttpUrlOrNull()
val provider = url?.let { CalDavProvider.forPrincipal(it) }
val host = url?.host?.removePrefix("www.")
return if (provider?.hosted == true) provider.label else host ?: displayName
}
internal fun AccountEntity.identity(): AccountIdentity {
val url = principalUrl?.toHttpUrlOrNull()
val provider = url?.let { CalDavProvider.forPrincipal(it) }
@@ -53,7 +61,7 @@ internal fun AccountEntity.identity(): AccountIdentity {
provider = provider,
// The fallback is the stored name: an account with no principal URL
// never got past discovery, so there is nothing better to call it.
title = if (hosted) provider.label else host ?: displayName,
title = label ?: defaultTitle(),
secondary = if (hosted) host else provider?.label,
user = username?.takeIf { it.isNotBlank() },
)
@@ -248,6 +248,10 @@ class AccountsViewModel @Inject constructor(
syncTrigger.enqueue(account.displayName, expedited = true)
}
fun rename(account: AccountEntity, label: String) {
viewModelScope.launch { repository.setLabel(account.id, label) }
}
/** The user has read what the last sync changed. */
fun dismissNotices(account: AccountEntity) {
viewModelScope.launch { notices.dismiss(account.id) }
@@ -1386,10 +1386,14 @@ class AddAccountViewModel @Inject constructor(
}
}
// The port is part of the name: two servers on one host are two accounts (#105).
private fun accountName(): String =
username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.host.orEmpty()}" }
username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.authority().orEmpty()}" }
?: typedInput
private fun HttpUrl.authority(): String =
if (port == HttpUrl.defaultPort(scheme)) host else "$host:$port"
internal companion object {
const val POLL_INTERVAL_MILLIS = 2_000L
@@ -89,7 +89,7 @@ object DataProvideModule {
// backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3)
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4)
.build()
/**
@@ -2,5 +2,5 @@ package de.jeanlucmakiola.agendula.data.sync
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
fun interface SyncRequests {
fun syncNow(accountName: String)
suspend fun syncNow(accountId: Long)
}
@@ -37,6 +37,9 @@ interface AccountDao {
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
fun recordSync(accountId: Long, at: Instant?, error: String?)
@Query("UPDATE accounts SET label = :label WHERE id = :accountId")
fun setLabel(accountId: Long, label: String?)
@Query("DELETE FROM accounts WHERE id = :accountId")
fun delete(accountId: Long): Int
}
@@ -26,6 +26,8 @@ data class AccountEntity(
@ColumnInfo(name = "username") val username: String? = null,
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null,
@ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null,
/** A name the user gave the account (#105); [displayName] stays the stable key. */
@ColumnInfo(name = "label") val label: String? = null,
)
/**
@@ -11,6 +11,7 @@ import androidx.room.Embedded
data class TaskListRow(
@Embedded val list: TaskListEntity,
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
@ColumnInfo(name = "account_label") val accountLabel: String? = null,
)
/**
@@ -23,5 +24,6 @@ data class TaskRow(
@ColumnInfo(name = "list_name") val listName: String,
@ColumnInfo(name = "list_color") val listColor: Int,
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
@ColumnInfo(name = "account_label") val accountLabel: String? = null,
@ColumnInfo(name = "list_read_only") val listReadOnly: Boolean = false,
)
@@ -20,7 +20,7 @@ object RoomTaskMapper {
color = row.list.color,
// TaskList.accountName is non-null and the lists screen groups by it, so a
// list with no account still has to report something to group under.
accountName = row.accountDisplayName ?: LocalAccount.NAME,
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME,
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
isSynced = row.list.isSynced,
isVisible = row.list.isVisible,
@@ -61,7 +61,7 @@ object RoomTaskMapper {
listColor = row.listColor,
taskColor = row.task.color,
listName = row.listName,
accountName = row.accountDisplayName ?: LocalAccount.NAME,
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME,
parentId = row.task.parentId,
isRecurring = row.task.isRecurring,
occurrenceStart = occurrenceStart,
@@ -29,7 +29,7 @@ interface TaskDao {
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
a.display_name AS account_display_name
a.display_name AS account_display_name, a.label AS account_label
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
@@ -44,7 +44,7 @@ interface TaskDao {
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
a.display_name AS account_display_name
a.display_name AS account_display_name, a.label AS account_label
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
@@ -56,7 +56,7 @@ interface TaskDao {
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
a.display_name AS account_display_name
a.display_name AS account_display_name, a.label AS account_label
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
@@ -16,7 +16,7 @@ interface TaskListDao {
@Query(
"""
SELECT l.*, a.display_name AS account_display_name
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
ORDER BY a.display_name, l.sort_order, l.name
"""
@@ -25,7 +25,7 @@ interface TaskListDao {
@Query(
"""
SELECT l.*, a.display_name AS account_display_name
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
WHERE l.id = :listId
"""
@@ -22,7 +22,7 @@ import kotlin.time.Instant
TaskEntity::class,
TaskAlarmEntity::class,
],
version = 3,
version = 4,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -173,5 +173,12 @@ abstract class TasksDatabase : RoomDatabase() {
)
}
}
/** v4 lets the user name an account (#105). */
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE accounts ADD COLUMN label TEXT")
}
}
}
}
@@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor(
val result = importer.import(ready.parsed, target)
(chosen as? ImportChoice.Existing)?.list
?.takeIf { it.accountId != null && result.imported > 0 }
?.let { runCatching { syncRequests.syncNow(it.accountName) } }
?.let { runCatching { syncRequests.syncNow(it.accountId!!) } }
ImportStep.Done(
listName = listName,
imported = result.imported,
+2
View File
@@ -681,6 +681,8 @@
<string name="accounts_remove_wipe_body">Also deletes this account\u2019s lists and tasks from this device.</string>
<string name="accounts_never_synced">Never synced</string>
<string name="accounts_last_sync">Last sync</string>
<string name="accounts_name">Name</string>
<string name="accounts_name_hint">How this account is shown in Agendula. Leave it empty to use “%1$s”.</string>
<string name="accounts_generic_provider">CalDAV account</string>
<string name="accounts_sync_failed">Last sync didn\u2019t finish</string>
<string name="accounts_failed_sign_in">Sync failed \u2014 the server didn\u2019t accept the password</string>
@@ -27,4 +27,16 @@ class RoomTaskMapperTest {
assertThat(RoomTaskMapper.taskList(list).acceptsWrites).isFalse()
}
@Test
fun `an account's label is shown in place of its stored name`() {
val list = TaskListRow(
list = TaskListEntity(id = 2, name = "Work", color = 0, accountId = 1),
accountDisplayName = "me@host",
accountLabel = "Work server",
)
assertThat(RoomTaskMapper.taskList(list).accountName).isEqualTo("Work server")
assertThat(RoomTaskMapper.taskList(list.copy(accountLabel = null)).accountName).isEqualTo("me@host")
}
}
@@ -1307,6 +1307,15 @@ class AddAccountViewModelTest {
assertThat(vm.state.value.fatal).isEqualTo(AddAccountMessage.ExternalStorage)
}
@Test
fun `a server on its own port is named with it`() = runTest(dispatcher) {
val vm = signedIn("https://cloud.example.com:5233/")
vm.onSave()
advanceUntilIdle()
assertThat(creator.created).containsExactly("me@cloud.example.com:5233")
}
@Test
fun `a completed add reports the account it created`() = runTest(dispatcher) {
val vm = signedIn()
@@ -1406,12 +1415,14 @@ class AddAccountViewModelTest {
* submit, so going straight from the address to a `Found` leaves the
* username and password empty.
*/
private fun kotlinx.coroutines.test.TestScope.signedIn(): AddAccountViewModel {
private fun kotlinx.coroutines.test.TestScope.signedIn(
address: String = "https://cloud.example.com/",
): AddAccountViewModel {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
gateway.loginFlow = null
val vm = viewModel()
vm.enterAddress("https://cloud.example.com/")
vm.enterAddress(address)
vm.onAddressSubmitted()
advanceUntilIdle()
@@ -0,0 +1,11 @@
### جديد
- يمكن أن يُحسب التذكير من بدء المهمة أو من موعد الاستحقاق.
- يمكن لمهام اليوم الكامل أن تحمل تذكيرات في أوقات محددة.
- يمكن للتطبيقات الأخرى قراءة مهامك المحلية وتعديلها عبر OpenTasks، بإذنك.
### تغييرات
- تُزامَن التذكيرات كـ VALARM، فترنّ أيضًا في تذكيرات Apple وThunderbird وtasks.org.
### إصلاحات
- تعمل الأدوات مجددًا بعد إنجاز المهام.
- عاد تسجيل الدخول بالبريد الإلكتروني يعمل لدى mailbox.org وPosteo.
@@ -0,0 +1,11 @@
Agendula تطبيق مهام حديث ومفتوح المصدر لنظام Android.
زامن مهامك عبر CalDAV: أضف حساب Nextcloud أو Radicale أو Baïkal أو أي حساب CalDAV آخر، واختر قوائم المهام التي تريد مزامنتها. أو احتفظ بكل شيء على الهاتف، فـ Agendula يخزّن مهامك بنفسه، دون حساب ودون تثبيت أي شيء آخر.
هل تستخدم OpenTasks أو tasks.org؟ اربط Agendula بذلك التطبيق لتظهر المهام التي يزامنها DAVx5 أو ما يشبهه كما هي، وانسخها إلى تخزين Agendula الخاص متى شئت من الإعدادات ← التخزين.
ما يقدمه: قوائم تديرها داخل التطبيق، تواريخ الاستحقاق والبدء، مهام اليوم الكامل، مهام فرعية، أولويات، نسبة الإنجاز، مهام متكررة يمكنك تعديل موعد واحد منها، عدة تذكيرات لكل مهمة مع الغفوة، استيراد iCalendar وتصديره، أداة للشاشة الرئيسية، اختصارات المشغّل، وبلاطة في الإعدادات السريعة.
التصميم Material 3 Expressive حقيقي في كل مكان، مع ألوان ديناميكية وحركة وأشكال معبّرة.
الخصوصية: لا قياس عن بُعد، ولا تحليلات، ولا إعلانات. لا يتصل Agendula بالإنترنت إلا للمزامنة مع حسابات CalDAV التي تضيفها. ودون حساب، لا تغادر مهامك الجهاز إلا إذا صدّرتها.
@@ -0,0 +1 @@
تطبيق مهام بتصميم Material 3 Expressive مع مزامنة CalDAV.
+1
View File
@@ -0,0 +1 @@
Agendula: Tasks
@@ -0,0 +1,11 @@
### Nové
- Připomínky se mohou počítat od začátku úkolu i od termínu.
- Celodenní úkoly mohou mít připomínky v pevný čas.
- Jiné aplikace mohou s vaším svolením číst a upravovat místní úkoly přes OpenTasks.
### Změny
- Připomínky se synchronizují jako VALARM a zvoní i v Připomínkách Apple, Thunderbirdu a tasks.org.
### Opravy
- Widgety opět fungují po odškrtnutí úkolu.
- Přihlášení e-mailem u mailbox.org a Posteo opět funguje.
@@ -0,0 +1,11 @@
Agendula je moderní open-source aplikace na úkoly pro Android.
Synchronizujte úkoly přes CalDAV: přidejte účet Nextcloud, Radicale, Baïkal nebo jiný CalDAV účet a vyberte, které seznamy úkolů se mají synchronizovat. Nebo nechte vše v telefonu. Agendula si úkoly ukládá sama, bez účtu a bez instalace čehokoli dalšího.
Už používáte OpenTasks nebo tasks.org? Nasměrujte Agendulu na tuto aplikaci, aby se úkoly synchronizované přes DAVx5 a podobně zobrazily tak, jak jsou, a kdykoli si je zkopírujte do vlastního úložiště Agenduly v Nastavení → Úložiště.
Co umí: seznamy spravované přímo v aplikaci, termíny a data začátku, celodenní úkoly, podúkoly, priority, průběh, opakované úkoly s možností upravit jediný výskyt, více připomínek na úkol s odložením, import a export iCalendar, widget na plochu, zkratky spouštěče a dlaždici Rychlého nastavení.
Design je skrz naskrz Material 3 Expressive, s dynamickými barvami a expresivními animacemi a tvary.
Soukromí: žádná telemetrie, žádná analytika, žádné reklamy. Agendula se připojuje k internetu jen kvůli synchronizaci s CalDAV účty, které sami přidáte. Bez účtu vaše úkoly zařízení neopustí, pokud je sami neexportujete.
@@ -0,0 +1 @@
Úkolníček v Material 3 Expressive se synchronizací CalDAV.
@@ -0,0 +1 @@
Agendula: Tasks
@@ -0,0 +1,11 @@
### Neu
- Erinnerungen können ab dem Start oder ab der Fälligkeit zählen.
- Ganztägige Aufgaben können Erinnerungen zu festen Uhrzeiten haben.
- Andere Apps können lokale Aufgaben über OpenTasks lesen und bearbeiten, wenn du es erlaubst.
### Geändert
- Erinnerungen werden als VALARM synchronisiert und klingeln auch in Apple Erinnerungen, Thunderbird und tasks.org.
### Behoben
- Widgets funktionieren wieder nach dem Abhaken.
- Die Anmeldung per E-Mail klappt wieder bei mailbox.org und Posteo.
@@ -0,0 +1,11 @@
### Added
- Reminders can count from a task's start as well as its due date.
- All-day tasks can have reminders at set times, e.g. 09:00 and 17:00.
- Other apps can read and edit your local tasks through an OpenTasks provider, with your permission.
### Changed
- Reminders sync as VALARMs, so they also ring in Apple Reminders, Thunderbird and tasks.org.
### Fixed
- Widgets work again after ticking off tasks.
- Signing in by email works again for mailbox.org and Posteo.
@@ -0,0 +1,11 @@
### Added
- Reminders can count from a task's start as well as its due date.
- All-day tasks can have reminders at set times, e.g. 09:00 and 17:00.
- Other apps can read and edit your local tasks through an OpenTasks provider, with your permission.
### Changed
- Reminders sync as VALARMs, so they also ring in Apple Reminders, Thunderbird and tasks.org.
### Fixed
- Widgets work again after ticking off tasks.
- Signing in by email works again for mailbox.org and Posteo.
@@ -0,0 +1,11 @@
### Nouveautés
- Un rappel peut partir du début ou de l'échéance d'une tâche.
- Rappels à heure fixe pour les tâches sur la journée.
- Avec votre accord, d'autres applis peuvent lire et modifier vos tâches locales via OpenTasks.
### Modifications
- Les rappels se synchronisent en VALARM et sonnent aussi dans Rappels d'Apple, Thunderbird et tasks.org.
### Corrections
- Les widgets marchent de nouveau après une tâche cochée.
- La connexion par e-mail marche de nouveau avec mailbox.org et Posteo.
@@ -0,0 +1,11 @@
Agendula est une appli de tâches moderne et open source pour Android.
Synchronisez vos tâches via CalDAV : ajoutez un compte Nextcloud, Radicale, Baïkal ou tout autre compte CalDAV et choisissez les listes de tâches à synchroniser. Ou gardez tout sur le téléphone : Agendula stocke vos tâches lui-même, sans compte et sans rien d'autre à installer.
Vous utilisez déjà OpenTasks ou tasks.org ? Reliez Agendula à cette appli pour que les tâches synchronisées par DAVx5 ou similaire s'affichent telles quelles, et copiez-les quand vous voulez dans le stockage propre d'Agendula, sous Réglages → Stockage.
Ce qu'il fait : listes gérées dans l'appli, dates d'échéance et de début, tâches sur toute la journée, sous-tâches, priorités, avancement, tâches récurrentes dont vous pouvez modifier une seule occurrence, plusieurs rappels par tâche avec report, import et export iCalendar, un widget d'écran d'accueil, des raccourcis de lanceur et une tuile de Réglages rapides.
Le design est du vrai Material 3 Expressive de bout en bout, avec couleurs dynamiques, animations et formes expressives.
Confidentialité : pas de télémétrie, pas d'analytique, pas de publicité. Agendula ne se connecte que pour synchroniser les comptes CalDAV que vous ajoutez. Sans compte, vos tâches ne quittent l'appareil que si vous les exportez.
@@ -0,0 +1 @@
Une appli de tâches Material 3 Expressive avec synchro CalDAV.
@@ -0,0 +1 @@
Agendula: Tasks
@@ -0,0 +1,11 @@
### Novidades
- Lembretes podem contar a partir do início da tarefa ou do prazo.
- Tarefas de dia inteiro podem ter lembretes em horários fixos.
- Outros apps podem ler e editar suas tarefas locais via OpenTasks, com sua permissão.
### Alterações
- Lembretes sincronizam como VALARM e tocam também no Lembretes da Apple, Thunderbird e tasks.org.
### Correções
- Widgets voltam a funcionar após marcar tarefas.
- O login por e-mail volta a funcionar no mailbox.org e Posteo.