From fcee1d1736ae984fa8b76a4fbc92183e8a2378cd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 4 Sep 2026 13:46:05 +0200 Subject: [PATCH] feat(lists): manage lists in the app, and fix four store defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owning the store left a fresh install with no lists and no way to make one, so no way to save a task. The seam gains updateList/deleteList beside createLocalList on both paths — the External one addresses the row as its own account's sync adapter, the only caller the provider lets write tasklists. ListEditorSheet is the family's full-screen sheet: name field, a 12-colour palette, and a destructive row behind a confirm when editing. Entry points are a "New list" row under the home Lists section, an empty state with a create button, and the home FAB switching to "New list" while there are none. Deleting takes the list's tasks with it and is offered only for device-only lists. Four defects a review of the branch turned up: - completing one occurrence closed the whole series — setCompleted wrote the master, the row TaskDao.tasks filters on. setCompletedInstance forks a RECURRENCE-ID override the way updateInstance does; phase 2 always specified this, only the edit half had it - the expansion ceiling was spent on the past, so a sub-daily series stopped expanding months before today and never reached Today or Upcoming - an imported START-referenced reminder fired off DUE, because the seam collapsed alarms to a bare minute count. TaskReminder carries the anchor now - registerObserver bound a live flow to whichever store was active at subscription, so a Settings store switch left every screen listening to the store it had stopped reading --- .../tasks/room/RoomTasksDataSourceTest.kt | 78 +++++ .../agendula/data/demo/DemoSeeder.kt | 2 +- .../data/reminders/ReminderScheduler.kt | 10 +- .../data/tasks/AndroidTasksDataSource.kt | 58 +++- .../data/tasks/ModeRoutingTasksDataSource.kt | 42 ++- .../agendula/data/tasks/ProviderResolver.kt | 21 ++ .../agendula/data/tasks/TaskWriteMapper.kt | 6 +- .../agendula/data/tasks/TasksContract.kt | 2 + .../agendula/data/tasks/TasksDataSource.kt | 40 ++- .../agendula/data/tasks/TasksRepository.kt | 11 +- .../data/tasks/TasksRepositoryImpl.kt | 18 +- .../data/tasks/room/RoomTasksDataSource.kt | 127 ++++++-- .../domain/recurrence/RecurrenceExpander.kt | 29 +- .../agendula/ui/common/ListColors.kt | 29 ++ .../agendula/ui/common/ShapedActionButton.kt | 3 + .../agendula/ui/detail/TaskDetailViewModel.kt | 2 +- .../agendula/ui/lists/ListEditorSheet.kt | 295 ++++++++++++++++++ .../agendula/ui/lists/ListsScreen.kt | 113 ++++++- .../agendula/ui/lists/ListsViewModel.kt | 29 +- .../agendula/ui/tasklist/TaskListScreen.kt | 72 ++++- .../agendula/ui/tasklist/TaskListViewModel.kt | 47 ++- app/src/main/res/values/strings.xml | 27 +- .../data/tasks/ProviderResolverTest.kt | 19 ++ .../recurrence/RecurrenceExpanderTest.kt | 39 +++ docs/ROADMAP.md | 27 ++ 25 files changed, 1067 insertions(+), 79 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt index 98ce04d..69f4e33 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt @@ -67,6 +67,31 @@ class RoomTasksDataSourceTest { assertThat(lists.single().accountName).isEqualTo("Local") } + @Test + fun renamesAndRecoloursAList() { + source.updateList(listId, " Errands ", 0xFF445566.toInt()) + + val list = source.taskLists().single() + assertThat(list.name).isEqualTo("Errands") + assertThat(list.color).isEqualTo(0xFF445566.toInt()) + // Nothing to sync a device-only list to, so the edit leaves it clean. + assertThat(db.taskLists().entity(listId)!!.isDirty).isFalse() + } + + @Test + fun deletingAListTakesItsTasksWithIt() { + source.insertTask(form(title = "Buy milk")) + source.insertTask(form(title = "Call the bank")) + val other = source.createLocalList("Work", 0xFF778899.toInt()) + val keeper = source.insertTask(TaskForm(title = "Ship it", listId = other)) + + source.deleteList(listId) + + assertThat(source.taskLists().map { it.id }).containsExactly(other) + assertThat(source.tasks(TaskQuery(includeCompleted = true)).map { it.taskId }) + .containsExactly(keeper) + } + @Test fun createsAndReadsBackANonRecurringTask() { val due = now + 1.days @@ -143,6 +168,59 @@ class RoomTasksDataSourceTest { assertThat(override.title).isEqualTo("Water them twice") } + @Test + fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() { + val id = source.insertTask(form(title = "Water the plants")) + makeRecurring(id, now) + val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } } + val before = open() + val target = before.first { it.distanceFromCurrent == 0 } + + source.setCompletedInstance(id, target.occurrenceStart!!, completed = true) + + // Writing the status onto the master would close the series: the master is + // the row the task query filters on, so every occurrence would vanish. + val after = open() + assertThat(after).hasSize(before.size - 1) + assertThat(after.map { it.occurrenceStart }).doesNotContain(target.occurrenceStart) + assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION) + + val override = db.tasks().override(id, target.occurrenceStart)!! + assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid) + assertThat(override.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(override.rrule).isNull() + // The override stands for *that* occurrence, so it carries the + // occurrence's resolved times, not the master's anchor. + assertThat(override.dtstart).isEqualTo(target.occurrenceStart) + } + + @Test + fun reopeningACompletedOccurrenceReusesItsOverride() { + val id = source.insertTask(form(title = "Water the plants")) + makeRecurring(id, now) + val target = source.tasks(TaskQuery(listId = listId)) + .first { it.taskId == id && it.distanceFromCurrent == 0 } + + source.setCompletedInstance(id, target.occurrenceStart!!, completed = true) + source.setCompletedInstance(id, target.occurrenceStart, completed = false) + + assertThat(db.tasks().overrides(id)).hasSize(1) + assertThat(db.tasks().override(id, target.occurrenceStart)!!.status) + .isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart }) + .contains(target.occurrenceStart) + } + + @Test + fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() { + val id = source.insertTask(form(title = "Buy milk", due = now + 1.days)) + + source.setCompletedInstance(id, now, completed = true) + + assertThat(db.tasks().overrides(id)).isEmpty() + assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED) + } + @Test fun anOverrideReplacesOnlyItsOwnOccurrence() { val id = source.insertTask(form(title = "Water the plants")) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt index f3234ff..a9b8f6a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt @@ -50,7 +50,7 @@ class DemoSeeder @Inject constructor( repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId)) val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day))) - repository.setCompleted(done, completed = true) + repository.setCompleted(done, occurrenceStart = null, completed = true) } private companion object { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt index ec63c3a..089ee9d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt @@ -59,12 +59,18 @@ class ReminderScheduler @Inject constructor( // A reminder set on the task itself wins; otherwise the task's list // may override the global lead, or opt out entirely (override = // null), in which case it gets no reminder at all. - val lead = perTask[task.taskId] + val reminder = perTask[task.taskId] + val lead = reminder?.minutesBefore ?: settings.reminderLeadFor(task.listId) ?: return@mapNotNull null + // A stored reminder says what it counts back from. Ours are always + // before due, but an imported dmfs alarm or another client's can be + // before *start* — firing those off the due date is silently wrong + // for every task whose start and due differ. + val anchor = if (reminder?.fromStart == true) task.start ?: task.due!! else task.due!! ScheduledReminder( taskId = task.taskId, - triggerAt = task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L, + triggerAt = anchor.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L, ) } // The lower bound trails `now` so a reminder missed while the device was diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt index 4ef3fad..ca7077d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt @@ -170,9 +170,13 @@ class AndroidTasksDataSource @Inject constructor( } } - override fun alarms(): Map { + override fun alarms(): Map { val uri = TasksContract.propertiesUri(authority()) - val projection = arrayOf(Properties.TASK_ID, TasksContract.Alarm.MINUTES_BEFORE) + val projection = arrayOf( + Properties.TASK_ID, + TasksContract.Alarm.MINUTES_BEFORE, + TasksContract.Alarm.REFERENCE, + ) return resolver.query( uri, projection, @@ -185,7 +189,16 @@ class AndroidTasksDataSource @Inject constructor( while (c.moveToNext()) { val id = reader.getLong(Properties.TASK_ID) val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE) - if (id != null && minutes != null) put(id, minutes) + val reference = reader.getInt(TasksContract.Alarm.REFERENCE) + if (id != null && minutes != null) { + put( + id, + TaskReminder( + minutesBefore = minutes, + fromStart = reference == TasksContract.Alarm.REFERENCE_START, + ), + ) + } } } } ?: emptyMap() @@ -197,6 +210,20 @@ class AndroidTasksDataSource @Inject constructor( if (rows == 0) throw TaskWriteFailedException("complete task $taskId") } + /** + * Through the instances URI, which is what makes the provider fork an override + * rather than close the series. No instance row for the anchor means the task + * is not a series after all — the plain write is then the right one. + */ + override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) { + val instanceId = instanceIdFor(taskId, occurrenceStart) + ?: return setCompleted(taskId, completed) + val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis()) + val uri = TasksContract.instanceUri(authority(), instanceId) + val rows = resolver.update(uri, values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId") + } + override fun deleteTask(taskId: Long) { resolver.delete(taskUri(authority(), taskId), null, null) } @@ -213,6 +240,31 @@ class AndroidTasksDataSource @Inject constructor( return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id") } + override fun updateList(listId: Long, name: String, color: Int) { + val values = TaskWriteMapper.listValues(name, color) + val rows = resolver.update(listSyncUri(listId), values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("update list $listId") + } + + override fun deleteList(listId: Long) { + val rows = resolver.delete(listSyncUri(listId), null, null) + if (rows == 0) throw TaskWriteFailedException("delete list $listId") + } + + /** + * A list row addressed as its own account's sync adapter — the provider only + * lets that caller write the `tasklists` table, and the account has to be the + * row's own (the params are matched against it, not merely accepted). + */ + private fun listSyncUri(listId: Long): Uri { + val authority = authority() + val uri = TasksContract.listUri(authority, listId) + val account = resolver.query(uri, arrayOf(Lists.ACCOUNT_NAME, Lists.ACCOUNT_TYPE), null, null, null) + ?.use { c -> if (c.moveToFirst()) c.getString(0).orEmpty() to c.getString(1).orEmpty() else null } + ?: throw TaskWriteFailedException("list $listId not found") + return TasksContract.asSyncAdapter(uri, account.first, account.second) + } + // --- observation ---------------------------------------------------------- override fun registerObserver(onChange: () -> Unit): AutoCloseable { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt index 22120ac..ae5ef6b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt @@ -41,10 +41,48 @@ class ModeRoutingTasksDataSource( active().updateInstance(taskId, occurrenceStart, form) override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue) - override fun alarms(): Map = active().alarms() + override fun alarms(): Map = active().alarms() override fun exportTasks(listId: Long): List = active().exportTasks(listId) override fun setCompleted(taskId: Long, completed: Boolean) = active().setCompleted(taskId, completed) + + override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = + active().setCompletedInstance(taskId, occurrenceStart, completed) + override fun deleteTask(taskId: Long) = active().deleteTask(taskId) override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color) - override fun registerObserver(onChange: () -> Unit): AutoCloseable = active().registerObserver(onChange) + override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color) + override fun deleteList(listId: Long) = active().deleteList(listId) + /** + * Unlike every other method here, an observer is registered once and then + * *held* — so it cannot be routed per call, and would otherwise stay bound to + * whichever store was active when the flow started. Switching stores in + * Settings would then leave every open screen listening to the store it is no + * longer reading from. + * + * So the registration moves with the mode, and the switch itself counts as a + * change: the data underneath every live flow has just been replaced. + */ + override fun registerObserver(onChange: () -> Unit): AutoCloseable { + val lock = Any() + var closed = false + var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull() + + val modeHandle = resolver.onModeChanged { + synchronized(lock) { + if (!closed) { + handle?.let { runCatching { it.close() } } + handle = runCatching { active().registerObserver(onChange) }.getOrNull() + } + } + onChange() + } + return AutoCloseable { + synchronized(lock) { + closed = true + modeHandle.close() + handle?.let { runCatching { it.close() } } + handle = null + } + } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt index a8ebe11..7f752c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject import javax.inject.Singleton @@ -31,15 +32,35 @@ class ProviderResolver @Inject constructor( private val environment: ProviderEnvironment, ) { + private val modeListeners = CopyOnWriteArrayList<() -> Unit>() + /** * The user's explicit choice, or `null` while they have not made one (which is * the normal state — most people never open Settings). Kept as a plain field * rather than read from DataStore on demand because [resolve] is called from * synchronous data-source code on every query, including from the main thread * via `providerStatus()`. [StorageModeHolder] owns keeping it current. + * + * Assigning a *different* mode notifies [onModeChanged]: every live store + * observer is bound to one store and has to be moved across. */ @Volatile var storageMode: StorageMode? = null + set(value) { + val changed = field != value + field = value + if (changed) modeListeners.forEach { it() } + } + + /** + * Observe switches between stores. Fires on the thread that set [storageMode] + * — [StorageModeHolder]'s collector — so listeners must be cheap and must not + * block. + */ + fun onModeChanged(listener: () -> Unit): AutoCloseable { + modeListeners += listener + return AutoCloseable { modeListeners -= listener } + } /** The active store, resolving the undecided case through [autoMode]. */ fun mode(): StorageMode = storageMode ?: autoMode() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt index 494e78d..f54dddd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt @@ -107,9 +107,13 @@ object TaskWriteMapper { Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE, ) - fun localListValues(name: String, color: Int): Map = mapOf( + /** The user-owned columns of a list — what an edit is allowed to change. */ + fun listValues(name: String, color: Int): Map = mapOf( Lists.NAME to name.trim(), Lists.COLOR to color, + ) + + fun localListValues(name: String, color: Int): Map = listValues(name, color) + mapOf( Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME, Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE, Lists.VISIBLE to 1, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt index 674bc94..63e1ce8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt @@ -163,6 +163,8 @@ object TasksContract { fun authorityUri(authority: String): Uri = Uri.parse("content://$authority") fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}") + fun listUri(authority: String, listId: Long): Uri = + Uri.parse("content://$authority/${Lists.PATH}/$listId") fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}") fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}") diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt index a8885e5..49b50bc 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt @@ -5,6 +5,16 @@ import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList import kotlin.time.Instant +/** + * A stored reminder: how long before, and what it counts back from. + * + * [fromStart] matters because both stores can hold a `START`-referenced alarm — + * the dmfs import preserves one, and an external provider's other clients write + * them — while Agendula's own UI only ever sets a before-due lead. Collapsing it + * to a number here is what silently fired those reminders off the wrong anchor. + */ +data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false) + /** What to fetch from the provider. Smart-list date logic is applied above this. */ data class TaskQuery( val listId: Long? = null, @@ -45,8 +55,8 @@ interface TasksDataSource { */ fun setAlarm(taskId: Long, minutesBeforeDue: Int?) - /** Every task's reminder lead, by task id. One query, for the scheduler. */ - fun alarms(): Map + /** Every task's reminder, by task id. One query, for the scheduler. */ + fun alarms(): Map /** * Every task in [listId] read from the **`tasks` table**, for export. Masters, @@ -56,9 +66,35 @@ interface TasksDataSource { fun exportTasks(listId: Long): List fun setCompleted(taskId: Long, completed: Boolean) + + /** + * Complete (or reopen) **one occurrence** of a recurring task, addressed the + * same way [updateInstance] is. Ticking a series through [setCompleted] would + * close the master row, which takes every past and future occurrence out of + * every list at once. + * + * Implementations fall back to [setCompleted] when the row turns out not to + * be a series master — an override, or a plain task the caller happened to + * hand an anchor for — so the routing above cannot get this wrong. + */ + fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) fun deleteTask(taskId: Long) fun createLocalList(name: String, color: Int): Long + /** Rename and recolour [listId]. */ + fun updateList(listId: Long, name: String, color: Int) + + /** + * Delete [listId] **and the tasks in it** — `tasks.list_id` cascades on the + * Room path, and the provider does the same on the External one. + * + * Only ever called for a local, device-only list: a collection that belongs + * to an account is the server's to remove, and neither store expresses a + * collection tombstone yet. The UI gates on [TaskList.isLocal]; this seam + * does not re-check it. + */ + fun deleteList(listId: Long) + /** Observe any change to tasks/lists; [onChange] fires on a background thread. */ fun registerObserver(onChange: () -> Unit): AutoCloseable } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt index 3333e6c..4601732 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt @@ -37,7 +37,12 @@ interface TasksRepository { * since the form loaded. Pass `null` to force the write (overwrite-anyway). */ suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null) - suspend fun setCompleted(taskId: Long, completed: Boolean) + /** + * Complete or reopen a task. Pass the occurrence's [Task.occurrenceStart] so a + * recurring series forks a `RECURRENCE-ID` override for that one occurrence + * instead of closing the whole series; `null` completes the row itself. + */ + suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) suspend fun deleteTask(taskId: Long) /** @@ -47,6 +52,10 @@ interface TasksRepository { */ suspend fun reminderFor(taskId: Long): Int? suspend fun createLocalList(name: String, color: Int): Long + suspend fun updateList(listId: Long, name: String, color: Int) + + /** Deletes the list **and its tasks**. Local lists only — see [TasksDataSource.deleteList]. */ + suspend fun deleteList(listId: Long) /** Synchronous snapshot for the permission/onboarding gate. */ fun providerStatus(): ProviderStatus diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index 03850ad..ab2ef75 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -88,7 +88,7 @@ class TasksRepositoryImpl @Inject constructor( } override suspend fun reminderFor(taskId: Long): Int? = - withContext(io) { runCatching { dataSource.alarms()[taskId] }.getOrNull() } + withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() } override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) = withContext(io) { @@ -117,8 +117,14 @@ class TasksRepositoryImpl @Inject constructor( } } - override suspend fun setCompleted(taskId: Long, completed: Boolean) = - withContext(io) { dataSource.setCompleted(taskId, completed) } + override suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) = + withContext(io) { + if (occurrenceStart != null) { + dataSource.setCompletedInstance(taskId, occurrenceStart, completed) + } else { + dataSource.setCompleted(taskId, completed) + } + } override suspend fun deleteTask(taskId: Long) = withContext(io) { dataSource.deleteTask(taskId) } @@ -126,6 +132,12 @@ class TasksRepositoryImpl @Inject constructor( override suspend fun createLocalList(name: String, color: Int): Long = withContext(io) { dataSource.createLocalList(name, color) } + override suspend fun updateList(listId: Long, name: String, color: Int) = + withContext(io) { dataSource.updateList(listId, name, color) } + + override suspend fun deleteList(listId: Long) = + withContext(io) { dataSource.deleteList(listId) } + override fun providerStatus(): ProviderStatus { // Our own store is always ready: it ships with the app, needs no provider // and no grant. The permission gate only ever applied to External mode — diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt index d482877..a496df0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.agendula.data.tasks.room import androidx.room.InvalidationTracker import de.jeanlucmakiola.agendula.data.tasks.TaskQuery +import de.jeanlucmakiola.agendula.data.tasks.TaskReminder import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.domain.Task @@ -74,8 +75,13 @@ class RoomTasksDataSource @Inject constructor( override fun exportTasks(listId: Long): List = tasks.exportTasks(listId).map(RoomTaskMapper::exportTask) - override fun alarms(): Map = - alarms.all().associate { it.taskId to it.minutesBefore } + override fun alarms(): Map = + alarms.all().associate { + it.taskId to TaskReminder( + minutesBefore = it.minutesBefore, + fromStart = it.reference == AlarmReference.START, + ) + } /** * Every occurrence of [row] inside the expansion window, with any @@ -86,46 +92,51 @@ class RoomTasksDataSource @Inject constructor( */ private fun occurrencesOf(row: TaskRow, overrides: List, now: Instant): List { val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row)) - val window = ExpansionWindow(from = now - WINDOW_BACK, until = now + WINDOW_FORWARD) + val window = ExpansionWindow( + from = now - WINDOW_BACK, + until = now + WINDOW_FORWARD, + pivot = now, + ) val anchors = RecurrenceExpander.expand(spec, window) if (anchors.isEmpty()) return emptyList() val distances = RecurrenceExpander.distancesFromCurrent(anchors, now) val byAnchor = overrides.associateBy { it.recurrenceId } - // A timed series keeps each occurrence's duration; a due-anchored one has - // no start to offset from, so the anchor *is* the due date. - val length = row.task.dtstart?.let { start -> row.task.due?.let { it - start } } return anchors.mapIndexedNotNull { index, anchor -> val override = byAnchor[anchor] - when { - override != null -> RoomTaskMapper.task( + if (override != null) { + RoomTaskMapper.task( row = row.copy(task = override), occurrenceStart = anchor, start = override.dtstart, due = override.due, distanceFromCurrent = distances[index], ) - - row.task.dtstart != null -> RoomTaskMapper.task( + } else { + val (start, due) = occurrenceTimes(row.task, anchor) + RoomTaskMapper.task( row = row, occurrenceStart = anchor, - start = anchor, - due = length?.let { anchor + it }, - distanceFromCurrent = distances[index], - ) - - else -> RoomTaskMapper.task( - row = row, - occurrenceStart = anchor, - start = null, - due = anchor, + start = start, + due = due, distanceFromCurrent = distances[index], ) } } } + /** + * One occurrence's resolved start and due. A timed series keeps each + * occurrence's duration; a due-anchored one has no start to offset from, so + * the anchor *is* the due date. + */ + private fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair { + if (master.dtstart == null) return null to anchor + val length = master.due?.let { it - master.dtstart } + return anchor to length?.let { anchor + it } + } + // --- writes --------------------------------------------------------------- override fun insertTask(form: TaskForm): Long { @@ -154,17 +165,9 @@ class RoomTasksDataSource @Inject constructor( tasks.update(TaskFormWriter.apply(existing, form, now, zone())) return } + val (start, due) = occurrenceTimes(master, occurrenceStart) val fork = TaskFormWriter.apply( - master.copy( - id = 0, - masterId = taskId, - recurrenceId = occurrenceStart, - rrule = null, - rdate = null, - exdate = null, - href = null, - etag = null, - ), + newOverride(master, taskId, occurrenceStart, start, due), form, now, zone(), @@ -187,6 +190,54 @@ class RoomTasksDataSource @Inject constructor( tasks.update(TaskFormWriter.completed(current, completed, clock.now())) } + /** + * Ticking one occurrence forks a `RECURRENCE-ID` override carrying the + * completion — the same model [updateInstance] writes. Writing the status onto + * the master instead would close the series: the master is what + * [TaskDao.tasks] filters on, so every occurrence, past and future, would + * leave every list at once. + */ + override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) { + val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete instance $taskId") + // Not a series master — an override, or a plain task the caller handed an + // anchor for. Either way this row *is* the occurrence. + if (master.recurrenceSpec() == null) return setCompleted(taskId, completed) + + val now = clock.now() + tasks.override(taskId, occurrenceStart)?.let { + tasks.update(TaskFormWriter.completed(it, completed, now)) + return + } + val (start, due) = occurrenceTimes(master, occurrenceStart) + val fork = TaskFormWriter.completed(newOverride(master, taskId, occurrenceStart, start, due), completed, now) + val id = tasks.insert(fork) + alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) } + } + + /** + * A blank override row for one occurrence of [master]: same UID (that is what + * makes it an override rather than a separate task), the series fields + * stripped, and no `href`/`etag` because the server has never seen it. + */ + private fun newOverride( + master: TaskEntity, + masterId: Long, + occurrenceStart: Instant, + start: Instant?, + due: Instant?, + ): TaskEntity = master.copy( + id = 0, + masterId = masterId, + recurrenceId = occurrenceStart, + dtstart = start, + due = due, + rrule = null, + rdate = null, + exdate = null, + href = null, + etag = null, + ) + /** * Hard delete for a row no server knows about, tombstone for one that is * still owed to a collection. `master_id` cascades, so deleting a series @@ -201,6 +252,22 @@ class RoomTasksDataSource @Inject constructor( override fun createLocalList(name: String, color: Int): Long = lists.insert(TaskListEntity(name = name.trim(), color = color)) + override fun updateList(listId: Long, name: String, color: Int) { + val current = lists.entity(listId) ?: throw TaskWriteFailedException("update list $listId") + // Only an account-backed collection owes a server a PROPPATCH; a + // device-only list has nothing to be dirty for. + lists.update( + current.copy( + name = name.trim(), + color = color, + isDirty = current.accountId != null, + ), + ) + } + + /** `tasks.list_id` is `ON DELETE CASCADE`, so the list's tasks go with it. */ + override fun deleteList(listId: Long) = lists.delete(listId) + // --- observation ---------------------------------------------------------- override fun registerObserver(onChange: () -> Unit): AutoCloseable { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt index 31e9a13..29f65e4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt @@ -27,11 +27,19 @@ data class RecurrenceSpec( /** * The window expansion is bounded to: [from] inclusive, [until] exclusive, and * never more than [maxOccurrences] results — so an unbounded `RRULE` terminates. + * + * [pivot] is where "now" sits inside the window, and it is what the occurrence + * budget is spent around. Without it a series firing more often than about + * once a day exhausts [maxOccurrences] inside the past alone — an eight-hourly + * task would stop expanding months before today, so it would never appear in + * Today or Upcoming at all. At most a quarter of the budget goes to occurrences + * before [pivot], and the most recent of those are the ones kept. */ data class ExpansionWindow( val from: Instant, val until: Instant, val maxOccurrences: Int = 500, + val pivot: Instant = from, ) /** @@ -74,15 +82,28 @@ object RecurrenceExpander { val iterator = set.iterator(zone, anchorMillis, window.until.toEpochMilliseconds()) iterator.fastForward(window.from.toEpochMilliseconds()) - val occurrences = ArrayList() + // Occurrences arrive ascending, so everything before the pivot lands first + // and `past` is final by the time the first future one appears. Past is a + // sliding window (the newest are the ones worth keeping); the rest of the + // budget then goes to the future, undiminished when there is no past. + val pastCap = window.maxOccurrences / 4 + val past = ArrayDeque() + val future = ArrayList() var previous = Long.MIN_VALUE - while (occurrences.size < window.maxOccurrences && iterator.hasNext()) { + while (iterator.hasNext()) { val millis = iterator.next() if (millis == previous) continue previous = millis - occurrences += Instant.fromEpochMilliseconds(millis) + val at = Instant.fromEpochMilliseconds(millis) + if (at < window.pivot) { + if (past.size == pastCap) past.removeFirst() + if (pastCap > 0) past.addLast(at) + } else { + future += at + if (past.size + future.size >= window.maxOccurrences) break + } } - return occurrences + return past + future } /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt new file mode 100644 index 0000000..0ccf9fd --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.agendula.ui.common + +/** + * The colours offered when creating or editing a task list. + * + * Raw ARGB, the way a CalDAV server sends one — every surface that draws a list + * colour runs it through + * [de.jeanlucmakiola.floret.components.pastelize] first, so these are hues + * rather than final fills, chosen to stay distinguishable after that pass. A + * list can still carry any colour a server gives it; this is only the set the + * app hands out. + */ +val ListPalette: List = listOf( + 0xFF7A5C6B.toInt(), // mauve — Agendula's own seed + 0xFFD7484A.toInt(), // red + 0xFFE8743B.toInt(), // orange + 0xFFE0A32E.toInt(), // amber + 0xFF7CA83E.toInt(), // olive + 0xFF35A06A.toInt(), // green + 0xFF19938C.toInt(), // teal + 0xFF2A9BC4.toInt(), // cyan + 0xFF3C74C8.toInt(), // blue + 0xFF6A5CC0.toInt(), // indigo + 0xFF9455B8.toInt(), // purple + 0xFFC94F8E.toInt(), // pink +) + +/** What a new list gets before the user picks anything. */ +val DefaultListColor: Int = ListPalette.first() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt index f7ddf99..6efdaba 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt @@ -98,4 +98,7 @@ object ActionShapes { /** Search — a 6-sided cookie, the same family as [Settings] but distinct. */ val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided + + /** New list — a sunny burst beside the Lists header. */ + val AddList: RoundedPolygon get() = MaterialShapes.Sunny } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt index 98db1a4..fff9af0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt @@ -49,7 +49,7 @@ class TaskDetailViewModel @Inject constructor( fun bind(id: Long) { taskId.value = id } fun toggleComplete(task: Task) = viewModelScope.launch { - runCatching { repository.setCompleted(task.taskId, !task.isCompleted) } + runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) } } fun delete(task: Task) = viewModelScope.launch { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt new file mode 100644 index 0000000..7ce564a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt @@ -0,0 +1,295 @@ +package de.jeanlucmakiola.agendula.ui.lists + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.ui.common.DefaultListColor +import de.jeanlucmakiola.agendula.ui.common.ListColorChip +import de.jeanlucmakiola.agendula.ui.common.ListPalette +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedSurface +import de.jeanlucmakiola.floret.components.InlineTextField +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.pastelize + +private const val SWATCHES_PER_ROW = 6 + +/** + * Create or edit a task list: a name field over the palette of list colours, on + * the family's full-screen sheet with the commit in its title bar. + * + * [initial] null is the create case. [onDelete] is null for a list the app must + * not remove — an account's collection belongs to its server — which is also why + * the destructive row only appears when it is non-null. + */ +@Composable +fun ListEditorSheet( + initial: TaskList?, + onSave: (name: String, color: Int) -> Unit, + onDismiss: () -> Unit, + onDelete: (() -> Unit)? = null, +) { + var name by rememberSaveable(initial?.id) { mutableStateOf(initial?.name.orEmpty()) } + var color by rememberSaveable(initial?.id) { mutableIntStateOf(initial?.color ?: DefaultListColor) } + var confirmDelete by rememberSaveable { mutableStateOf(false) } + + val valid = name.isNotBlank() + val commit = { + if (valid) { + onSave(name.trim(), color) + onDismiss() + } + } + + FullScreenPicker( + title = stringResource(if (initial == null) R.string.list_new_title else R.string.list_edit_title), + onDismiss = onDismiss, + actions = { + Button( + onClick = commit, + enabled = valid, + modifier = Modifier.padding(end = 12.dp), + ) { Text(stringResource(R.string.save)) } + }, + ) { + NameField( + name = name, + color = color, + // A new list opens with the keyboard up: naming it is the whole task. + autoFocus = initial == null, + onNameChange = { name = it }, + onImeAction = commit, + ) + + Spacer(Modifier.height(20.dp)) + SectionLabel(stringResource(R.string.list_color)) + ColorGrid(selected = color, onSelect = { color = it }) + + if (onDelete != null) { + Spacer(Modifier.height(24.dp)) + DeleteRow(onClick = { confirmDelete = true }) + } + Spacer(Modifier.height(24.dp)) + } + + if (confirmDelete && onDelete != null) { + DeleteListDialog( + listName = initial?.name.orEmpty(), + onConfirm = { + confirmDelete = false + onDelete() + onDismiss() + }, + onDismiss = { confirmDelete = false }, + ) + } +} + +/** The name, with the chosen colour beside it so the two read as one thing. */ +@Composable +private fun NameField( + name: String, + color: Int, + autoFocus: Boolean, + onNameChange: (String) -> Unit, + onImeAction: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() } + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + ListColorChip(color) + InlineTextField( + value = name, + onValueChange = onNameChange, + placeholder = stringResource(R.string.list_name_hint), + imeAction = ImeAction.Done, + onImeAction = onImeAction, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + ) + } + } +} + +/** The palette as two rows of round swatches; the chosen one carries a check. */ +@Composable +private fun ColorGrid(selected: Int, onSelect: (Int) -> Unit) { + val dark = isSystemInDarkTheme() + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ListPalette.chunked(SWATCHES_PER_ROW).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + row.forEach { swatch -> + Swatch( + color = swatch, + dark = dark, + selected = swatch == selected, + onClick = { onSelect(swatch) }, + modifier = Modifier.weight(1f), + ) + } + // Keeps a short final row's swatches at the same size as a full + // one's rather than stretching them across the width. + repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } + } +} + +@Composable +private fun Swatch( + color: Int, + dark: Boolean, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val fill = pastelize(color, dark) + val label = stringResource(colorLabel(color)) + Box( + modifier = modifier + .aspectRatio(1f) + .clip(CircleShape) + .background(fill) + // selectable, not clickable: the swatch carries its chosen state in + // semantics, so the check below is decoration rather than the only cue. + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + Icons.Rounded.Check, + contentDescription = null, + tint = if (fill.luminance() > 0.5f) Color.Black else Color.White, + modifier = Modifier.size(22.dp), + ) + } + } +} + +@Composable +private fun DeleteRow(onClick: () -> Unit) { + GroupedSurface( + position = Position.Alone, + modifier = Modifier.padding(horizontal = 16.dp), + onClick = onClick, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.Rounded.DeleteOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringResource(R.string.list_delete), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } +} + +@Composable +private fun DeleteListDialog(listName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.list_delete_confirm_title)) }, + text = { Text(stringResource(R.string.list_delete_confirm_message, listName)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp), + ) +} + +/** Names the palette entries for screen readers; anything else is just "colour". */ +private fun colorLabel(color: Int): Int = when (ListPalette.indexOf(color)) { + 0 -> R.string.list_color_mauve + 1 -> R.string.list_color_red + 2 -> R.string.list_color_orange + 3 -> R.string.list_color_amber + 4 -> R.string.list_color_olive + 5 -> R.string.list_color_green + 6 -> R.string.list_color_teal + 7 -> R.string.list_color_cyan + 8 -> R.string.list_color_blue + 9 -> R.string.list_color_indigo + 10 -> R.string.list_color_purple + 11 -> R.string.list_color_pink + else -> R.string.list_color +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index b3f7439..fa5862a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.material.icons.rounded.Upcoming import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -80,6 +81,10 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.ui.common.ActionShapes import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SnackChip +import de.jeanlucmakiola.floret.components.SnackChipHeight +import de.jeanlucmakiola.floret.components.SnackChipMargin +import kotlinx.coroutines.delay import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton import de.jeanlucmakiola.agendula.ui.common.priorityAccent @@ -108,10 +113,22 @@ fun ListsScreen( val state by viewModel.state.collectAsStateWithLifecycle() var query by rememberSaveable { mutableStateOf("") } var searchActive by rememberSaveable { mutableStateOf(false) } + var newList by rememberSaveable { mutableStateOf(false) } val closeSearch = { query = "" searchActive = false } + // With no lists there is nowhere to put a task, so the primary action becomes + // making one — otherwise a fresh install's FAB opens a form that cannot save. + val noLists = (state as? ListsUiState.Content)?.groups?.isEmpty() == true + // The sheet closes on save, so a refused write has to report itself here. + val writeFailure by viewModel.writeFailure.collectAsStateWithLifecycle() + LaunchedEffect(writeFailure) { + if (writeFailure != null) { + delay(4_000) + viewModel.clearWriteFailure() + } + } // System back closes search before leaving the screen. BackHandler(enabled = searchActive, onBack = closeSearch) @@ -130,9 +147,11 @@ fun ListsScreen( // The FAB would otherwise float over the search results. if (!searchActive) { ExtendedFloatingActionButton( - onClick = onNewTask, + onClick = if (noLists) ({ newList = true }) else onNewTask, icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.new_task)) }, + text = { + Text(stringResource(if (noLists) R.string.list_add else R.string.new_task)) + }, ) } }, @@ -147,6 +166,7 @@ fun ListsScreen( state = s, onOpenFilter = onOpenFilter, onOpenTask = onOpenTask, + onNewList = { newList = true }, topPadding = 0.dp, bottomPadding = inner.calculateBottomPadding() + 96.dp, ) @@ -166,8 +186,33 @@ fun ListsScreen( } } } + // Anchored beside the FAB, at its height — the same receipt placement + // the task list uses. + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding( + start = SnackChipMargin, + bottom = inner.calculateBottomPadding() + SnackChipMargin, + ) + .height(SnackChipHeight), + contentAlignment = Alignment.CenterStart, + ) { + SnackChip( + visible = writeFailure != null, + message = stringResource(R.string.list_save_failed), + ) + } } } + + if (newList) { + ListEditorSheet( + initial = null, + onSave = viewModel::createList, + onDismiss = { newList = false }, + ) + } } @Composable @@ -175,6 +220,7 @@ private fun ListsContent( state: ListsUiState.Content, onOpenFilter: (TaskFilter) -> Unit, onOpenTask: (Long) -> Unit, + onNewList: () -> Unit, topPadding: androidx.compose.ui.unit.Dp, bottomPadding: androidx.compose.ui.unit.Dp, ) { @@ -215,9 +261,21 @@ private fun ListsContent( } if (state.groups.isEmpty()) { - item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) } + item { EmptyLists(onNewList = onNewList) } } else { - item { SectionHeader(stringResource(R.string.lists_header)) } + item { + SectionHeader( + text = stringResource(R.string.lists_header), + action = { + ShapedActionButton( + shape = ActionShapes.AddList, + icon = Icons.Rounded.Add, + contentDescription = stringResource(R.string.list_add), + onClick = onNewList, + ) + }, + ) + } state.groups.forEach { group -> item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) } itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview -> @@ -243,6 +301,28 @@ private fun ListsContent( } } +/** No lists at all — a fresh install, where nothing else on this screen works yet. */ +@Composable +private fun EmptyLists(onNewList: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.lists_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + FilledTonalButton(onClick = onNewList) { + Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.lists_empty_action)) + } + } +} + /** * The home top bar. There is no title — the launcher icon already says which app * this is. Settings is pinned at the right; the search action sits just left of it @@ -655,12 +735,25 @@ private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick: } @Composable -private fun SectionHeader(text: String) { - Text( - text = text, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp), - ) +private fun SectionHeader(text: String, action: (@Composable () -> Unit)? = null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 28.dp, + end = if (action == null) 28.dp else 20.dp, + top = if (action == null) 16.dp else 12.dp, + bottom = 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + action?.invoke() + } } @Composable diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index d6b8aeb..0215035 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -11,14 +11,23 @@ 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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import java.time.ZoneId import javax.inject.Inject import kotlin.time.Clock +/** + * What a list write failed at. The screens turn this into wording; the view + * models stay free of resources. + */ +enum class ListWriteFailure { SAVE, DELETE } + data class ListOverview(val list: TaskList, val openCount: Int) data class AccountGroup(val accountName: String, val lists: List) data class SmartCount(val smart: SmartList, val count: Int) @@ -44,7 +53,7 @@ private const val UPCOMING_PREVIEW = 3 /** The home overview: smart lists with live counts, then user lists by account. */ @HiltViewModel class ListsViewModel @Inject constructor( - repository: TasksRepository, + private val repository: TasksRepository, ) : ViewModel() { val state: StateFlow = @@ -112,4 +121,22 @@ class ListsViewModel @Inject constructor( allTasks = openTasks + completedTasks, ) } + + private val _writeFailure = MutableStateFlow(null) + + /** Set when a list write is refused; the screen shows it and clears it. */ + val writeFailure: StateFlow = _writeFailure.asStateFlow() + + fun clearWriteFailure() { _writeFailure.value = null } + + /** + * Create a device-only list. The lists flow picks it up on the store change; + * a refusal (External mode, a provider that says no) surfaces through + * [writeFailure] rather than vanishing, because the sheet has already closed. + */ + fun createList(name: String, color: Int) = viewModelScope.launch { + if (name.isBlank()) return@launch + runCatching { repository.createLocalList(name.trim(), color) } + .onFailure { _writeFailure.value = ListWriteFailure.SAVE } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index 4294383..5bcca82 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Delete +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 @@ -97,6 +98,8 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.agendula.ui.common.priorityAccent +import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet +import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.SnackChip import de.jeanlucmakiola.floret.components.SnackChipHeight @@ -126,8 +129,22 @@ fun TaskListScreen( val state by viewModel.state.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val content = state as? TaskListUiState.Content - val listName = content?.listName + val list = content?.list + val listName = list?.name val listId = (filter as? TaskFilter.OfList)?.listId + // Editing is offered for a device-only list. A collection that belongs to an + // account is the server's to rename or remove, not ours. + var editingList by rememberSaveable { mutableStateOf(false) } + val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle() + val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle() + // The list this screen is about is gone; there is nothing left to show. + LaunchedEffect(listDeleted) { if (listDeleted) onBack() } + LaunchedEffect(listWriteFailure) { + if (listWriteFailure != null) { + delay(4_000) + viewModel.clearListWriteFailure() + } + } // One add affordance, never two: a real list with the setting on gets a pinned // bottom quick-add bar; everything else (incl. smart lists, which have no single // target list) gets the floating "New task" button. @@ -162,6 +179,16 @@ fun TaskListScreen( ) } }, + actions = { + if (list != null && list.isLocal) { + IconButton(onClick = { editingList = true }) { + Icon( + Icons.Rounded.Edit, + contentDescription = stringResource(R.string.list_edit_title), + ) + } + } + }, scrollBehavior = scrollBehavior, ) }, @@ -199,18 +226,43 @@ fun TaskListScreen( .height(SnackChipHeight), contentAlignment = Alignment.CenterStart, ) { - SnackChip( - visible = undoTarget != null, - message = stringResource(R.string.task_deleted), - actionLabel = stringResource(R.string.undo), - onAction = { - undoTarget?.let { viewModel.undoDelete(it.taskId) } - undoTarget = null - }, - ) + // One chip, one anchor: the undo receipt takes precedence, and a + // refused list write reports itself once the undo window is clear. + val failure = listWriteFailure + if (undoTarget != null || failure == null) { + SnackChip( + visible = undoTarget != null, + message = stringResource(R.string.task_deleted), + actionLabel = stringResource(R.string.undo), + onAction = { + undoTarget?.let { viewModel.undoDelete(it.taskId) } + undoTarget = null + }, + ) + } else { + SnackChip( + visible = true, + message = stringResource(listWriteFailureMessage(failure)), + ) + } } } } + + if (editingList && list != null) { + ListEditorSheet( + initial = list, + onSave = { name, color -> viewModel.updateList(list.id, name, color) }, + onDismiss = { editingList = false }, + onDelete = { viewModel.deleteList(list.id) }, + ) + } +} + +/** Wording for a refused list write. */ +private fun listWriteFailureMessage(failure: ListWriteFailure): Int = when (failure) { + ListWriteFailure.SAVE -> R.string.list_save_failed + ListWriteFailure.DELETE -> R.string.list_delete_failed } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index 4287b7e..3729555 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -9,10 +9,13 @@ import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest @@ -28,13 +31,13 @@ sealed interface TaskListUiState { data object Failure : TaskListUiState /** - * [listName] is the real list's name when the filter is a - * [TaskFilter.OfList] (for the top-bar title), `null` for smart lists — - * the screen falls back to the smart label in that case. + * [list] is the real list when the filter is a [TaskFilter.OfList] — it + * titles the bar and backs the edit action — and `null` for smart lists, + * where the screen falls back to the smart label. */ data class Content( val tasks: List, - val listName: String? = null, + val list: TaskList? = null, /** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */ val showAddSubtaskRow: Boolean = true, /** Whether a real list uses the bottom quick-add bar instead of the FAB. */ @@ -65,8 +68,8 @@ class TaskListViewModel @Inject constructor( val tasks = repository.tasks(f) val content: kotlinx.coroutines.flow.Flow = when (f) { is TaskFilter.OfList -> - combine(tasks, repository.taskLists()) { list, lists -> - TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name) + combine(tasks, repository.taskLists()) { rows, lists -> + TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId }) } is TaskFilter.Smart -> tasks.map { TaskListUiState.Content(it) } @@ -126,7 +129,7 @@ class TaskListViewModel @Inject constructor( fun bind(taskFilter: TaskFilter) { filter.value = taskFilter } fun toggleComplete(task: Task) = viewModelScope.launch { - runCatching { repository.setCompleted(task.taskId, !task.isCompleted) } + runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) } } /** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */ @@ -155,6 +158,36 @@ class TaskListViewModel @Inject constructor( runCatching { repository.createTask(TaskForm(title = title, listId = listId)) } } + private val _listWriteFailure = MutableStateFlow(null) + + /** Set when a list write is refused; the screen shows it and clears it. */ + val listWriteFailure: StateFlow = _listWriteFailure.asStateFlow() + + private val _listDeleted = MutableStateFlow(false) + + /** Flips once the list this screen shows is really gone, so it can leave. */ + val listDeleted: StateFlow = _listDeleted.asStateFlow() + + fun clearListWriteFailure() { _listWriteFailure.value = null } + + /** Rename / recolour the list this screen is showing. */ + fun updateList(listId: Long, name: String, color: Int) = viewModelScope.launch { + if (name.isBlank()) return@launch + runCatching { repository.updateList(listId, name.trim(), color) } + .onFailure { _listWriteFailure.value = ListWriteFailure.SAVE } + } + + /** + * Delete the list **and its tasks**. The screen navigates away on + * [listDeleted], not on the call — leaving first would strand a refusal on a + * screen that no longer exists. + */ + fun deleteList(listId: Long) = viewModelScope.launch { + runCatching { repository.deleteList(listId) } + .onSuccess { _listDeleted.value = true } + .onFailure { _listWriteFailure.value = ListWriteFailure.DELETE } + } + /** Inline "add subtask" from an expanded list group — files it under [parent]. */ fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch { if (title.isBlank() || parent.listId <= 0L) return@launch diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f8b7cd..1a6beca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,7 +122,32 @@ Lists New task Could not read your tasks. - No task lists yet. Add one in your tasks app or with the + button. + No task lists yet. + Create a list + + + New list + New list + Edit list + List name + Colour + Delete list + Could not save the list. + Could not delete the list. + Delete list? + “%1$s” and all of its tasks will be deleted. This can\'t be undone. + Mauve + Red + Orange + Amber + Olive + Green + Teal + Cyan + Blue + Indigo + Purple + Pink Today Overdue Upcoming diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt index 6f4e129..9d7a988 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -164,6 +164,25 @@ class ProviderResolverTest { assertThat(resolver.resolve()?.packageName).isEqualTo("org.tasks") } + @Test + fun `a mode change notifies listeners once, and only on a real change`() { + // What store observers hang off: a live flow is bound to one store, so + // it has to be told when the store underneath it is swapped. + val resolver = resolver(mode = StorageMode.OWN) + var fired = 0 + val handle = resolver.onModeChanged { fired++ } + + resolver.storageMode = StorageMode.OWN + assertThat(fired).isEqualTo(0) + + resolver.storageMode = StorageMode.EXTERNAL + assertThat(fired).isEqualTo(1) + + handle.close() + resolver.storageMode = StorageMode.OWN + assertThat(fired).isEqualTo(1) + } + @Test fun `never name an authority of ours`() { // EXTERNAL must mean "somebody else's store", and Agendula publishes no diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt index 2c08ac6..bb98ffc 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt @@ -164,6 +164,45 @@ class RecurrenceExpanderTest { assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z") } + @Test + fun `a sub-daily series spends most of the ceiling on occurrences from the pivot on`() { + // The real shape: an eight-hourly task, a year of window behind now and a + // 500-occurrence ceiling. Filling the budget from the window start would + // exhaust it ~166 days before now, so the task would never appear in Today + // or Upcoming at all. + val result = expand( + spec(rrule = "FREQ=HOURLY;INTERVAL=8", anchor = "2024-01-01T00:00:00Z"), + ExpansionWindow( + from = at("2024-06-01T00:00:00Z"), + until = at("2026-06-01T00:00:00Z"), + maxOccurrences = 500, + pivot = at("2025-06-01T00:00:00Z"), + ), + ) + assertThat(result).hasSize(500) + // A quarter of the budget looks back, and it keeps the *most recent* of + // the past — not the oldest, which is what filling from the window start + // would have kept. (ISO-8601 UTC sorts lexicographically.) + assertThat(result.count { it < "2025-06-01T00:00:00Z" }).isEqualTo(125) + assertThat(result.first()).isGreaterThan("2025-04-01T00:00:00Z") + assertThat(result.last()).isGreaterThan("2025-09-01T00:00:00Z") + } + + @Test + fun `the ceiling goes entirely to the future when nothing precedes the pivot`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + ExpansionWindow( + from = at("2025-01-01T00:00:00Z"), + until = at("2030-01-01T00:00:00Z"), + maxOccurrences = 4, + pivot = at("2025-01-01T00:00:00Z"), + ), + ) + assertThat(result).hasSize(4) + assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z") + } + @Test fun `an unbounded rule stops at the window end`() { val result = expand( diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4e55669..a73b09f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -207,6 +207,18 @@ most of it broken, absent or unusable. Reasoning in - ✅ Fallout: `ReminderScheduler.sync()` gated on `resolve() != null`, which is what `OWN` returns, so no due reminder armed in the default mode. It now gates on `ProviderResolver.canReadStore()`, with tests. +- ✅ Fallout, second pass — four defects a review of the branch turned up: + **completing one occurrence closed the whole series** (`setCompleted` wrote the + master, which is the row `TaskDao.tasks` filters on, so every occurrence left + every list); the **expansion ceiling was spent on the past**, so a sub-daily + series stopped expanding months before today and never reached Today or + Upcoming; an imported **`START`-referenced reminder fired off `DUE`**, because + the seam collapsed alarms to a bare minute count; and `registerObserver` bound + a live flow to whichever store was active at subscription, so a Settings + store switch would have left every screen listening to the store it had + stopped reading. `setCompletedInstance` now forks a `RECURRENCE-ID` override + the way `updateInstance` does — phase 2 always specified this, only the edit + half had it. - ⬜ **Run the instrumented suite on a device.** Six classes — the Room seam, the DAOs, the import, the migration harness, the restore path and the performance check — all compile and none has ever executed. Everything load-bearing about @@ -215,6 +227,21 @@ most of it broken, absent or unusable. Reasoning in v0.3.2 APK with seeded data landing every task, list and reminder. - ⬜ Per-locale release notes for the dropped authority and permissions. +### ✅ Managing lists in the app +Owning the store made this mandatory: there is no longer a provider app to +create a list in, so a fresh install had no lists, no way to make one, and +therefore no way to save a task. The seam gained `updateList` / `deleteList` +beside the existing `createLocalList`, implemented on both the Room and the +External path (which addresses the row as its own account's sync adapter, the +only caller the provider lets write `tasklists`). +- ✅ `ListEditorSheet` — the family's full-screen sheet with a name field, the + 12-colour palette and, when editing, a destructive row behind a confirm. +- ✅ Entry points: a "New list" row under the home Lists section, a real empty + state with a create button, and the home FAB switching to "New list" while + there are none. Editing is the pencil in a list's own top bar. +- ✅ Deleting a list deletes its tasks — `tasks.list_id` cascades — and is + offered only for device-only lists; an account's collection is its server's. + --- ## Open decisions / to verify