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:
@@ -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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user