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")
|
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
|
@Test
|
||||||
fun createsAndReadsBackANonRecurringTask() {
|
fun createsAndReadsBackANonRecurringTask() {
|
||||||
val due = now + 1.days
|
val due = now + 1.days
|
||||||
@@ -143,6 +168,59 @@ class RoomTasksDataSourceTest {
|
|||||||
assertThat(override.title).isEqualTo("Water them twice")
|
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
|
@Test
|
||||||
fun anOverrideReplacesOnlyItsOwnOccurrence() {
|
fun anOverrideReplacesOnlyItsOwnOccurrence() {
|
||||||
val id = source.insertTask(form(title = "Water the plants"))
|
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))
|
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)))
|
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 {
|
private companion object {
|
||||||
|
|||||||
@@ -59,12 +59,18 @@ class ReminderScheduler @Inject constructor(
|
|||||||
// A reminder set on the task itself wins; otherwise the task's list
|
// A reminder set on the task itself wins; otherwise the task's list
|
||||||
// may override the global lead, or opt out entirely (override =
|
// may override the global lead, or opt out entirely (override =
|
||||||
// null), in which case it gets no reminder at all.
|
// 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)
|
?: settings.reminderLeadFor(task.listId)
|
||||||
?: return@mapNotNull null
|
?: 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(
|
ScheduledReminder(
|
||||||
taskId = task.taskId,
|
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
|
// 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 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(
|
return resolver.query(
|
||||||
uri,
|
uri,
|
||||||
projection,
|
projection,
|
||||||
@@ -185,7 +189,16 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
val id = reader.getLong(Properties.TASK_ID)
|
val id = reader.getLong(Properties.TASK_ID)
|
||||||
val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE)
|
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()
|
} ?: emptyMap()
|
||||||
@@ -197,6 +210,20 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
|
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) {
|
override fun deleteTask(taskId: Long) {
|
||||||
resolver.delete(taskUri(authority(), taskId), null, null)
|
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")
|
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 ----------------------------------------------------------
|
// --- observation ----------------------------------------------------------
|
||||||
|
|
||||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||||
|
|||||||
@@ -41,10 +41,48 @@ class ModeRoutingTasksDataSource(
|
|||||||
active().updateInstance(taskId, occurrenceStart, form)
|
active().updateInstance(taskId, occurrenceStart, form)
|
||||||
|
|
||||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
|
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 exportTasks(listId: Long): List<ExportTask> = active().exportTasks(listId)
|
||||||
override fun setCompleted(taskId: Long, completed: Boolean) = active().setCompleted(taskId, completed)
|
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 deleteTask(taskId: Long) = active().deleteTask(taskId)
|
||||||
override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color)
|
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
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -31,15 +32,35 @@ class ProviderResolver @Inject constructor(
|
|||||||
private val environment: ProviderEnvironment,
|
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 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
|
* 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
|
* rather than read from DataStore on demand because [resolve] is called from
|
||||||
* synchronous data-source code on every query, including from the main thread
|
* synchronous data-source code on every query, including from the main thread
|
||||||
* via `providerStatus()`. [StorageModeHolder] owns keeping it current.
|
* 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
|
@Volatile
|
||||||
var storageMode: StorageMode? = null
|
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]. */
|
/** The active store, resolving the undecided case through [autoMode]. */
|
||||||
fun mode(): StorageMode = storageMode ?: autoMode()
|
fun mode(): StorageMode = storageMode ?: autoMode()
|
||||||
|
|||||||
@@ -107,9 +107,13 @@ object TaskWriteMapper {
|
|||||||
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
|
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.NAME to name.trim(),
|
||||||
Lists.COLOR to color,
|
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_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
|
||||||
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
|
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
|
||||||
Lists.VISIBLE to 1,
|
Lists.VISIBLE to 1,
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ object TasksContract {
|
|||||||
|
|
||||||
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
|
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
|
||||||
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
|
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 tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
|
||||||
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.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 de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import kotlin.time.Instant
|
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. */
|
/** What to fetch from the provider. Smart-list date logic is applied above this. */
|
||||||
data class TaskQuery(
|
data class TaskQuery(
|
||||||
val listId: Long? = null,
|
val listId: Long? = null,
|
||||||
@@ -45,8 +55,8 @@ interface TasksDataSource {
|
|||||||
*/
|
*/
|
||||||
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
|
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
|
||||||
|
|
||||||
/** Every task's reminder lead, by task id. One query, for the scheduler. */
|
/** Every task's reminder, by task id. One query, for the scheduler. */
|
||||||
fun alarms(): Map<Long, Int>
|
fun alarms(): Map<Long, TaskReminder>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
|
* 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 exportTasks(listId: Long): List<de.jeanlucmakiola.agendula.domain.export.ExportTask>
|
||||||
|
|
||||||
fun setCompleted(taskId: Long, completed: Boolean)
|
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 deleteTask(taskId: Long)
|
||||||
fun createLocalList(name: String, color: Int): 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. */
|
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
|
||||||
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ interface TasksRepository {
|
|||||||
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
|
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
|
||||||
*/
|
*/
|
||||||
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
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)
|
suspend fun deleteTask(taskId: Long)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +52,10 @@ interface TasksRepository {
|
|||||||
*/
|
*/
|
||||||
suspend fun reminderFor(taskId: Long): Int?
|
suspend fun reminderFor(taskId: Long): Int?
|
||||||
suspend fun createLocalList(name: String, color: Int): Long
|
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. */
|
/** Synchronous snapshot for the permission/onboarding gate. */
|
||||||
fun providerStatus(): ProviderStatus
|
fun providerStatus(): ProviderStatus
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun reminderFor(taskId: Long): Int? =
|
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?) =
|
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
@@ -117,8 +117,14 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
|
override suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) =
|
||||||
withContext(io) { dataSource.setCompleted(taskId, completed) }
|
withContext(io) {
|
||||||
|
if (occurrenceStart != null) {
|
||||||
|
dataSource.setCompletedInstance(taskId, occurrenceStart, completed)
|
||||||
|
} else {
|
||||||
|
dataSource.setCompleted(taskId, completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun deleteTask(taskId: Long) =
|
override suspend fun deleteTask(taskId: Long) =
|
||||||
withContext(io) { dataSource.deleteTask(taskId) }
|
withContext(io) { dataSource.deleteTask(taskId) }
|
||||||
@@ -126,6 +132,12 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun createLocalList(name: String, color: Int): Long =
|
override suspend fun createLocalList(name: String, color: Int): Long =
|
||||||
withContext(io) { dataSource.createLocalList(name, color) }
|
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 {
|
override fun providerStatus(): ProviderStatus {
|
||||||
// Our own store is always ready: it ships with the app, needs no provider
|
// 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 —
|
// 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 androidx.room.InvalidationTracker
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
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.TaskWriteFailedException
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
@@ -74,8 +75,13 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
override fun exportTasks(listId: Long): List<ExportTask> =
|
override fun exportTasks(listId: Long): List<ExportTask> =
|
||||||
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
||||||
|
|
||||||
override fun alarms(): Map<Long, Int> =
|
override fun alarms(): Map<Long, TaskReminder> =
|
||||||
alarms.all().associate { it.taskId to it.minutesBefore }
|
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
|
* 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> {
|
private fun occurrencesOf(row: TaskRow, overrides: List<TaskEntity>, now: Instant): List<Task> {
|
||||||
val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row))
|
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)
|
val anchors = RecurrenceExpander.expand(spec, window)
|
||||||
if (anchors.isEmpty()) return emptyList()
|
if (anchors.isEmpty()) return emptyList()
|
||||||
|
|
||||||
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
|
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
|
||||||
val byAnchor = overrides.associateBy { it.recurrenceId }
|
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 ->
|
return anchors.mapIndexedNotNull { index, anchor ->
|
||||||
val override = byAnchor[anchor]
|
val override = byAnchor[anchor]
|
||||||
when {
|
if (override != null) {
|
||||||
override != null -> RoomTaskMapper.task(
|
RoomTaskMapper.task(
|
||||||
row = row.copy(task = override),
|
row = row.copy(task = override),
|
||||||
occurrenceStart = anchor,
|
occurrenceStart = anchor,
|
||||||
start = override.dtstart,
|
start = override.dtstart,
|
||||||
due = override.due,
|
due = override.due,
|
||||||
distanceFromCurrent = distances[index],
|
distanceFromCurrent = distances[index],
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
row.task.dtstart != null -> RoomTaskMapper.task(
|
val (start, due) = occurrenceTimes(row.task, anchor)
|
||||||
|
RoomTaskMapper.task(
|
||||||
row = row,
|
row = row,
|
||||||
occurrenceStart = anchor,
|
occurrenceStart = anchor,
|
||||||
start = anchor,
|
start = start,
|
||||||
due = length?.let { anchor + it },
|
due = due,
|
||||||
distanceFromCurrent = distances[index],
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> RoomTaskMapper.task(
|
|
||||||
row = row,
|
|
||||||
occurrenceStart = anchor,
|
|
||||||
start = null,
|
|
||||||
due = anchor,
|
|
||||||
distanceFromCurrent = distances[index],
|
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 ---------------------------------------------------------------
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
override fun insertTask(form: TaskForm): Long {
|
override fun insertTask(form: TaskForm): Long {
|
||||||
@@ -154,17 +165,9 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
tasks.update(TaskFormWriter.apply(existing, form, now, zone()))
|
tasks.update(TaskFormWriter.apply(existing, form, now, zone()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||||
val fork = TaskFormWriter.apply(
|
val fork = TaskFormWriter.apply(
|
||||||
master.copy(
|
newOverride(master, taskId, occurrenceStart, start, due),
|
||||||
id = 0,
|
|
||||||
masterId = taskId,
|
|
||||||
recurrenceId = occurrenceStart,
|
|
||||||
rrule = null,
|
|
||||||
rdate = null,
|
|
||||||
exdate = null,
|
|
||||||
href = null,
|
|
||||||
etag = null,
|
|
||||||
),
|
|
||||||
form,
|
form,
|
||||||
now,
|
now,
|
||||||
zone(),
|
zone(),
|
||||||
@@ -187,6 +190,54 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
|
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
|
* 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
|
* 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 =
|
override fun createLocalList(name: String, color: Int): Long =
|
||||||
lists.insert(TaskListEntity(name = name.trim(), color = color))
|
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 ----------------------------------------------------------
|
// --- observation ----------------------------------------------------------
|
||||||
|
|
||||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||||
|
|||||||
@@ -27,11 +27,19 @@ data class RecurrenceSpec(
|
|||||||
/**
|
/**
|
||||||
* The window expansion is bounded to: [from] inclusive, [until] exclusive, and
|
* The window expansion is bounded to: [from] inclusive, [until] exclusive, and
|
||||||
* never more than [maxOccurrences] results — so an unbounded `RRULE` terminates.
|
* 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(
|
data class ExpansionWindow(
|
||||||
val from: Instant,
|
val from: Instant,
|
||||||
val until: Instant,
|
val until: Instant,
|
||||||
val maxOccurrences: Int = 500,
|
val maxOccurrences: Int = 500,
|
||||||
|
val pivot: Instant = from,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,15 +82,28 @@ object RecurrenceExpander {
|
|||||||
val iterator = set.iterator(zone, anchorMillis, window.until.toEpochMilliseconds())
|
val iterator = set.iterator(zone, anchorMillis, window.until.toEpochMilliseconds())
|
||||||
iterator.fastForward(window.from.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
|
var previous = Long.MIN_VALUE
|
||||||
while (occurrences.size < window.maxOccurrences && iterator.hasNext()) {
|
while (iterator.hasNext()) {
|
||||||
val millis = iterator.next()
|
val millis = iterator.next()
|
||||||
if (millis == previous) continue
|
if (millis == previous) continue
|
||||||
previous = millis
|
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. */
|
/** Search — a 6-sided cookie, the same family as [Settings] but distinct. */
|
||||||
val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided
|
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 bind(id: Long) { taskId.value = id }
|
||||||
|
|
||||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
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 {
|
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.CircularWavyProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
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.ListColorChip
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
||||||
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||||
@@ -108,10 +113,22 @@ fun ListsScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
var query by rememberSaveable { mutableStateOf("") }
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var newList by rememberSaveable { mutableStateOf(false) }
|
||||||
val closeSearch = {
|
val closeSearch = {
|
||||||
query = ""
|
query = ""
|
||||||
searchActive = false
|
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.
|
// System back closes search before leaving the screen.
|
||||||
BackHandler(enabled = searchActive, onBack = closeSearch)
|
BackHandler(enabled = searchActive, onBack = closeSearch)
|
||||||
|
|
||||||
@@ -130,9 +147,11 @@ fun ListsScreen(
|
|||||||
// The FAB would otherwise float over the search results.
|
// The FAB would otherwise float over the search results.
|
||||||
if (!searchActive) {
|
if (!searchActive) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = onNewTask,
|
onClick = if (noLists) ({ newList = true }) else onNewTask,
|
||||||
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
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,
|
state = s,
|
||||||
onOpenFilter = onOpenFilter,
|
onOpenFilter = onOpenFilter,
|
||||||
onOpenTask = onOpenTask,
|
onOpenTask = onOpenTask,
|
||||||
|
onNewList = { newList = true },
|
||||||
topPadding = 0.dp,
|
topPadding = 0.dp,
|
||||||
bottomPadding = inner.calculateBottomPadding() + 96.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
|
@Composable
|
||||||
@@ -175,6 +220,7 @@ private fun ListsContent(
|
|||||||
state: ListsUiState.Content,
|
state: ListsUiState.Content,
|
||||||
onOpenFilter: (TaskFilter) -> Unit,
|
onOpenFilter: (TaskFilter) -> Unit,
|
||||||
onOpenTask: (Long) -> Unit,
|
onOpenTask: (Long) -> Unit,
|
||||||
|
onNewList: () -> Unit,
|
||||||
topPadding: androidx.compose.ui.unit.Dp,
|
topPadding: androidx.compose.ui.unit.Dp,
|
||||||
bottomPadding: androidx.compose.ui.unit.Dp,
|
bottomPadding: androidx.compose.ui.unit.Dp,
|
||||||
) {
|
) {
|
||||||
@@ -215,9 +261,21 @@ private fun ListsContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.groups.isEmpty()) {
|
if (state.groups.isEmpty()) {
|
||||||
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
|
item { EmptyLists(onNewList = onNewList) }
|
||||||
} else {
|
} 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 ->
|
state.groups.forEach { group ->
|
||||||
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
|
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
|
||||||
itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview ->
|
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
|
* 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
|
* 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
|
@Composable
|
||||||
private fun SectionHeader(text: String) {
|
private fun SectionHeader(text: String, action: (@Composable () -> Unit)? = null) {
|
||||||
Text(
|
Row(
|
||||||
text = text,
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.titleMedium,
|
.fillMaxWidth()
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp),
|
.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
|
@Composable
|
||||||
|
|||||||
@@ -11,14 +11,23 @@ import de.jeanlucmakiola.agendula.domain.Task
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Clock
|
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 ListOverview(val list: TaskList, val openCount: Int)
|
||||||
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
||||||
data class SmartCount(val smart: SmartList, val count: Int)
|
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. */
|
/** The home overview: smart lists with live counts, then user lists by account. */
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ListsViewModel @Inject constructor(
|
class ListsViewModel @Inject constructor(
|
||||||
repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val state: StateFlow<ListsUiState> =
|
val state: StateFlow<ListsUiState> =
|
||||||
@@ -112,4 +121,22 @@ class ListsViewModel @Inject constructor(
|
|||||||
allTasks = openTasks + completedTasks,
|
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.Check
|
||||||
import androidx.compose.material.icons.rounded.Checklist
|
import androidx.compose.material.icons.rounded.Checklist
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
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.ExpandMore
|
||||||
import androidx.compose.material.icons.rounded.Flag
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
import androidx.compose.material3.Checkbox
|
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.TaskSection
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSections
|
import de.jeanlucmakiola.agendula.domain.TaskSections
|
||||||
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
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.Position
|
||||||
import de.jeanlucmakiola.floret.components.SnackChip
|
import de.jeanlucmakiola.floret.components.SnackChip
|
||||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||||
@@ -126,8 +129,22 @@ fun TaskListScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
val content = state as? TaskListUiState.Content
|
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
|
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
|
// 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
|
// bottom quick-add bar; everything else (incl. smart lists, which have no single
|
||||||
// target list) gets the floating "New task" button.
|
// 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,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -199,18 +226,43 @@ fun TaskListScreen(
|
|||||||
.height(SnackChipHeight),
|
.height(SnackChipHeight),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
SnackChip(
|
// One chip, one anchor: the undo receipt takes precedence, and a
|
||||||
visible = undoTarget != null,
|
// refused list write reports itself once the undo window is clear.
|
||||||
message = stringResource(R.string.task_deleted),
|
val failure = listWriteFailure
|
||||||
actionLabel = stringResource(R.string.undo),
|
if (undoTarget != null || failure == null) {
|
||||||
onAction = {
|
SnackChip(
|
||||||
undoTarget?.let { viewModel.undoDelete(it.taskId) }
|
visible = undoTarget != null,
|
||||||
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)
|
@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.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
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.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -28,13 +31,13 @@ sealed interface TaskListUiState {
|
|||||||
data object Failure : TaskListUiState
|
data object Failure : TaskListUiState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [listName] is the real list's name when the filter is a
|
* [list] is the real list when the filter is a [TaskFilter.OfList] — it
|
||||||
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
|
* titles the bar and backs the edit action — and `null` for smart lists,
|
||||||
* the screen falls back to the smart label in that case.
|
* where the screen falls back to the smart label.
|
||||||
*/
|
*/
|
||||||
data class Content(
|
data class Content(
|
||||||
val tasks: List<Task>,
|
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). */
|
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
|
||||||
val showAddSubtaskRow: Boolean = true,
|
val showAddSubtaskRow: Boolean = true,
|
||||||
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
|
/** 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 tasks = repository.tasks(f)
|
||||||
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
|
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
|
||||||
is TaskFilter.OfList ->
|
is TaskFilter.OfList ->
|
||||||
combine(tasks, repository.taskLists()) { list, lists ->
|
combine(tasks, repository.taskLists()) { rows, lists ->
|
||||||
TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name)
|
TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId })
|
||||||
}
|
}
|
||||||
is TaskFilter.Smart ->
|
is TaskFilter.Smart ->
|
||||||
tasks.map { TaskListUiState.Content(it) }
|
tasks.map { TaskListUiState.Content(it) }
|
||||||
@@ -126,7 +129,7 @@ class TaskListViewModel @Inject constructor(
|
|||||||
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
||||||
|
|
||||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
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. */
|
/** 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)) }
|
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]. */
|
/** Inline "add subtask" from an expanded list group — files it under [parent]. */
|
||||||
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
|
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
|
||||||
if (title.isBlank() || parent.listId <= 0L) return@launch
|
if (title.isBlank() || parent.listId <= 0L) return@launch
|
||||||
|
|||||||
@@ -122,7 +122,32 @@
|
|||||||
<string name="lists_header">Lists</string>
|
<string name="lists_header">Lists</string>
|
||||||
<string name="new_task">New task</string>
|
<string name="new_task">New task</string>
|
||||||
<string name="lists_failure">Could not read your tasks.</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_today">Today</string>
|
||||||
<string name="smart_overdue">Overdue</string>
|
<string name="smart_overdue">Overdue</string>
|
||||||
<string name="smart_upcoming">Upcoming</string>
|
<string name="smart_upcoming">Upcoming</string>
|
||||||
|
|||||||
@@ -164,6 +164,25 @@ class ProviderResolverTest {
|
|||||||
assertThat(resolver.resolve()?.packageName).isEqualTo("org.tasks")
|
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
|
@Test
|
||||||
fun `never name an authority of ours`() {
|
fun `never name an authority of ours`() {
|
||||||
// EXTERNAL must mean "somebody else's store", and Agendula publishes no
|
// 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")
|
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
|
@Test
|
||||||
fun `an unbounded rule stops at the window end`() {
|
fun `an unbounded rule stops at the window end`() {
|
||||||
val result = expand(
|
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
|
- ✅ Fallout: `ReminderScheduler.sync()` gated on `resolve() != null`, which is
|
||||||
what `OWN` returns, so no due reminder armed in the default mode. It now gates
|
what `OWN` returns, so no due reminder armed in the default mode. It now gates
|
||||||
on `ProviderResolver.canReadStore()`, with tests.
|
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
|
- ⬜ **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
|
DAOs, the import, the migration harness, the restore path and the performance
|
||||||
check — all compile and none has ever executed. Everything load-bearing about
|
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.
|
v0.3.2 APK with seeded data landing every task, list and reminder.
|
||||||
- ⬜ Per-locale release notes for the dropped authority and permissions.
|
- ⬜ 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
|
## Open decisions / to verify
|
||||||
|
|||||||
Reference in New Issue
Block a user