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
This commit is contained in:
Binary file not shown.
+9
@@ -212,6 +212,15 @@ class OneShotImportTest {
|
||||
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importsCategoryPropertiesAsTags() {
|
||||
importer.importFrom(fixture())
|
||||
val tasks = taskRows()
|
||||
|
||||
assertThat(tasks.getValue("Buy milk").categories).containsExactly("Errands")
|
||||
assertThat(tasks.getValue("Ship the release").categories).isEmpty()
|
||||
}
|
||||
|
||||
// --- running it -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
|
||||
+34
-21
@@ -8,27 +8,7 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The migration harness, proven against the committed schema in `app/schemas/`.
|
||||
*
|
||||
* There is one schema version today, so all there is to assert is that the helper
|
||||
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
|
||||
* export, the assets wiring and the identity hash all line up. That is the point:
|
||||
* the first real migration only has to add its own case.
|
||||
*
|
||||
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
|
||||
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
|
||||
* database, and add a test here shaped like this:
|
||||
*
|
||||
* ```
|
||||
* helper.createDatabase(TEST_DB, 1).use { db ->
|
||||
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
|
||||
* }
|
||||
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
|
||||
* // read the seeded rows back — validation proves the shape, not the data
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
/** Migrations, proven against the committed schemas in `app/schemas/`. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabaseMigrationTest {
|
||||
|
||||
@@ -62,6 +42,39 @@ class TasksDatabaseMigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liftsCategoriesOutOfTheResidueWithoutDirtyingTheTask() {
|
||||
helper.createDatabase(TEST_DB, 1).use { db ->
|
||||
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Work', 0)")
|
||||
db.execSQL(
|
||||
"INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)",
|
||||
arrayOf<Any?>(1, "uid-1", "Claimed", "X-FOO:bar\r\nCATEGORIES:Work,Home\r\n"),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)",
|
||||
arrayOf<Any?>(2, "uid-2", "Split", "CATEGORIES:a\r\nCATEGORIES:b\r\n"),
|
||||
)
|
||||
db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (3, 1, 'uid-3', 'Untagged')")
|
||||
}
|
||||
|
||||
helper.runMigrationsAndValidate(TEST_DB, 2, true, TasksDatabase.MIGRATION_1_2).use { db ->
|
||||
db.query("SELECT categories, unknown_properties, is_dirty FROM tasks ORDER BY id").use { c ->
|
||||
c.moveToNext()
|
||||
assertThat(c.getString(0)).isEqualTo("Work,Home")
|
||||
assertThat(c.getString(1)).doesNotContain("CATEGORIES")
|
||||
assertThat(c.getString(1)).contains("X-FOO:bar")
|
||||
assertThat(c.getInt(2)).isEqualTo(0)
|
||||
|
||||
c.moveToNext()
|
||||
assertThat(c.getString(0)).isEqualTo("a,b")
|
||||
assertThat(c.getString(1)).isEqualTo("CATEGORIES:a\r\nCATEGORIES:b\r\n")
|
||||
|
||||
c.moveToNext()
|
||||
assertThat(c.getString(0)).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
|
||||
+13
@@ -107,6 +107,17 @@ class ExternalImportTest {
|
||||
assertThat(importer.hasRun.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copiesTagsCleanedUp() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
source.tasks = mapOf(7L to listOf(task(100, "Milk", tags = listOf("Shop", " Shop ", "", "Home"))))
|
||||
|
||||
importer.run()
|
||||
|
||||
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).single().task.categories)
|
||||
.containsExactly("Shop", "Home").inOrder()
|
||||
}
|
||||
|
||||
/** Every list arrives device-only: the account belongs to the sync app. */
|
||||
@Test
|
||||
fun importedListsAreDeviceOnly() = runBlocking {
|
||||
@@ -241,6 +252,7 @@ class ExternalImportTest {
|
||||
title: String,
|
||||
uid: String? = "uid-$id",
|
||||
parentId: Long? = null,
|
||||
tags: List<String> = emptyList(),
|
||||
) = ExportTask(
|
||||
taskId = id,
|
||||
uid = uid,
|
||||
@@ -260,6 +272,7 @@ class ExternalImportTest {
|
||||
rrule = null,
|
||||
rdate = null,
|
||||
parentId = parentId,
|
||||
tags = tags,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -89,6 +89,7 @@ object DataProvideModule {
|
||||
// backed-up `.db` does not. The backup rules carry all three files and
|
||||
// the app checkpoints on ON_STOP.
|
||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||
.addMigrations(TasksDatabase.MIGRATION_1_2)
|
||||
.build()
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
@@ -19,6 +20,7 @@ import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
@@ -90,11 +92,14 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
// `is_recurring` / `distance_from_current`). The by-name mapper reads
|
||||
// whatever is present and nulls the rest; the repository re-sorts, so we
|
||||
// don't depend on a provider sort column either.
|
||||
return resolver.query(uri, null, selection, args, null)
|
||||
val tasks = resolver.query(uri, null, selection, args, null)
|
||||
?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
|
||||
} ?: emptyList()
|
||||
if (tasks.isEmpty()) return tasks
|
||||
val tags = categories(tasks.map { it.taskId })
|
||||
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
||||
}
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||
@@ -102,7 +107,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
// table's shape varies across provider versions, and the by-name mapper
|
||||
// reads what's there.
|
||||
val uri = TasksContract.tasksUri(authority())
|
||||
return resolver.query(
|
||||
val tasks = resolver.query(
|
||||
uri,
|
||||
null,
|
||||
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
|
||||
@@ -115,21 +120,39 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
|
||||
} ?: emptyList()
|
||||
if (tasks.isEmpty()) return tasks
|
||||
val tags = categories(tasks.map { it.taskId })
|
||||
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
override fun insertTask(form: TaskForm): Long {
|
||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
|
||||
?: throw TaskWriteFailedException("insert task")
|
||||
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
|
||||
// One batch, so a refused tag leaves no task behind for a retry to duplicate.
|
||||
val authority = authority()
|
||||
val ops = arrayListOf(
|
||||
ContentProviderOperation.newInsert(TasksContract.tasksUri(authority))
|
||||
.withValues(values.toContentValues())
|
||||
.build(),
|
||||
)
|
||||
normalizeTags(form.tags).forEach { tag ->
|
||||
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
|
||||
.withValues(TaskWriteMapper.categoryValues(0, tag).toContentValues().apply { remove(Properties.TASK_ID) })
|
||||
.withValueBackReference(Properties.TASK_ID, 0)
|
||||
.build()
|
||||
}
|
||||
val results = runCatching { resolver.applyBatch(authority, ops) }
|
||||
.getOrElse { throw TaskWriteFailedException("insert task", it) }
|
||||
return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull()
|
||||
?: throw TaskWriteFailedException("insert task: no id")
|
||||
}
|
||||
|
||||
override fun updateTask(taskId: Long, form: TaskForm) {
|
||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||
setCategories(taskId, form.tags)
|
||||
}
|
||||
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||
@@ -151,6 +174,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
|
||||
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
|
||||
setCategories(seriesId, form.tags)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,8 +267,28 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
||||
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
||||
// The fork copies the series' properties onto the override, so the tags
|
||||
// go onto the series first and are put back once the override exists.
|
||||
// Unchanged tags are inherited as they are, and the series is left alone.
|
||||
val seriesTags = categories(listOf(taskId))[taskId].orEmpty()
|
||||
val retag = normalizeTags(form.tags) != seriesTags
|
||||
if (retag) setCategories(taskId, form.tags)
|
||||
var failure: Throwable? = null
|
||||
try {
|
||||
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
||||
} catch (e: Throwable) {
|
||||
failure = e
|
||||
throw e
|
||||
} finally {
|
||||
if (retag) {
|
||||
try {
|
||||
setCategories(taskId, seriesTags)
|
||||
} catch (e: Throwable) {
|
||||
failure?.addSuppressed(e) ?: throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,6 +392,58 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
/** Tags by task id for [taskIds], in stored order. */
|
||||
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
|
||||
val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk ->
|
||||
val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})"
|
||||
val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray()
|
||||
resolver.query(
|
||||
TasksContract.propertiesUri(authority()),
|
||||
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
|
||||
selection,
|
||||
args,
|
||||
Properties.PROPERTY_ID,
|
||||
)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
val id = reader.getLong(Properties.TASK_ID) ?: continue
|
||||
val name = reader.getString(TasksContract.Category.NAME) ?: continue
|
||||
add(id to name)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
|
||||
* leave half the tags, and leaves them alone when nothing changed so the task
|
||||
* isn't dirtied.
|
||||
*/
|
||||
private fun setCategories(taskId: Long, tags: List<String>) {
|
||||
val wanted = normalizeTags(tags)
|
||||
if (categories(listOf(taskId))[taskId].orEmpty() == wanted) return
|
||||
val authority = authority()
|
||||
val uri = TasksContract.propertiesUri(authority)
|
||||
val ops = arrayListOf(
|
||||
ContentProviderOperation.newDelete(uri)
|
||||
.withSelection(
|
||||
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
|
||||
arrayOf(taskId.toString(), TasksContract.Category.MIMETYPE),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
wanted.forEach {
|
||||
ops += ContentProviderOperation.newInsert(uri)
|
||||
.withValues(TaskWriteMapper.categoryValues(taskId, it).toContentValues())
|
||||
.build()
|
||||
}
|
||||
runCatching { resolver.applyBatch(authority, ops) }
|
||||
.getOrElse { throw TaskWriteFailedException("set categories for task $taskId", it) }
|
||||
}
|
||||
|
||||
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
||||
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
|
||||
@@ -460,3 +556,6 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
return cv
|
||||
}
|
||||
}
|
||||
|
||||
/** SQLite's default bound-argument limit on older Android releases. */
|
||||
private const val MAX_SQL_ARGS = 999
|
||||
|
||||
@@ -5,8 +5,8 @@ class ProviderUnavailableException :
|
||||
IllegalStateException("No tasks provider installed")
|
||||
|
||||
/** A ContentResolver write returned no URI or affected no rows. */
|
||||
class TaskWriteFailedException(operation: String) :
|
||||
RuntimeException("Task write failed: $operation")
|
||||
class TaskWriteFailedException(operation: String, cause: Throwable? = null) :
|
||||
RuntimeException("Task write failed: $operation", cause)
|
||||
|
||||
/**
|
||||
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
|
||||
|
||||
@@ -113,6 +113,12 @@ object TaskWriteMapper {
|
||||
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
|
||||
)
|
||||
|
||||
fun categoryValues(taskId: Long, name: String): Map<String, Any?> = mapOf(
|
||||
Properties.TASK_ID to taskId,
|
||||
Properties.MIMETYPE to TasksContract.Category.MIMETYPE,
|
||||
TasksContract.Category.NAME to name,
|
||||
)
|
||||
|
||||
/** The user-owned columns of a list — what an edit is allowed to change. */
|
||||
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
||||
Lists.NAME to name.trim(),
|
||||
|
||||
@@ -154,6 +154,18 @@ object TasksContract {
|
||||
const val TYPE_MESSAGE = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* A category property row — one tag. DAVx5 maps these to and from
|
||||
* `CATEGORIES`. Inserting with only [NAME] is enough: the provider looks the
|
||||
* category up in the task's account, or creates it, and fills in `data0`.
|
||||
*/
|
||||
object Category {
|
||||
const val MIMETYPE = "vnd.android.cursor.item/category"
|
||||
|
||||
/** `data1` — the tag as spelled. */
|
||||
const val NAME = "data1"
|
||||
}
|
||||
|
||||
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
|
||||
|
||||
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
||||
|
||||
@@ -66,6 +66,7 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
|
||||
// several lists are read in full and membership is filtered in-memory.
|
||||
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
|
||||
is TaskFilter.Tagged -> TaskQuery(includeCompleted = true)
|
||||
}
|
||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||
val all = SeriesCollapse.visible(dataSource.tasks(query))
|
||||
@@ -112,6 +113,7 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
parentId = parentId,
|
||||
location = task.location,
|
||||
url = task.url,
|
||||
tags = task.tags,
|
||||
),
|
||||
)
|
||||
val reminders = dataSource.reminders()[task.taskId].orEmpty()
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
@@ -81,6 +82,8 @@ object VTodoMapper {
|
||||
// Parameterised copies stay in the residue verbatim; authoring the
|
||||
// column beside them would emit the same exclusions twice.
|
||||
"RDATE", "EXDATE",
|
||||
// Several lines, or one the column would not re-serialise exactly.
|
||||
"CATEGORIES",
|
||||
)
|
||||
|
||||
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
|
||||
@@ -200,6 +203,9 @@ object VTodoMapper {
|
||||
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
|
||||
)
|
||||
|
||||
val (categories, categoriesProperty) = readCategories(vtodo)
|
||||
claim(categoriesProperty)
|
||||
|
||||
val sequenceProperty = vtodo.property("SEQUENCE")
|
||||
val sequence = take(
|
||||
sequenceProperty,
|
||||
@@ -225,6 +231,7 @@ object VTodoMapper {
|
||||
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
|
||||
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
|
||||
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
|
||||
categories = categories,
|
||||
// URI, not TEXT — escaping it would corrupt a query string.
|
||||
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
|
||||
status = status,
|
||||
@@ -284,8 +291,9 @@ object VTodoMapper {
|
||||
now: Instant = Clock.System.now(),
|
||||
): ICalComponent {
|
||||
val residue = parseResidue(entity.unknownProperties)
|
||||
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
|
||||
val keptResidue =
|
||||
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) }
|
||||
residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) }
|
||||
val suppressed = keptResidue
|
||||
.map { it.name.uppercase() }
|
||||
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
|
||||
@@ -307,6 +315,7 @@ object VTodoMapper {
|
||||
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
|
||||
add("LOCATION", entity.location?.let(ICalValues::escapeText))
|
||||
add("URL", entity.url)
|
||||
add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList))
|
||||
|
||||
add("STATUS", entity.status.toICalName())
|
||||
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
|
||||
@@ -386,6 +395,7 @@ object VTodoMapper {
|
||||
private fun contradictsResidue(
|
||||
property: ICalProperty,
|
||||
residue: ICalComponent,
|
||||
residueCategories: List<String>,
|
||||
entity: TaskEntity,
|
||||
parentUid: String?,
|
||||
): Boolean {
|
||||
@@ -433,6 +443,7 @@ object VTodoMapper {
|
||||
"PRIORITY" -> entity.priority != PRIORITY_NONE
|
||||
"PERCENT-COMPLETE" -> entity.percentComplete != null
|
||||
"CLASS" -> entity.classification != null
|
||||
"CATEGORIES" -> entity.categories != residueCategories
|
||||
// Not SEQUENCE: it is the organiser's counter and never ours to bump,
|
||||
// so the column stays at its fallback and the residue always wins.
|
||||
else -> false
|
||||
@@ -475,6 +486,23 @@ object VTodoMapper {
|
||||
|
||||
// -------------------------------------------------------------- helpers
|
||||
|
||||
/**
|
||||
* Every `CATEGORIES` copy's values, merged, and the one line to claim — only
|
||||
* when it is the sole, parameterless copy and the column re-serialises it
|
||||
* byte for byte. Anything else is read for display and stays in the residue.
|
||||
*/
|
||||
private fun readCategories(vtodo: ICalComponent): Pair<List<String>, ICalProperty?> {
|
||||
val copies = vtodo.properties("CATEGORIES")
|
||||
val values = normalizeTags(copies.flatMap(::categoryValues))
|
||||
val claimable = copies.singleOrNull()?.takeIf {
|
||||
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
|
||||
}
|
||||
return values to claimable
|
||||
}
|
||||
|
||||
private fun categoryValues(property: ICalProperty): List<String> =
|
||||
normalizeTags(ICalValues.parseTextList(property.value))
|
||||
|
||||
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
|
||||
|
||||
private class TimeRead(
|
||||
|
||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -221,12 +222,17 @@ class OneShotImport @Inject constructor(
|
||||
}
|
||||
|
||||
val alarms = mutableListOf<LegacyAlarm>()
|
||||
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null)
|
||||
val categories = mutableMapOf<Long, MutableList<String>>()
|
||||
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties ORDER BY property_id", null)
|
||||
.use { cursor ->
|
||||
val r = CursorColumnReader(cursor)
|
||||
while (cursor.moveToNext()) {
|
||||
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
|
||||
val taskId = r.getLong("task_id") ?: continue
|
||||
if (r.getString("mimetype") == CATEGORY_MIMETYPE) {
|
||||
r.getString("data1")?.let { categories.getOrPut(taskId, ::mutableListOf) += it }
|
||||
continue
|
||||
}
|
||||
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
|
||||
val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
|
||||
alarms += LegacyAlarm(
|
||||
taskId = taskId,
|
||||
@@ -241,7 +247,7 @@ class OneShotImport @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
return LegacySnapshot(lists, rows, alarms)
|
||||
return LegacySnapshot(lists, rows, alarms, categories.mapValues { normalizeTags(it.value) })
|
||||
}
|
||||
|
||||
// --- writing into Room ----------------------------------------------------
|
||||
@@ -276,7 +282,12 @@ class OneShotImport @Inject constructor(
|
||||
// the unique index; a fresh uid costs the row nothing it still has.
|
||||
val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
|
||||
?: UUID.randomUUID().toString()
|
||||
val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId)
|
||||
val entity = row.entity.copy(
|
||||
listId = listId,
|
||||
uid = uid,
|
||||
recurrenceId = recurrenceId,
|
||||
categories = snapshot.categories[row.id].orEmpty(),
|
||||
)
|
||||
val newId = taskDao.insert(entity)
|
||||
taskIds[row.id] = newId
|
||||
inserted += row to entity.copy(id = newId)
|
||||
@@ -379,6 +390,7 @@ class OneShotImport @Inject constructor(
|
||||
const val ARCHIVE_NAME = "tasks.db.imported"
|
||||
|
||||
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
|
||||
private const val CATEGORY_MIMETYPE = "vnd.android.cursor.item/category"
|
||||
private const val REFERENCE_START = "2"
|
||||
private val SIDECARS = listOf("-journal", "-wal", "-shm")
|
||||
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
|
||||
@@ -391,6 +403,8 @@ private class LegacySnapshot(
|
||||
val lists: List<LegacyList>,
|
||||
val tasks: List<LegacyTaskRow>,
|
||||
val alarms: List<LegacyAlarm>,
|
||||
/** Tags by legacy task `_id`. */
|
||||
val categories: Map<Long, List<String>>,
|
||||
)
|
||||
|
||||
private class LegacyList(val id: Long, val entity: TaskListEntity)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
|
||||
/**
|
||||
* Lifts `CATEGORIES` out of a residue written before v2 claimed it.
|
||||
*
|
||||
* ⚠️ Frozen with [TasksDatabase.MIGRATION_1_2]. The claim rule is copied here
|
||||
* rather than shared with the mapper, so a later change to what the mapper
|
||||
* claims cannot change what this migration produced for an earlier upgrader.
|
||||
*/
|
||||
internal object CategoriesLift {
|
||||
|
||||
/**
|
||||
* Every copy's values merged, and the residue with the one claimed line
|
||||
* removed — or `null` for the residue when nothing was claimed, so the caller
|
||||
* leaves the stored one alone.
|
||||
*/
|
||||
data class Result(val categories: List<String>, val residue: String?, val claimed: Boolean)
|
||||
|
||||
fun lift(residue: String?): Result {
|
||||
val component = parse(residue) ?: return Result(emptyList(), residue, claimed = false)
|
||||
val copies = component.properties.filter { it.name.equals("CATEGORIES", ignoreCase = true) }
|
||||
val values = copies.flatMap(::values)
|
||||
val claim = copies.singleOrNull()?.takeIf {
|
||||
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
|
||||
} ?: return Result(values, residue, claimed = false)
|
||||
val rest = ICalSerializer.serializeProperties(component.properties.filterNot { it === claim }) +
|
||||
ICalSerializer.serializeAll(component.components)
|
||||
return Result(values, rest.ifEmpty { null }, claimed = true)
|
||||
}
|
||||
|
||||
private fun values(property: ICalProperty): List<String> =
|
||||
ICalValues.parseTextList(property.value).map(String::trim).filter(String::isNotEmpty).distinct()
|
||||
|
||||
private fun parse(text: String?): ICalComponent? {
|
||||
if (text.isNullOrEmpty()) return null
|
||||
return runCatching { ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n") }.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import de.jeanlucmakiola.agendula.domain.toInt
|
||||
import kotlin.time.Instant
|
||||
@@ -36,3 +38,17 @@ object Converters {
|
||||
fun alarmReferenceFrom(value: String): AlarmReference =
|
||||
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE)
|
||||
}
|
||||
|
||||
/**
|
||||
* [TaskEntity.categories] stored as a `CATEGORIES` value, so escaping has one
|
||||
* home; empty when there are none. Scoped to that field rather than the
|
||||
* database, so no other `List<String>` picks up iCalendar escaping by accident.
|
||||
*/
|
||||
object CategoriesConverter {
|
||||
@TypeConverter
|
||||
fun toText(value: List<String>): String = ICalValues.formatTextList(normalizeTags(value))
|
||||
|
||||
@TypeConverter
|
||||
fun fromText(value: String): List<String> =
|
||||
if (value.isEmpty()) emptyList() else normalizeTags(ICalValues.parseTextList(value))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import kotlin.time.Instant
|
||||
@@ -143,6 +144,10 @@ data class TaskEntity(
|
||||
@ColumnInfo(name = "url") val url: String? = null,
|
||||
/** ARGB override for the list colour. */
|
||||
@ColumnInfo(name = "color") val color: Int? = null,
|
||||
/** RFC 5545 `CATEGORIES` — the tags — in the spelling each task carries them. */
|
||||
@ColumnInfo(name = "categories", defaultValue = "")
|
||||
@field:TypeConverters(CategoriesConverter::class)
|
||||
val categories: List<String> = emptyList(),
|
||||
|
||||
// state
|
||||
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||
|
||||
@@ -49,6 +49,7 @@ object RoomTaskMapper {
|
||||
description = row.task.description,
|
||||
location = row.task.location,
|
||||
url = row.task.url,
|
||||
tags = row.task.categories,
|
||||
priority = priorityFromICal(row.task.priority),
|
||||
status = row.task.status,
|
||||
percentComplete = row.task.percentComplete,
|
||||
@@ -79,6 +80,7 @@ object RoomTaskMapper {
|
||||
description = task.description,
|
||||
location = task.location,
|
||||
url = task.url,
|
||||
tags = task.categories,
|
||||
priority = priorityFromICal(task.priority),
|
||||
status = task.status,
|
||||
percentComplete = task.percentComplete,
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -50,6 +51,7 @@ object TaskFormWriter {
|
||||
description = form.description?.trim()?.ifBlank { null },
|
||||
location = form.location?.trim()?.ifBlank { null },
|
||||
url = form.url?.trim()?.ifBlank { null },
|
||||
categories = normalizeTags(form.tags),
|
||||
priority = form.priority.toICal(),
|
||||
percentComplete = percent,
|
||||
status = statusFor(percent, current.status),
|
||||
|
||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
/**
|
||||
* Agendula's own task store. Four tables, designed from
|
||||
@@ -18,7 +20,7 @@ import androidx.room.TypeConverters
|
||||
TaskEntity::class,
|
||||
TaskAlarmEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -30,5 +32,36 @@ abstract class TasksDatabase : RoomDatabase() {
|
||||
|
||||
companion object {
|
||||
const val NAME = "agendula-tasks.db"
|
||||
|
||||
/**
|
||||
* v2 adds `categories`. Until then `CATEGORIES` lived in the residue, and
|
||||
* an unchanged task is never downloaded again, so the values are lifted
|
||||
* out here. Not marked dirty: nothing changed on the server's side.
|
||||
*/
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE tasks ADD COLUMN categories TEXT NOT NULL DEFAULT ''")
|
||||
val lifted = mutableListOf<Pair<Long, CategoriesLift.Result>>()
|
||||
db.query(
|
||||
"SELECT id, unknown_properties FROM tasks WHERE unknown_properties LIKE '%CATEGORIES%'",
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val result = CategoriesLift.lift(cursor.getString(1))
|
||||
if (result.categories.isNotEmpty()) lifted += cursor.getLong(0) to result
|
||||
}
|
||||
}
|
||||
lifted.forEach { (id, result) ->
|
||||
val categories = CategoriesConverter.toText(result.categories)
|
||||
if (result.claimed) {
|
||||
db.execSQL(
|
||||
"UPDATE tasks SET categories = ?, unknown_properties = ? WHERE id = ?",
|
||||
arrayOf<Any?>(categories, result.residue, id),
|
||||
)
|
||||
} else {
|
||||
db.execSQL("UPDATE tasks SET categories = ? WHERE id = ?", arrayOf<Any?>(categories, id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -213,6 +214,7 @@ class ExternalImport @Inject constructor(
|
||||
description = description,
|
||||
location = location,
|
||||
url = url,
|
||||
categories = normalizeTags(tags),
|
||||
status = status,
|
||||
percentComplete = percentComplete,
|
||||
completedAt = completedAt,
|
||||
|
||||
@@ -52,6 +52,8 @@ data class Task(
|
||||
val description: String?,
|
||||
val location: String?,
|
||||
val url: String?,
|
||||
/** `CATEGORIES`, as the task spells them. */
|
||||
val tags: List<String> = emptyList(),
|
||||
val priority: Priority,
|
||||
val status: TaskStatus,
|
||||
val percentComplete: Int?,
|
||||
@@ -161,3 +163,10 @@ fun TaskStatus.toInt(): Int = when (this) {
|
||||
TaskStatus.COMPLETED -> ICalStatus.COMPLETED
|
||||
TaskStatus.CANCELLED -> ICalStatus.CANCELLED
|
||||
}
|
||||
|
||||
/** Tags as every writer stores them: trimmed, no blanks, each spelling once, order kept. */
|
||||
fun normalizeTags(tags: List<String>): List<String> = tags.map(String::trim).filter(String::isNotEmpty).distinct()
|
||||
|
||||
/** Every tag on [tasks], each spelling once, alphabetically ignoring case. */
|
||||
fun tagsInUse(tasks: Iterable<Task>): List<String> =
|
||||
tasks.flatMap { it.tags }.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER)
|
||||
|
||||
@@ -5,12 +5,14 @@ import kotlin.time.Instant
|
||||
/** The built-in "smart" lists, computed from due dates rather than membership. */
|
||||
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
|
||||
|
||||
/** What a task screen is showing: one real list, or a smart list. */
|
||||
/** What a task screen is showing: one real list, a smart list, or a tag. */
|
||||
sealed interface TaskFilter {
|
||||
data class OfList(val listId: Long) : TaskFilter
|
||||
/** Several lists shown together, e.g. one widget covering more than one list. */
|
||||
data class OfLists(val listIds: Set<Long>) : TaskFilter
|
||||
data class Smart(val list: SmartList) : TaskFilter
|
||||
/** Every task carrying [tag], open and completed, across all lists. */
|
||||
data class Tagged(val tag: String) : TaskFilter
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,8 +27,12 @@ object TaskFiltering {
|
||||
is TaskFilter.OfList -> task.listId == filter.listId
|
||||
is TaskFilter.OfLists -> task.listId in filter.listIds
|
||||
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
|
||||
is TaskFilter.Tagged -> filter.tag in task.tags
|
||||
}
|
||||
|
||||
/** Whether [task] carries every one of [tags]; an empty set matches everything. */
|
||||
fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags)
|
||||
|
||||
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean {
|
||||
val due = task.due
|
||||
return when (list) {
|
||||
|
||||
@@ -22,6 +22,7 @@ data class TaskForm(
|
||||
val reminders: List<Int> = emptyList(),
|
||||
val location: String? = null,
|
||||
val url: String? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
/** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */
|
||||
val rrule: String? = null,
|
||||
) {
|
||||
@@ -44,7 +45,7 @@ enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITH
|
||||
* a setting; the rest unfold on demand. Declaring these as an enum keeps the
|
||||
* disclosure generic, so adding a field later is one entry plus its card.
|
||||
*/
|
||||
enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url }
|
||||
enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url, Tags }
|
||||
|
||||
/** The optional fields that already carry a value — auto-revealed when editing. */
|
||||
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
|
||||
@@ -56,4 +57,5 @@ fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
|
||||
if (rrule != null) add(TaskFormField.Recurrence)
|
||||
if (!location.isNullOrBlank()) add(TaskFormField.Location)
|
||||
if (!url.isNullOrBlank()) add(TaskFormField.Url)
|
||||
if (tags.isNotEmpty()) add(TaskFormField.Tags)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ data class ExportTask(
|
||||
val rdate: String?,
|
||||
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
|
||||
val parentId: Long?,
|
||||
val tags: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** A task list and everything in it, ready to become one `.ics` document. */
|
||||
|
||||
@@ -88,6 +88,7 @@ object ICalendarWriter {
|
||||
task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) }
|
||||
// URL is a URI, not TEXT: it must not be escaped like one.
|
||||
task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) }
|
||||
task.tags.takeIf { it.isNotEmpty() }?.let { rawProperty("CATEGORIES", ICalValues.formatTextList(it)) }
|
||||
|
||||
task.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
|
||||
task.due?.let { dateProperty("DUE", it, task.isAllDay) }
|
||||
|
||||
@@ -49,6 +49,37 @@ object ICalValues {
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* A multi-valued TEXT property (`CATEGORIES`, §3.8.1.2): split on the
|
||||
* commas that are not escaped, then unescape each value.
|
||||
*/
|
||||
fun parseTextList(value: String): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var i = 0
|
||||
while (i < value.length) {
|
||||
val c = value[i]
|
||||
when {
|
||||
c == '\\' && i + 1 < value.length -> {
|
||||
current.append(c).append(value[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
c == ',' -> {
|
||||
values += unescapeText(current.toString())
|
||||
current.clear()
|
||||
}
|
||||
else -> current.append(c)
|
||||
}
|
||||
i++
|
||||
}
|
||||
values += unescapeText(current.toString())
|
||||
return values
|
||||
}
|
||||
|
||||
/** The inverse of [parseTextList]. */
|
||||
fun formatTextList(values: List<String>): String = values.joinToString(",", transform = ::escapeText)
|
||||
|
||||
/** How a DATE / DATE-TIME property was written, and whether we can reproduce it. */
|
||||
sealed interface TimeValue {
|
||||
/** `VALUE=DATE` — date-only. */
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Notifications
|
||||
import androidx.compose.material.icons.rounded.Percent
|
||||
import androidx.compose.material.icons.rounded.Repeat
|
||||
import androidx.compose.material.icons.rounded.Sell
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
@@ -23,6 +24,7 @@ fun TaskFormField.labelRes(): Int = when (this) {
|
||||
TaskFormField.Reminder -> R.string.edit_reminder_label
|
||||
TaskFormField.Location -> R.string.edit_location_label
|
||||
TaskFormField.Url -> R.string.edit_url_label
|
||||
TaskFormField.Tags -> R.string.edit_tags_label
|
||||
}
|
||||
|
||||
fun TaskFormField.icon(): ImageVector = when (this) {
|
||||
@@ -34,4 +36,5 @@ fun TaskFormField.icon(): ImageVector = when (this) {
|
||||
TaskFormField.Reminder -> Icons.Rounded.Notifications
|
||||
TaskFormField.Location -> Icons.Rounded.LocationOn
|
||||
TaskFormField.Url -> Icons.Rounded.Link
|
||||
TaskFormField.Tags -> Icons.Rounded.Sell
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A pill naming one tag. Solid secondary by default so it reads against any card;
|
||||
* [muted] is the quieter tonal look for something not yet applied, like a suggestion.
|
||||
*/
|
||||
@Composable
|
||||
fun TagChip(
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
muted: Boolean = false,
|
||||
icon: ImageVector? = null,
|
||||
iconContentDescription: String? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val container = if (muted) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.secondary
|
||||
val content = if (muted) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSecondary
|
||||
val shape = RoundedCornerShape(50)
|
||||
val body: @Composable () -> Unit = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(start = 12.dp, end = if (icon != null) 8.dp else 12.dp, top = 6.dp, bottom = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (icon != null) {
|
||||
Icon(icon, contentDescription = iconContentDescription, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onClick != null) {
|
||||
Surface(onClick = onClick, color = container, contentColor = content, shape = shape, modifier = modifier) { body() }
|
||||
} else {
|
||||
Surface(color = container, contentColor = content, shape = shape, modifier = modifier) { body() }
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
@@ -42,6 +44,7 @@ import android.net.Uri
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Link
|
||||
import androidx.compose.material.icons.rounded.Sell
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Notifications
|
||||
import androidx.compose.material.icons.rounded.Repeat
|
||||
@@ -60,6 +63,7 @@ import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
|
||||
import de.jeanlucmakiola.agendula.ui.common.TagChip
|
||||
import de.jeanlucmakiola.agendula.ui.common.linkified
|
||||
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||
@@ -97,6 +101,7 @@ import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.readableDescription
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
import de.jeanlucmakiola.floret.components.revealOnFocus
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
|
||||
import de.jeanlucmakiola.floret.time.formatDate
|
||||
@@ -240,6 +245,7 @@ fun TaskDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun DetailBody(
|
||||
detail: TaskDetail,
|
||||
@@ -453,6 +459,19 @@ private fun DetailBody(
|
||||
}
|
||||
}
|
||||
|
||||
if (task.tags.isNotEmpty()) {
|
||||
add {
|
||||
DetailCard(icon = Icons.Rounded.Sell, iconContentDescription = stringResource(R.string.edit_tags_label)) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
task.tags.forEach { TagChip(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Description — selectable, with its links tappable.
|
||||
task.description?.readableDescription()?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
add {
|
||||
@@ -606,7 +625,7 @@ private fun AddSubtaskField(onAdd: (String) -> Unit) {
|
||||
}
|
||||
val accentOn = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).revealOnFocus().padding(start = 8.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
package de.jeanlucmakiola.agendula.ui.edit
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import de.jeanlucmakiola.agendula.ui.common.TagChip
|
||||
import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
|
||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.material.icons.rounded.Link
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.material.icons.rounded.Sell
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Repeat
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -15,6 +31,7 @@ import de.jeanlucmakiola.agendula.ui.common.icon
|
||||
import de.jeanlucmakiola.agendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.floret.components.revealOnFocus
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
@@ -153,11 +170,25 @@ fun TaskEditScreen(
|
||||
val requestBack = { if (state.isDirty && !state.saved) showDiscard = true else onBack() }
|
||||
BackHandler(enabled = state.isDirty && !state.saved) { showDiscard = true }
|
||||
|
||||
// Once the title field scrolls away it rides in the bar, so a long form never
|
||||
// loses track of which task is being edited.
|
||||
val scroll = rememberScrollState()
|
||||
var titleBottom by remember { mutableIntStateOf(Int.MAX_VALUE) }
|
||||
val titleInBar by remember { derivedStateOf { scroll.value > titleBottom } }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
title = {
|
||||
AnimatedVisibility(
|
||||
visible = titleInBar && state.title.isNotBlank(),
|
||||
enter = fadeIn() + slideInVertically { it / 2 },
|
||||
exit = fadeOut() + slideOutVertically { it / 2 },
|
||||
) {
|
||||
Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = requestBack) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close))
|
||||
@@ -180,6 +211,8 @@ fun TaskEditScreen(
|
||||
EditContent(
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
scroll = scroll,
|
||||
onTitleBottom = { titleBottom = it },
|
||||
modifier = Modifier.padding(inner),
|
||||
)
|
||||
}
|
||||
@@ -220,6 +253,8 @@ private enum class PickerTarget { Start, Due }
|
||||
private fun EditContent(
|
||||
state: TaskEditUiState,
|
||||
viewModel: TaskEditViewModel,
|
||||
scroll: ScrollState,
|
||||
onTitleBottom: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
@@ -250,7 +285,7 @@ private fun EditContent(
|
||||
// Shrink the scroll viewport by the keyboard so the focused field
|
||||
// scrolls into view above the IME instead of being hidden.
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.verticalScroll(scroll)
|
||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||
) {
|
||||
// Title: borderless headline + accent bar, mirroring the detail screen.
|
||||
@@ -259,7 +294,9 @@ private fun EditContent(
|
||||
onValueChange = viewModel::onTitleChange,
|
||||
placeholder = stringResource(R.string.edit_title_hint),
|
||||
textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.focusRequester(titleFocus),
|
||||
modifier = Modifier
|
||||
.focusRequester(titleFocus)
|
||||
.onPlaced { onTitleBottom(it.positionInParent().y.toInt() + it.size.height) },
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
@@ -572,6 +609,19 @@ private fun EditContent(
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = TaskFormField.Tags in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
TagsCard(
|
||||
tags = state.tags,
|
||||
draft = state.tagDraft,
|
||||
suggestions = state.tagSuggestions,
|
||||
onDraftChange = viewModel::onTagDraftChange,
|
||||
onCommitDraft = viewModel::commitTagDraft,
|
||||
onAdd = viewModel::addTag,
|
||||
onRemove = viewModel::removeTag,
|
||||
)
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
TextButton(
|
||||
@@ -727,9 +777,9 @@ private fun EditCard(
|
||||
}
|
||||
}
|
||||
if (onClick != null) {
|
||||
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() }
|
||||
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth().revealOnFocus()) { inner() }
|
||||
} else {
|
||||
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() }
|
||||
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth().revealOnFocus()) { inner() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1090,77 @@ private fun AddReminderCard(position: Position, label: String, onClick: () -> Un
|
||||
}
|
||||
}
|
||||
|
||||
/** The input sits beside the icon; the tags and any suggestions run below it. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun TagsCard(
|
||||
tags: List<String>,
|
||||
draft: String,
|
||||
suggestions: List<String>,
|
||||
onDraftChange: (String) -> Unit,
|
||||
onCommitDraft: () -> Unit,
|
||||
onAdd: (String) -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
) {
|
||||
val query = draft.trim()
|
||||
val matches = if (query.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
suggestions.filter { it.contains(query, ignoreCase = true) && it !in tags }.take(MAX_TAG_SUGGESTIONS)
|
||||
}
|
||||
EditCard(
|
||||
icon = Icons.Rounded.Sell,
|
||||
iconContentDescription = stringResource(R.string.edit_tags_label),
|
||||
header = {
|
||||
InlineTextField(
|
||||
value = draft,
|
||||
onValueChange = onDraftChange,
|
||||
placeholder = stringResource(R.string.edit_tags_hint),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = onCommitDraft,
|
||||
)
|
||||
},
|
||||
) {
|
||||
if (matches.isNotEmpty()) {
|
||||
TagFlow(modifier = Modifier.padding(top = 8.dp)) {
|
||||
matches.forEach { match ->
|
||||
TagChip(
|
||||
name = match,
|
||||
muted = true,
|
||||
icon = Icons.Rounded.Add,
|
||||
onClick = { onAdd(match) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tags.isNotEmpty()) {
|
||||
TagFlow(modifier = Modifier.padding(top = 8.dp)) {
|
||||
tags.forEach { tag ->
|
||||
TagChip(
|
||||
name = tag,
|
||||
icon = Icons.Rounded.Close,
|
||||
iconContentDescription = stringResource(R.string.edit_tag_remove, tag),
|
||||
onClick = { onRemove(tag) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun TagFlow(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
private const val MAX_TAG_SUGGESTIONS = 8
|
||||
|
||||
/** Picks one hidden optional section to add to the form. */
|
||||
@Composable
|
||||
private fun FieldPickerDialog(
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
@@ -17,6 +18,8 @@ import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.agendula.domain.htmlToPlainText
|
||||
import de.jeanlucmakiola.agendula.domain.looksLikeHtml
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
||||
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
|
||||
import de.jeanlucmakiola.agendula.domain.readableDescription
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||
@@ -51,6 +54,11 @@ data class TaskEditUiState(
|
||||
val reminders: List<Int> = emptyList(),
|
||||
val location: String = "",
|
||||
val url: String = "",
|
||||
val tags: List<String> = emptyList(),
|
||||
/** What is typed in the tag field and not added yet. Saving still takes it. */
|
||||
val tagDraft: String = "",
|
||||
/** Every tag already in use, for completing what the user types. */
|
||||
val tagSuggestions: List<String> = emptyList(),
|
||||
val rrule: String? = null,
|
||||
/** Editing one occurrence of a series: saving asks how far the edit reaches. */
|
||||
val isOccurrence: Boolean = false,
|
||||
@@ -114,7 +122,12 @@ class TaskEditViewModel @Inject constructor(
|
||||
private var defaultFields: Set<TaskFormField> = emptySet()
|
||||
|
||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||
fun bindNew(presetListId: Long? = null, parentId: Long? = null, initialTitle: String? = null) {
|
||||
fun bindNew(
|
||||
presetListId: Long? = null,
|
||||
parentId: Long? = null,
|
||||
initialTitle: String? = null,
|
||||
presetTag: String? = null,
|
||||
) {
|
||||
if (bound) return
|
||||
bound = true
|
||||
editingTaskId = null
|
||||
@@ -142,6 +155,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
lists = lists,
|
||||
parentCandidates = loadParents(defaultList, selfId = null),
|
||||
title = initialTitle.orEmpty(),
|
||||
tags = listOfNotNull(presetTag),
|
||||
),
|
||||
).also { baseline = it.toForm().copy(title = "") }
|
||||
}
|
||||
@@ -179,6 +193,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore },
|
||||
location = task.location.orEmpty(),
|
||||
url = task.url.orEmpty(),
|
||||
tags = task.tags,
|
||||
rrule = task.recurrenceRule,
|
||||
originalRule = task.recurrenceRule,
|
||||
isOccurrence = task.isOccurrence,
|
||||
@@ -202,6 +217,18 @@ class TaskEditViewModel @Inject constructor(
|
||||
return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId }
|
||||
}
|
||||
|
||||
private var suggestionsRequested = false
|
||||
|
||||
/** Tags on open tasks, read once the user starts typing one rather than on every bind. */
|
||||
private fun loadTagSuggestions() {
|
||||
if (suggestionsRequested) return
|
||||
suggestionsRequested = true
|
||||
viewModelScope.launch {
|
||||
val tasks = runCatching { repository.tasks(TaskFilter.Smart(SmartList.ALL)).first() }.getOrElse { emptyList() }
|
||||
_state.value = _state.value.copy(tagSuggestions = tagsInUse(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
/** Reveal an optional section (one tap from the "More fields" picker). */
|
||||
fun revealField(field: TaskFormField) = update {
|
||||
val visible = it.visibleFields + field
|
||||
@@ -257,6 +284,18 @@ class TaskEditViewModel @Inject constructor(
|
||||
fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) }
|
||||
fun onLocationChange(value: String) = update { it.copy(location = value) }
|
||||
fun onUrlChange(value: String) = update { it.copy(url = value) }
|
||||
fun addTag(name: String) = update { it.copy(tags = normalizeTags(it.tags + name), tagDraft = "") }
|
||||
|
||||
/** A typed comma adds what came before it. */
|
||||
fun onTagDraftChange(value: String) {
|
||||
loadTagSuggestions()
|
||||
update {
|
||||
if (',' in value) it.copy(tags = it.withDraft(value), tagDraft = "") else it.copy(tagDraft = value)
|
||||
}
|
||||
}
|
||||
|
||||
fun commitTagDraft() = update { it.copy(tags = it.withDraft(it.tagDraft), tagDraft = "") }
|
||||
fun removeTag(name: String) = update { it.copy(tags = it.tags - name) }
|
||||
fun onRecurrenceChange(rrule: String?) = update { it.copy(rrule = rrule, errors = emptySet()) }
|
||||
|
||||
/** Dismiss the conflict prompt without saving (the user keeps editing). */
|
||||
@@ -339,5 +378,8 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
|
||||
reminders = reminders,
|
||||
location = location.ifBlank { null },
|
||||
url = url.ifBlank { null },
|
||||
tags = withDraft(tagDraft),
|
||||
rrule = rrule,
|
||||
)
|
||||
|
||||
private fun TaskEditUiState.withDraft(draft: String): List<String> = normalizeTags(tags + draft.split(','))
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.ui.lists
|
||||
import de.jeanlucmakiola.floret.components.ReorderableColumn
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.DragHandle
|
||||
import androidx.compose.material.icons.rounded.Sell
|
||||
import androidx.compose.material.icons.rounded.SwapVert
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -370,6 +371,31 @@ private fun ListsContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.tags.isNotEmpty()) {
|
||||
item(key = "tags-header") { SectionHeader(stringResource(R.string.tags_header)) }
|
||||
itemsIndexed(state.tags, key = { _, t -> "tag-${t.tag}" }) { index, overview ->
|
||||
GroupedRow(
|
||||
title = overview.tag,
|
||||
position = positionOf(index, state.tags.size),
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Rounded.Sell,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Text(
|
||||
overview.openCount.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = { onOpenFilter(TaskFilter.Tagged(overview.tag)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -73,6 +74,7 @@ internal fun RemoteLists.Outcome.asFailure(local: ListWriteFailure): ListWriteFa
|
||||
data class ListOverview(val list: TaskList, val openCount: Int)
|
||||
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
||||
data class SmartCount(val smart: SmartList, val count: Int)
|
||||
data class TagOverview(val tag: String, val openCount: Int)
|
||||
|
||||
sealed interface ListsUiState {
|
||||
data object Loading : ListsUiState
|
||||
@@ -87,6 +89,8 @@ sealed interface ListsUiState {
|
||||
val upcoming: List<Task>,
|
||||
/** Every task (open and completed) — the corpus the home search filters. */
|
||||
val allTasks: List<Task>,
|
||||
/** Each tag on an open task, alphabetically. */
|
||||
val tags: List<TagOverview> = emptyList(),
|
||||
) : ListsUiState
|
||||
}
|
||||
|
||||
@@ -177,13 +181,22 @@ class ListsViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
.sortedBy { it.accountName.lowercase() }
|
||||
// Counts the open rows the tag's own screen shows: a subtask folds under a
|
||||
// parent carrying the same tag, and stands on its own otherwise.
|
||||
val allTasks = openTasks + completedTasks
|
||||
val tags = tagsInUse(openTasks).map { tag ->
|
||||
val tagged = allTasks.filter { tag in it.tags }
|
||||
val ids = tagged.mapTo(HashSet()) { it.taskId }
|
||||
TagOverview(tag, tagged.count { !it.isClosed && (!it.isSubtask || it.parentId !in ids) })
|
||||
}
|
||||
return ListsUiState.Content(
|
||||
smartCounts = smartCounts,
|
||||
groups = groups,
|
||||
todayDone = completedDueToday,
|
||||
todayTotal = todayTotal,
|
||||
upcoming = upcoming,
|
||||
allTasks = openTasks + completedTasks,
|
||||
allTasks = allTasks,
|
||||
tags = tags,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ fun AgendulaNavHost(
|
||||
val filter = Dest.TaskList.filterOf(
|
||||
smart = args?.getString(Dest.TaskList.ARG_SMART),
|
||||
listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID,
|
||||
tag = args?.getString(Dest.TaskList.ARG_TAG),
|
||||
)
|
||||
val vm: TaskListViewModel = hiltViewModel()
|
||||
LaunchedEffect(filter) { vm.bind(filter) }
|
||||
@@ -125,7 +126,8 @@ fun AgendulaNavHost(
|
||||
onOpenTask = onOpenTask,
|
||||
onNewTask = {
|
||||
val presetListId = (filter as? TaskFilter.OfList)?.listId
|
||||
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId))
|
||||
val tag = (filter as? TaskFilter.Tagged)?.tag
|
||||
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId, tag = tag))
|
||||
},
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
@@ -166,6 +168,7 @@ fun AgendulaNavHost(
|
||||
val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID
|
||||
val occurrence = args?.getLong(Dest.TaskEdit.ARG_OCCURRENCE)?.takeIf { it != Dest.NO_ID }
|
||||
val title = args?.getString(Dest.TaskEdit.ARG_TITLE)
|
||||
val tag = args?.getString(Dest.TaskEdit.ARG_TAG)
|
||||
val vm: TaskEditViewModel = hiltViewModel()
|
||||
LaunchedEffect(taskId, presetListId, parentId) {
|
||||
if (taskId != Dest.NO_ID) {
|
||||
@@ -175,6 +178,7 @@ fun AgendulaNavHost(
|
||||
presetListId = presetListId.takeIf { it != Dest.NO_ID },
|
||||
parentId = parentId.takeIf { it != Dest.NO_ID },
|
||||
initialTitle = title,
|
||||
presetTag = tag,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,30 +53,35 @@ object Dest {
|
||||
}
|
||||
|
||||
/**
|
||||
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
|
||||
* `smart` (a [SmartList] name); whichever is set decides the filter.
|
||||
* One [TaskFilter]'s tasks. Carries one of `listId` (a real list), `smart`
|
||||
* (a [SmartList] name) or `tag`; whichever is set decides the filter.
|
||||
*/
|
||||
object TaskList {
|
||||
const val ARG_LIST_ID = "listId"
|
||||
const val ARG_SMART = "smart"
|
||||
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}"
|
||||
const val ARG_TAG = "tag"
|
||||
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}&$ARG_TAG={$ARG_TAG}"
|
||||
|
||||
val arguments = listOf(
|
||||
navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
)
|
||||
|
||||
fun build(filter: TaskFilter): String = when (filter) {
|
||||
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
|
||||
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
|
||||
is TaskFilter.Tagged -> "taskList?$ARG_TAG=${android.net.Uri.encode(filter.tag)}"
|
||||
// Several lists at once only exists for the widget, which never routes here.
|
||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||
}
|
||||
|
||||
/** Reconstruct the filter from a destination's arguments. */
|
||||
fun filterOf(smart: String?, listId: Long): TaskFilter =
|
||||
if (smart != null) TaskFilter.Smart(SmartList.valueOf(smart))
|
||||
else TaskFilter.OfList(listId)
|
||||
fun filterOf(smart: String?, listId: Long, tag: String? = null): TaskFilter = when {
|
||||
smart != null -> TaskFilter.Smart(SmartList.valueOf(smart))
|
||||
tag != null -> TaskFilter.Tagged(tag)
|
||||
else -> TaskFilter.OfList(listId)
|
||||
}
|
||||
}
|
||||
|
||||
/** A single task's detail; `occ` (epoch millis) picks one occurrence of a series. */
|
||||
@@ -105,12 +110,14 @@ object Dest {
|
||||
const val ARG_PARENT_ID = "parentId"
|
||||
const val ARG_OCCURRENCE = "occ"
|
||||
const val ARG_TITLE = "title"
|
||||
const val ARG_TAG = "tag"
|
||||
const val route =
|
||||
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
|
||||
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
|
||||
"&$ARG_PARENT_ID={$ARG_PARENT_ID}" +
|
||||
"&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" +
|
||||
"&$ARG_TITLE={$ARG_TITLE}"
|
||||
"&$ARG_TITLE={$ARG_TITLE}" +
|
||||
"&$ARG_TAG={$ARG_TAG}"
|
||||
|
||||
val arguments = listOf(
|
||||
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
@@ -118,17 +125,24 @@ object Dest {
|
||||
navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
navArgument(ARG_OCCURRENCE) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
)
|
||||
|
||||
fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
|
||||
if (occurrenceStart == null) "taskEdit?$ARG_TASK_ID=$taskId"
|
||||
else "taskEdit?$ARG_TASK_ID=$taskId&$ARG_OCCURRENCE=$occurrenceStart"
|
||||
|
||||
fun buildNew(presetListId: Long? = null, parentId: Long? = null, title: String? = null): String {
|
||||
fun buildNew(
|
||||
presetListId: Long? = null,
|
||||
parentId: Long? = null,
|
||||
title: String? = null,
|
||||
tag: String? = null,
|
||||
): String {
|
||||
val params = buildList {
|
||||
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
|
||||
if (parentId != null) add("$ARG_PARENT_ID=$parentId")
|
||||
if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}")
|
||||
if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}")
|
||||
}
|
||||
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -48,6 +49,8 @@ import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.Flag
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -57,14 +60,19 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material.icons.automirrored.rounded.Sort
|
||||
import androidx.compose.material.icons.rounded.Repeat
|
||||
import androidx.compose.material.icons.rounded.Sell
|
||||
import androidx.compose.material.icons.rounded.Tune
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||
import de.jeanlucmakiola.floret.components.revealOnFocus
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -189,7 +197,9 @@ fun TaskListScreen(
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showSort = true }, enabled = content != null) {
|
||||
Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title))
|
||||
BadgedBox(badge = { if (content?.selectedTags?.isNotEmpty() == true) Badge() }) {
|
||||
Icon(Icons.Rounded.Tune, contentDescription = stringResource(R.string.view_options_title))
|
||||
}
|
||||
}
|
||||
if (list != null) {
|
||||
IconButton(
|
||||
@@ -277,12 +287,13 @@ fun TaskListScreen(
|
||||
}
|
||||
|
||||
if (showSort && content != null) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.sort_title),
|
||||
options = TaskSortOrder.entries,
|
||||
selected = content.sortOrder,
|
||||
label = { stringResource(sortLabel(it)) },
|
||||
onSelect = { viewModel.setSortOrder(it); showSort = false },
|
||||
ViewOptionsPicker(
|
||||
sortOrder = content.sortOrder,
|
||||
tags = content.availableTags,
|
||||
selectedTags = content.selectedTags,
|
||||
onSortOrder = viewModel::setSortOrder,
|
||||
onToggleTag = viewModel::toggleTagFilter,
|
||||
onClearTags = viewModel::clearTagFilter,
|
||||
onDismiss = { showSort = false },
|
||||
)
|
||||
}
|
||||
@@ -324,7 +335,7 @@ fun TaskListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun TaskListBody(
|
||||
state: TaskListUiState.Content,
|
||||
@@ -349,7 +360,7 @@ private fun TaskListBody(
|
||||
val listId = (filter as? TaskFilter.OfList)?.listId
|
||||
// In a mixed view (a smart list) the list colour bar + name disambiguate
|
||||
// rows; inside a single real list both are redundant.
|
||||
val showListName = filter is TaskFilter.Smart
|
||||
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged
|
||||
// Completed tasks collapse away by default to keep the active list in focus.
|
||||
var completedExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
// Parents the user expanded to reveal their subtasks inline. Children are
|
||||
@@ -379,8 +390,16 @@ private fun TaskListBody(
|
||||
// When the bottom quick-add bar is shown it already lives in the scaffold's
|
||||
// bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB.
|
||||
val bottomBarShown = state.bottomAddBar && listId != null
|
||||
// While typing, the viewport ends at the keyboard (or at the quick-add bar
|
||||
// riding on it), so a focused add-subtask row can scroll fully above it.
|
||||
val imeVisible = WindowInsets.isImeVisible
|
||||
val viewport = when {
|
||||
!imeVisible -> Modifier
|
||||
bottomBarShown -> Modifier.padding(bottom = inner.calculateBottomPadding())
|
||||
else -> Modifier.imePadding()
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize().then(viewport),
|
||||
contentPadding = PaddingValues(
|
||||
top = inner.calculateTopPadding() + 8.dp,
|
||||
bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp,
|
||||
@@ -659,7 +678,7 @@ internal fun TaskRowContent(
|
||||
val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
|
||||
// Everything secondary collapses into one quiet supporting line below the title.
|
||||
val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 ||
|
||||
listName != null || task.isOccurrence
|
||||
listName != null || task.isOccurrence || task.tags.isNotEmpty()
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
@@ -714,6 +733,7 @@ internal fun TaskRowContent(
|
||||
subtaskTotal = subtaskTotal,
|
||||
listName = listName,
|
||||
listColor = task.effectiveColor,
|
||||
tags = task.tags,
|
||||
dark = dark,
|
||||
)
|
||||
}
|
||||
@@ -745,6 +765,7 @@ private fun TaskMetaLine(
|
||||
subtaskTotal: Int,
|
||||
listName: String?,
|
||||
listColor: Int,
|
||||
tags: List<String>,
|
||||
dark: Boolean,
|
||||
) {
|
||||
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -800,6 +821,28 @@ private fun TaskMetaLine(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tags.isNotEmpty()) {
|
||||
add {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Sell,
|
||||
contentDescription = stringResource(R.string.edit_tags_label),
|
||||
tint = muted,
|
||||
modifier = Modifier.size(13.dp),
|
||||
)
|
||||
Text(
|
||||
tags.joinToString(", "),
|
||||
style = style,
|
||||
color = muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (listName != null) {
|
||||
add {
|
||||
Text(
|
||||
@@ -852,7 +895,7 @@ private fun AddSubtaskRow(
|
||||
shape = cardShape(position, 22.dp, 6.dp),
|
||||
// Same tone as the subtask rows it closes, so the group reads as one unit.
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap).revealOnFocus(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
|
||||
@@ -1169,11 +1212,64 @@ private fun CenteredMessage(text: String, inner: PaddingValues) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort and tag filter in one place. Stays open while choosing, since several
|
||||
* tags can be ticked; back leaves. A task must carry every ticked tag.
|
||||
*/
|
||||
@Composable
|
||||
private fun ViewOptionsPicker(
|
||||
sortOrder: TaskSortOrder,
|
||||
tags: List<String>,
|
||||
selectedTags: Set<String>,
|
||||
onSortOrder: (TaskSortOrder) -> Unit,
|
||||
onToggleTag: (String) -> Unit,
|
||||
onClearTags: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.view_options_title),
|
||||
onDismiss = onDismiss,
|
||||
actions = {
|
||||
if (selectedTags.isNotEmpty()) {
|
||||
TextButton(onClick = onClearTags) { Text(stringResource(R.string.view_filter_clear)) }
|
||||
}
|
||||
},
|
||||
) {
|
||||
GroupedSectionHeader(stringResource(R.string.sort_title))
|
||||
TaskSortOrder.entries.forEachIndexed { index, order ->
|
||||
val isSelected = order == sortOrder
|
||||
GroupedRow(
|
||||
title = stringResource(sortLabel(order)),
|
||||
position = positionOf(index, TaskSortOrder.entries.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = { onSortOrder(order) },
|
||||
)
|
||||
}
|
||||
if (tags.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GroupedSectionHeader(stringResource(R.string.view_filter_tags))
|
||||
tags.forEachIndexed { index, tag ->
|
||||
val isSelected = tag in selectedTags
|
||||
GroupedRow(
|
||||
title = tag,
|
||||
position = positionOf(index, tags.size),
|
||||
selected = isSelected,
|
||||
leading = { Icon(Icons.Rounded.Sell, contentDescription = null) },
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = { onToggleTag(tag) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
|
||||
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
|
||||
// Several lists at once only exists for the widget, which never routes here.
|
||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||
is TaskFilter.Tagged -> filter.tag
|
||||
is TaskFilter.Smart -> stringResource(
|
||||
when (filter.list) {
|
||||
SmartList.TODAY -> R.string.smart_today
|
||||
|
||||
@@ -9,6 +9,8 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||
@@ -55,6 +57,10 @@ sealed interface TaskListUiState {
|
||||
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
|
||||
/** This real list is kept out of the smart lists. */
|
||||
val hiddenFromSmartLists: Boolean = false,
|
||||
/** Tags the unnarrowed view carries, offered as filter chips. */
|
||||
val availableTags: List<String> = emptyList(),
|
||||
/** The tags [tasks] is narrowed to; a task must carry all of them. */
|
||||
val selectedTags: Set<String> = emptySet(),
|
||||
) : TaskListUiState
|
||||
}
|
||||
|
||||
@@ -83,6 +89,8 @@ class TaskListViewModel @Inject constructor(
|
||||
|
||||
private val filter = MutableStateFlow<TaskFilter?>(null)
|
||||
|
||||
private val tagFilter = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** Occurrences swiped to delete but not yet committed — hidden until the undo lapses. */
|
||||
private val pendingDeletes = MutableStateFlow<Map<String, Task>>(emptyMap())
|
||||
|
||||
@@ -101,7 +109,7 @@ class TaskListViewModel @Inject constructor(
|
||||
combine(tasks, repository.taskLists()) { rows, lists ->
|
||||
TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId })
|
||||
}
|
||||
is TaskFilter.Smart ->
|
||||
is TaskFilter.Smart, is TaskFilter.Tagged ->
|
||||
tasks.map { TaskListUiState.Content(it) }
|
||||
// Several lists at once only exists for the widget, which never routes here.
|
||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||
@@ -109,12 +117,20 @@ class TaskListViewModel @Inject constructor(
|
||||
// Drop rows pending an undoable delete so the row vanishes on swipe
|
||||
// while the actual provider delete waits for the snackbar to commit,
|
||||
// and fold in the live UI settings (add-subtask row, bottom add bar).
|
||||
combine(content, pendingDeletes, settingsPrefs.settings) { st, pending, settings ->
|
||||
combine(content, pendingDeletes, settingsPrefs.settings, tagFilter) { st, pending, settings, tags ->
|
||||
if (st is TaskListUiState.Content) {
|
||||
val visible = settings.visibleIn(f, st.tasks).filter { it.occurrenceKey !in pending }
|
||||
val available = tagsInUse(visible) - setOfNotNull((f as? TaskFilter.Tagged)?.tag)
|
||||
// A tag that vanished can no longer be unticked, so it is dropped
|
||||
// for good rather than narrowing again when it comes back.
|
||||
val selected = tags.filterTo(LinkedHashSet()) { it in available }
|
||||
if (selected.size != tags.size) tagFilter.value = selected
|
||||
st.copy(
|
||||
tasks = settings.visibleIn(f, st.tasks)
|
||||
.filter { it.occurrenceKey !in pending }
|
||||
tasks = visible
|
||||
.filter { TaskFiltering.hasAllTags(it, selected) }
|
||||
.sortedWith(settings.sortOrder.comparator()),
|
||||
availableTags = available,
|
||||
selectedTags = selected,
|
||||
showAddSubtaskRow = settings.showAddSubtaskRow,
|
||||
bottomAddBar = settings.bottomAddBar,
|
||||
sortOrder = settings.sortOrder,
|
||||
@@ -163,6 +179,12 @@ class TaskListViewModel @Inject constructor(
|
||||
|
||||
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
||||
|
||||
fun toggleTagFilter(tag: String) {
|
||||
tagFilter.value = if (tag in tagFilter.value) tagFilter.value - tag else tagFilter.value + tag
|
||||
}
|
||||
|
||||
fun clearTagFilter() { tagFilter.value = emptySet() }
|
||||
|
||||
fun toggleComplete(task: Task) {
|
||||
if (task.isCompleted) {
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -169,6 +169,7 @@ private fun TaskWidgetBody(
|
||||
val ready = data as? TaskWidgetData.Ready
|
||||
val title = when (filter) {
|
||||
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
|
||||
is TaskFilter.Tagged -> filter.tag
|
||||
is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
|
||||
?: context.getString(R.string.widget_tasks_label)
|
||||
}
|
||||
@@ -279,6 +280,7 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo
|
||||
// still opens straight to it, otherwise this just raises the app.
|
||||
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
|
||||
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
is TaskFilter.Tagged -> Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
WidgetHeader(
|
||||
title = title,
|
||||
|
||||
@@ -221,7 +221,7 @@ private fun WidgetFilterPicker(
|
||||
when (selected) {
|
||||
is TaskFilter.OfList -> setOf(selected.listId)
|
||||
is TaskFilter.OfLists -> selected.listIds
|
||||
is TaskFilter.Smart -> emptySet()
|
||||
is TaskFilter.Smart, is TaskFilter.Tagged -> emptySet()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ internal fun TaskFilter.widgetStorageValue(): String = when (this) {
|
||||
is TaskFilter.OfList -> "list:$listId"
|
||||
is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}"
|
||||
is TaskFilter.Smart -> "smart:${list.name}"
|
||||
is TaskFilter.Tagged -> error("A tag has no widget")
|
||||
}
|
||||
|
||||
/** Anything unreadable, or a smart list the widget doesn't offer, falls back to Today. */
|
||||
@@ -166,7 +167,7 @@ sealed interface TaskWidgetData {
|
||||
val singleListId: Long? = when (filter) {
|
||||
is TaskFilter.OfList -> filter.listId
|
||||
is TaskFilter.OfLists -> filter.listIds.singleOrNull()
|
||||
is TaskFilter.Smart -> null
|
||||
is TaskFilter.Smart, is TaskFilter.Tagged -> null
|
||||
}
|
||||
|
||||
/** [singleListId]'s name. */
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
<string name="lists_reorder_done">Done reordering</string>
|
||||
<string name="lists_reorder_handle">Drag to reorder</string>
|
||||
<string name="sort_title">Sort by</string>
|
||||
<string name="view_options_title">Sort and filter</string>
|
||||
<string name="view_filter_tags">Filter by tag</string>
|
||||
<string name="view_filter_clear">Clear filter</string>
|
||||
<string name="sort_due">Due date</string>
|
||||
<string name="sort_priority">Priority</string>
|
||||
<string name="sort_title_alpha">Title</string>
|
||||
@@ -185,6 +188,9 @@
|
||||
<string name="edit_location_label">Location</string>
|
||||
<string name="edit_url_label">Link</string>
|
||||
<string name="edit_url_hint">https://…</string>
|
||||
<string name="edit_tags_label">Tags</string>
|
||||
<string name="edit_tags_hint">Add a tag</string>
|
||||
<string name="edit_tag_remove">Remove tag %1$s</string>
|
||||
<string name="edit_discard_title">Discard changes?</string>
|
||||
<string name="edit_discard_body">Your changes to this task haven’t been saved.</string>
|
||||
<string name="edit_discard_confirm">Discard</string>
|
||||
@@ -212,6 +218,7 @@
|
||||
|
||||
<!-- Lists overview -->
|
||||
<string name="lists_header">Lists</string>
|
||||
<string name="tags_header">Tags</string>
|
||||
<string name="new_task">New task</string>
|
||||
<string name="lists_failure">Could not read your tasks.</string>
|
||||
<string name="lists_empty">No task lists yet.</string>
|
||||
|
||||
@@ -148,6 +148,17 @@ class TaskWriteMapperTest {
|
||||
assertThat(undone[Tasks.COMPLETED]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `category row carries only the name, so the provider resolves the category itself`() {
|
||||
val values = TaskWriteMapper.categoryValues(taskId = 12L, name = "Work")
|
||||
|
||||
assertThat(values).containsExactly(
|
||||
TasksContract.Properties.TASK_ID, 12L,
|
||||
TasksContract.Properties.MIMETYPE, "vnd.android.cursor.item/category",
|
||||
TasksContract.Category.NAME, "Work",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alarm carries every column the provider's validator demands`() {
|
||||
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
|
||||
|
||||
@@ -303,6 +303,65 @@ class VTodoMapperTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Categories {
|
||||
|
||||
@Test
|
||||
fun `a single plain line is claimed into the column`() {
|
||||
val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work,Home\\, Garden"))
|
||||
assertThat(mapped.entity.categories).containsExactly("Work", "Home, Garden").inOrder()
|
||||
assertThat(mapped.entity.unknownProperties.orEmpty()).doesNotContain("CATEGORIES")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `several lines are read but round-trip from the residue`() {
|
||||
val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work", "CATEGORIES:Errands"))
|
||||
assertThat(mapped.entity.categories).containsExactly("Work", "Errands").inOrder()
|
||||
|
||||
val written = VTodoMapper.write(mapped.entity).properties("CATEGORIES").map { it.value }
|
||||
assertThat(written).containsExactly("Work", "Errands")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a line the column would not reproduce exactly stays in the residue`() {
|
||||
val mapped = VTodoMapper.read(vtodo("CATEGORIES;LANGUAGE=de:Arbeit", "SUMMARY:x"))
|
||||
assertThat(mapped.entity.categories).containsExactly("Arbeit")
|
||||
assertThat(VTodoMapper.read(vtodo("CATEGORIES:a, b")).entity.unknownProperties)
|
||||
.contains("CATEGORIES:a, b")
|
||||
|
||||
val written = VTodoMapper.write(mapped.entity).properties("CATEGORIES")
|
||||
assertThat(written.single().param("LANGUAGE")).isEqualTo("de")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing the tags evicts the residue copies and authors one line`() {
|
||||
val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work", "CATEGORIES:Errands"))
|
||||
val edited = mapped.entity.copy(categories = listOf("Work", "Home"))
|
||||
|
||||
val written = VTodoMapper.write(edited).properties("CATEGORIES")
|
||||
assertThat(written.single().value).isEqualTo("Work,Home")
|
||||
assertThat(written.single().params).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no tags writes no line`() {
|
||||
assertThat(VTodoMapper.write(entity()).properties("CATEGORIES")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duplicated or blank tag is dropped and the line stays in the residue`() {
|
||||
val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work, ,Work"))
|
||||
assertThat(mapped.entity.categories).containsExactly("Work")
|
||||
assertThat(VTodoMapper.write(mapped.entity).properties("CATEGORIES").single().value).isEqualTo("Work, ,Work")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unclean column is cleaned on the way out`() {
|
||||
val written = VTodoMapper.write(entity().copy(categories = listOf(" a ", "", "a", "b")))
|
||||
assertThat(written.properties("CATEGORIES").single().value).isEqualTo("a,b")
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Validation {
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ class VTodoRoundTripTest {
|
||||
"mixed-value-types",
|
||||
"malformed-scalars",
|
||||
"two-timezones",
|
||||
"categories-shapes",
|
||||
"categories-split",
|
||||
)
|
||||
|
||||
@TestFactory
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CategoriesLiftTest {
|
||||
|
||||
@Test
|
||||
fun `claims a lone plain line and keeps the rest of the residue`() {
|
||||
val result = CategoriesLift.lift("X-FOO:bar\r\nCATEGORIES:Work,Home\r\n")
|
||||
assertThat(result.categories).containsExactly("Work", "Home").inOrder()
|
||||
assertThat(result.claimed).isTrue()
|
||||
assertThat(result.residue).contains("X-FOO:bar")
|
||||
assertThat(result.residue).doesNotContain("CATEGORIES")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a residue that was only the line becomes null`() {
|
||||
assertThat(CategoriesLift.lift("CATEGORIES:Work\r\n").residue).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `several lines are read but not claimed`() {
|
||||
val residue = "CATEGORIES:a\r\nCATEGORIES:b\r\n"
|
||||
val result = CategoriesLift.lift(residue)
|
||||
assertThat(result.categories).containsExactly("a", "b").inOrder()
|
||||
assertThat(result.claimed).isFalse()
|
||||
assertThat(result.residue).isEqualTo(residue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing to lift from an empty residue`() {
|
||||
assertThat(CategoriesLift.lift(null)).isEqualTo(CategoriesLift.Result(emptyList(), null, claimed = false))
|
||||
}
|
||||
}
|
||||
@@ -50,4 +50,12 @@ class ConvertersTest {
|
||||
}
|
||||
assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `categories round-trip with escaped commas and come back cleaned`() {
|
||||
val stored = CategoriesConverter.toText(listOf("a,b", " c ", "", "c"))
|
||||
assertThat(stored).isEqualTo("a\\,b,c")
|
||||
assertThat(CategoriesConverter.fromText(stored)).containsExactly("a,b", "c").inOrder()
|
||||
assertThat(CategoriesConverter.fromText("")).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ private fun form(
|
||||
start: Instant? = null,
|
||||
due: Instant? = null,
|
||||
isAllDay: Boolean = false,
|
||||
tags: List<String> = emptyList(),
|
||||
) = TaskForm(
|
||||
title = title,
|
||||
listId = 1,
|
||||
@@ -36,6 +37,7 @@ private fun form(
|
||||
start = start,
|
||||
due = due,
|
||||
isAllDay = isAllDay,
|
||||
tags = tags,
|
||||
)
|
||||
|
||||
class TaskFormWriterTest {
|
||||
@@ -51,6 +53,12 @@ class TaskFormWriterTest {
|
||||
assertThat(entity.isDirty).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tags are trimmed and blanks dropped`() {
|
||||
val entity = TaskFormWriter.apply(task(), form(tags = listOf(" Work ", "", " ", "Home")), NOW, ZONE)
|
||||
assertThat(entity.categories).containsExactly("Work", "Home").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress and status move together in both directions`() {
|
||||
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status)
|
||||
|
||||
@@ -58,4 +58,32 @@ class TaskFilteringTest {
|
||||
assertThat(TaskFiltering.matches(task, TaskFilter.OfList(7), todayStart, todayEnd)).isTrue()
|
||||
assertThat(TaskFiltering.matches(task, TaskFilter.OfList(8), todayStart, todayEnd)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Tagged matches open and completed tasks carrying the tag`() {
|
||||
val filter = TaskFilter.Tagged("Work")
|
||||
assertThat(TaskFiltering.matches(testTask(tags = listOf("Home", "Work")), filter, todayStart, todayEnd)).isTrue()
|
||||
assertThat(
|
||||
TaskFiltering.matches(testTask(tags = listOf("Work"), status = TaskStatus.COMPLETED), filter, todayStart, todayEnd),
|
||||
).isTrue()
|
||||
assertThat(TaskFiltering.matches(testTask(tags = listOf("work")), filter, todayStart, todayEnd)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `narrowing by tags needs every one of them`() {
|
||||
val task = testTask(tags = listOf("Home", "Work"))
|
||||
assertThat(TaskFiltering.hasAllTags(task, emptySet())).isTrue()
|
||||
assertThat(TaskFiltering.hasAllTags(task, setOf("Work"))).isTrue()
|
||||
assertThat(TaskFiltering.hasAllTags(task, setOf("Work", "Home"))).isTrue()
|
||||
assertThat(TaskFiltering.hasAllTags(task, setOf("Work", "Errands"))).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tags in use are listed once each, alphabetically ignoring case`() {
|
||||
val tasks = listOf(
|
||||
testTask(id = 1, tags = listOf("work", "Home")),
|
||||
testTask(id = 2, tags = listOf("Home", "errands")),
|
||||
)
|
||||
assertThat(tagsInUse(tasks)).containsExactly("errands", "Home", "work").inOrder()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,4 +44,16 @@ class TaskFormTest {
|
||||
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields())
|
||||
.containsExactly(TaskFormField.Progress, TaskFormField.Parent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tags auto-reveal when the task has any`() {
|
||||
assertThat(TaskForm(title = "x", listId = 1, tags = listOf("Work")).populatedFields())
|
||||
.containsExactly(TaskFormField.Tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tags are normalised to trimmed, non-blank, unique spellings in order`() {
|
||||
assertThat(normalizeTags(listOf(" Work", "", "Home ", "Work", "work")))
|
||||
.containsExactly("Work", "Home", "work").inOrder()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ fun testTask(
|
||||
isAllDay: Boolean = false,
|
||||
completedAt: Instant? = null,
|
||||
parentId: Long? = null,
|
||||
tags: List<String> = emptyList(),
|
||||
): Task = Task(
|
||||
taskId = id,
|
||||
listId = listId,
|
||||
@@ -38,4 +39,5 @@ fun testTask(
|
||||
distanceFromCurrent = 0,
|
||||
created = null,
|
||||
lastModified = null,
|
||||
tags = tags,
|
||||
)
|
||||
|
||||
@@ -35,9 +35,10 @@ class ICalendarWriterTest {
|
||||
rrule: String? = null,
|
||||
rdate: String? = null,
|
||||
parentId: Long? = null,
|
||||
tags: List<String> = emptyList(),
|
||||
) = ExportTask(
|
||||
taskId, uid, title, description, location, url, priority, status, percentComplete,
|
||||
start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId,
|
||||
start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId, tags,
|
||||
)
|
||||
|
||||
private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String =
|
||||
@@ -59,6 +60,13 @@ class ICalendarWriterTest {
|
||||
assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writes the tags as one CATEGORIES line, escaped`() {
|
||||
val lines = linesOf(write(task(tags = listOf("Work", "Home, Garden"))))
|
||||
assertThat(lines).contains("CATEGORIES:Work,Home\\, Garden")
|
||||
assertThat(linesOf(write(task())).none { it.startsWith("CATEGORIES") }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uses CRLF line endings`() {
|
||||
// RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected
|
||||
|
||||
@@ -30,3 +30,5 @@ VTIMEZONE bodies, fold positions, parameter quoting).
|
||||
| `malformed-scalars.ics` | `PRIORITY:11`, `PERCENT-COMPLETE:150`, `SEQUENCE:x`, `STATUS:X-DEFERRED`, `CLASS:X-INTERNAL` — values no spec allows. Clamping or defaulting any of them is a silent rewrite of somebody's data |
|
||||
| `two-timezones.ics` | `DTSTART` and `DUE` in different zones. One `timezone` column cannot author both |
|
||||
| `folded-utf8.ics` | A line folded mid-emoji-adjacent, to prove folding counts octets and splits on character boundaries |
|
||||
| `categories-shapes.ics` | One `CATEGORIES` line with an escaped comma inside a value — the one shape the column claims, so it is re-authored and must come out identical |
|
||||
| `categories-split.ics` | One line per tag, a `LANGUAGE` parameter and a space after a comma. Read for display, but none of it is ours to rewrite |
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Several Clients//EN
|
||||
BEGIN:VTODO
|
||||
UID:categories-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:One line, an escaped comma
|
||||
CATEGORIES:Work,Home\, Garden
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,12 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Several Clients//EN
|
||||
BEGIN:VTODO
|
||||
UID:categories-002
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:One line per tag, one with a language
|
||||
CATEGORIES:Errands
|
||||
CATEGORIES;LANGUAGE=de:Einkauf
|
||||
CATEGORIES:a, b
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
Reference in New Issue
Block a user