feat(lists): manage lists in the app, and fix four store defects

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
This commit is contained in:
2026-09-04 13:46:05 +02:00
parent faee90f8b1
commit fcee1d1736
25 changed files with 1067 additions and 79 deletions

View File

@@ -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"))

View File

@@ -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 {

View File

@@ -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

View File

@@ -170,9 +170,13 @@ class AndroidTasksDataSource @Inject constructor(
}
}
override fun alarms(): Map<Long, Int> {
override fun alarms(): Map<Long, TaskReminder> {
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 {

View File

@@ -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<Long, Int> = active().alarms()
override fun alarms(): Map<Long, TaskReminder> = active().alarms()
override fun exportTasks(listId: Long): List<ExportTask> = 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
}
}
}
}

View File

@@ -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()

View File

@@ -107,9 +107,13 @@ object TaskWriteMapper {
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
)
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf(
/** 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(),
Lists.COLOR to color,
)
fun localListValues(name: String, color: Int): Map<String, Any?> = listValues(name, color) + mapOf(
Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
Lists.VISIBLE to 1,

View File

@@ -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}")

View File

@@ -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<Long, Int>
/** Every task's reminder, by task id. One query, for the scheduler. */
fun alarms(): Map<Long, TaskReminder>
/**
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
@@ -56,9 +66,35 @@ interface TasksDataSource {
fun exportTasks(listId: Long): List<de.jeanlucmakiola.agendula.domain.export.ExportTask>
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
}

View File

@@ -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

View File

@@ -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 —

View File

@@ -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<ExportTask> =
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
override fun alarms(): Map<Long, Int> =
alarms.all().associate { it.taskId to it.minutesBefore }
override fun alarms(): Map<Long, TaskReminder> =
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<TaskEntity>, now: Instant): List<Task> {
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<Instant?, Instant?> {
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 {

View File

@@ -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<Instant>()
// 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<Instant>()
val future = ArrayList<Instant>()
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
}
/**

View File

@@ -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<Int> = 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()

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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<ListOverview>)
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<ListsUiState> =
@@ -112,4 +121,22 @@ class ListsViewModel @Inject constructor(
allTasks = openTasks + completedTasks,
)
}
private val _writeFailure = MutableStateFlow<ListWriteFailure?>(null)
/** Set when a list write is refused; the screen shows it and clears it. */
val writeFailure: StateFlow<ListWriteFailure?> = _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 }
}
}

View File

@@ -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)

View File

@@ -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<Task>,
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<TaskListUiState> = 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<ListWriteFailure?>(null)
/** Set when a list write is refused; the screen shows it and clears it. */
val listWriteFailure: StateFlow<ListWriteFailure?> = _listWriteFailure.asStateFlow()
private val _listDeleted = MutableStateFlow(false)
/** Flips once the list this screen shows is really gone, so it can leave. */
val listDeleted: StateFlow<Boolean> = _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

View File

@@ -122,7 +122,32 @@
<string name="lists_header">Lists</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. Add one in your tasks app or with the + button.</string>
<string name="lists_empty">No task lists yet.</string>
<string name="lists_empty_action">Create a list</string>
<!-- Task lists: create, edit, delete -->
<string name="list_add">New list</string>
<string name="list_new_title">New list</string>
<string name="list_edit_title">Edit list</string>
<string name="list_name_hint">List name</string>
<string name="list_color">Colour</string>
<string name="list_delete">Delete list</string>
<string name="list_save_failed">Could not save the list.</string>
<string name="list_delete_failed">Could not delete the list.</string>
<string name="list_delete_confirm_title">Delete list?</string>
<string name="list_delete_confirm_message">“%1$s” and all of its tasks will be deleted. This can\'t be undone.</string>
<string name="list_color_mauve">Mauve</string>
<string name="list_color_red">Red</string>
<string name="list_color_orange">Orange</string>
<string name="list_color_amber">Amber</string>
<string name="list_color_olive">Olive</string>
<string name="list_color_green">Green</string>
<string name="list_color_teal">Teal</string>
<string name="list_color_cyan">Cyan</string>
<string name="list_color_blue">Blue</string>
<string name="list_color_indigo">Indigo</string>
<string name="list_color_purple">Purple</string>
<string name="list_color_pink">Pink</string>
<string name="smart_today">Today</string>
<string name="smart_overdue">Overdue</string>
<string name="smart_upcoming">Upcoming</string>

View File

@@ -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

View File

@@ -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(