M1: full task data layer, reminders and ViewModels over OpenTasks
All checks were successful
CI / ci (push) Successful in 5m25s
All checks were successful
CI / ci (push) Successful in 5m25s
Non-visual stack (backoffice) complete and verified against the real provider semantics (tasks.org's bundled dmfs TaskProvider, authority org.tasks.opentasks, org.tasks.permission.* dangerous): - TaskContract subset + ProviderResolver (runtime authority/permission) - AndroidTasksDataSource (Instances query, ContentObserver) + TasksRepository with live Flows; domain models, smart-list filtering, sorting, form validation - Self-scheduled due-reminder engine (AlarmManager, boot/provider-change) - Render-only ViewModels + UiState for every screen - 24 JVM unit tests; assembleDebug + lintDebug + testDebugUnitTest green - RootScreen is a functional scaffold over real data, to be replaced with the Material 3 Expressive screens one by one Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
/** A [ColumnReader] backed by a Map, so mappers test without a real Cursor. */
|
||||
class MapColumnReader(private val values: Map<String, Any?>) : ColumnReader {
|
||||
override fun getLong(name: String): Long? = (values[name] as? Number)?.toLong()
|
||||
override fun getInt(name: String): Int? = (values[name] as? Number)?.toInt()
|
||||
override fun getString(name: String): String? = values[name] as? String
|
||||
override fun getBoolean(name: String): Boolean = ((values[name] as? Number)?.toInt() ?: 0) != 0
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.floret.domain.Priority
|
||||
import de.jeanlucmakiola.floret.domain.TaskStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TaskMapperTest {
|
||||
|
||||
@Test
|
||||
fun `maps an instance row to a Task`() {
|
||||
val reader = MapColumnReader(
|
||||
mapOf(
|
||||
Tasks.ID to 42L,
|
||||
Instances.TASK_ID to 7L,
|
||||
Tasks.LIST_ID to 3L,
|
||||
Tasks.TITLE to "Buy milk",
|
||||
Tasks.DESCRIPTION to "2%",
|
||||
Tasks.PRIORITY to 1,
|
||||
Tasks.STATUS to TasksContract.STATUS_IN_PROCESS,
|
||||
Tasks.PERCENT_COMPLETE to 40,
|
||||
Instances.INSTANCE_DUE to 1_000L,
|
||||
Instances.INSTANCE_START to 500L,
|
||||
Tasks.IS_ALLDAY to 0,
|
||||
Tasks.LIST_COLOR to 0x123456,
|
||||
Tasks.TASK_COLOR to 0xABCDEF,
|
||||
Tasks.LIST_NAME to "Groceries",
|
||||
Tasks.PARENT_ID to 7L,
|
||||
Instances.IS_RECURRING to 1,
|
||||
Instances.DISTANCE_FROM_CURRENT to 0,
|
||||
),
|
||||
)
|
||||
|
||||
val task = TaskMapper.task(reader)
|
||||
|
||||
assertThat(task.id).isEqualTo(42L)
|
||||
assertThat(task.taskId).isEqualTo(7L)
|
||||
assertThat(task.listId).isEqualTo(3L)
|
||||
assertThat(task.title).isEqualTo("Buy milk")
|
||||
assertThat(task.priority).isEqualTo(Priority.HIGH)
|
||||
assertThat(task.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||
assertThat(task.percentComplete).isEqualTo(40)
|
||||
assertThat(task.due?.toEpochMilliseconds()).isEqualTo(1_000L)
|
||||
assertThat(task.start?.toEpochMilliseconds()).isEqualTo(500L)
|
||||
assertThat(task.isRecurring).isTrue()
|
||||
assertThat(task.effectiveColor).isEqualTo(0xABCDEF)
|
||||
assertThat(task.isSubtask).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to instance id when task_id missing, and list color when no task color`() {
|
||||
val task = TaskMapper.task(
|
||||
MapColumnReader(mapOf(Tasks.ID to 9L, Tasks.LIST_COLOR to 0x111111)),
|
||||
)
|
||||
assertThat(task.taskId).isEqualTo(9L)
|
||||
assertThat(task.effectiveColor).isEqualTo(0x111111)
|
||||
assertThat(task.title).isEmpty()
|
||||
assertThat(task.due).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maps a task list row`() {
|
||||
val list = TaskMapper.taskList(
|
||||
MapColumnReader(
|
||||
mapOf(
|
||||
Lists.ID to 5L,
|
||||
Lists.NAME to "Work",
|
||||
Lists.COLOR to 0x00FF00,
|
||||
Lists.ACCOUNT_NAME to "me@dav",
|
||||
Lists.ACCOUNT_TYPE to "bitfire.at.davdroid",
|
||||
Lists.SYNC_ENABLED to 1,
|
||||
Lists.VISIBLE to 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertThat(list.id).isEqualTo(5L)
|
||||
assertThat(list.name).isEqualTo("Work")
|
||||
assertThat(list.isSynced).isTrue()
|
||||
assertThat(list.isLocal).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.floret.domain.Priority
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class TaskWriteMapperTest {
|
||||
|
||||
@Test
|
||||
fun `timed task writes due, priority and an explicit timezone`() {
|
||||
val form = TaskForm(
|
||||
title = " Pay rent ",
|
||||
listId = 4L,
|
||||
due = Instant.fromEpochMilliseconds(2_000L),
|
||||
priority = Priority.HIGH,
|
||||
)
|
||||
val values = TaskWriteMapper.taskValues(form, tzId = "Europe/Berlin")
|
||||
|
||||
assertThat(values[Tasks.TITLE]).isEqualTo("Pay rent")
|
||||
assertThat(values[Tasks.LIST_ID]).isEqualTo(4L)
|
||||
assertThat(values[Tasks.DUE]).isEqualTo(2_000L)
|
||||
assertThat(values[Tasks.PRIORITY]).isEqualTo(1)
|
||||
assertThat(values[Tasks.IS_ALLDAY]).isEqualTo(0)
|
||||
assertThat(values[Tasks.TZ]).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day task clears the timezone`() {
|
||||
val form = TaskForm(
|
||||
title = "Holiday",
|
||||
listId = 1L,
|
||||
due = Instant.fromEpochMilliseconds(0L),
|
||||
isAllDay = true,
|
||||
)
|
||||
val values = TaskWriteMapper.taskValues(form, tzId = "Europe/Berlin")
|
||||
assertThat(values[Tasks.IS_ALLDAY]).isEqualTo(1)
|
||||
assertThat(values[Tasks.TZ]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completion sets status, percent and timestamp, un-completion clears them`() {
|
||||
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
|
||||
assertThat(done[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_COMPLETED)
|
||||
assertThat(done[Tasks.PERCENT_COMPLETE]).isEqualTo(100)
|
||||
assertThat(done[Tasks.COMPLETED]).isEqualTo(999L)
|
||||
|
||||
val undone = TaskWriteMapper.completionValues(completed = false, nowMillis = 999L)
|
||||
assertThat(undone[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_NEEDS_ACTION)
|
||||
assertThat(undone[Tasks.COMPLETED]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local list uses the LOCAL account`() {
|
||||
val values = TaskWriteMapper.localListValues("Inbox", 0x123)
|
||||
assertThat(values[Lists.NAME]).isEqualTo("Inbox")
|
||||
assertThat(values[Lists.ACCOUNT_TYPE]).isEqualTo(TasksContract.LOCAL_ACCOUNT_TYPE)
|
||||
assertThat(values[Lists.ACCOUNT_NAME]).isEqualTo(TasksContract.LOCAL_ACCOUNT_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
class DayWindowTest {
|
||||
|
||||
@Test
|
||||
fun `today spans exactly 24h in UTC and contains now`() {
|
||||
val zone = ZoneId.of("UTC")
|
||||
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
|
||||
val (start, end) = DayWindow.today(now, zone)
|
||||
|
||||
assertThat(end.toEpochMilliseconds() - start.toEpochMilliseconds()).isEqualTo(86_400_000L)
|
||||
assertThat(now >= start).isTrue()
|
||||
assertThat(now < end).isTrue()
|
||||
// In UTC, midnight aligns to a multiple of one day since the epoch.
|
||||
assertThat(start.toEpochMilliseconds() % 86_400_000L).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start is local midnight in a non-UTC zone`() {
|
||||
val zone = ZoneId.of("Europe/Berlin")
|
||||
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
|
||||
val (start, end) = DayWindow.today(now, zone)
|
||||
|
||||
val startLocal = java.time.Instant.ofEpochMilli(start.toEpochMilliseconds()).atZone(zone)
|
||||
assertThat(startLocal.hour).isEqualTo(0)
|
||||
assertThat(startLocal.minute).isEqualTo(0)
|
||||
assertThat(now >= start).isTrue()
|
||||
assertThat(now < end).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ModelsTest {
|
||||
|
||||
@Test
|
||||
fun `priority maps from iCalendar buckets`() {
|
||||
assertThat(priorityFromICal(null)).isEqualTo(Priority.NONE)
|
||||
assertThat(priorityFromICal(0)).isEqualTo(Priority.NONE)
|
||||
assertThat(priorityFromICal(1)).isEqualTo(Priority.HIGH)
|
||||
assertThat(priorityFromICal(4)).isEqualTo(Priority.HIGH)
|
||||
assertThat(priorityFromICal(5)).isEqualTo(Priority.MEDIUM)
|
||||
assertThat(priorityFromICal(6)).isEqualTo(Priority.LOW)
|
||||
assertThat(priorityFromICal(9)).isEqualTo(Priority.LOW)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `priority maps back to representative iCalendar values`() {
|
||||
assertThat(Priority.NONE.toICal()).isEqualTo(0)
|
||||
assertThat(Priority.HIGH.toICal()).isEqualTo(1)
|
||||
assertThat(Priority.MEDIUM.toICal()).isEqualTo(5)
|
||||
assertThat(Priority.LOW.toICal()).isEqualTo(9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status round-trips through its integer code`() {
|
||||
TaskStatus.entries.forEach { status ->
|
||||
assertThat(statusFromInt(status.toInt())).isEqualTo(status)
|
||||
}
|
||||
assertThat(statusFromInt(null)).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class TaskFilteringTest {
|
||||
|
||||
private val dayMs = 86_400_000L
|
||||
private val todayStart = Instant.fromEpochMilliseconds(1000 * dayMs)
|
||||
private val todayEnd = Instant.fromEpochMilliseconds(1001 * dayMs)
|
||||
|
||||
private fun at(millis: Long) = Instant.fromEpochMilliseconds(millis)
|
||||
private fun matches(task: Task, smart: SmartList) =
|
||||
TaskFiltering.matches(task, TaskFilter.Smart(smart), todayStart, todayEnd)
|
||||
|
||||
@Test
|
||||
fun `overdue is open with a due before today`() {
|
||||
val task = testTask(due = at(1000 * dayMs - 1))
|
||||
assertThat(matches(task, SmartList.OVERDUE)).isTrue()
|
||||
assertThat(matches(task, SmartList.TODAY)).isFalse()
|
||||
assertThat(matches(task, SmartList.UPCOMING)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `today is open with due within today`() {
|
||||
val task = testTask(due = at(1000 * dayMs + 1))
|
||||
assertThat(matches(task, SmartList.TODAY)).isTrue()
|
||||
assertThat(matches(task, SmartList.OVERDUE)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upcoming is open with due tomorrow or later`() {
|
||||
val task = testTask(due = at(1001 * dayMs + 1))
|
||||
assertThat(matches(task, SmartList.UPCOMING)).isTrue()
|
||||
assertThat(matches(task, SmartList.TODAY)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no-date is open without a due`() {
|
||||
val task = testTask(due = null)
|
||||
assertThat(matches(task, SmartList.NO_DATE)).isTrue()
|
||||
assertThat(matches(task, SmartList.ALL)).isTrue()
|
||||
assertThat(matches(task, SmartList.UPCOMING)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completed only matches COMPLETED, never the open lists`() {
|
||||
val task = testTask(status = TaskStatus.COMPLETED, due = at(1000 * dayMs + 1))
|
||||
assertThat(matches(task, SmartList.COMPLETED)).isTrue()
|
||||
assertThat(matches(task, SmartList.ALL)).isFalse()
|
||||
assertThat(matches(task, SmartList.TODAY)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OfList matches by list id`() {
|
||||
val task = testTask(listId = 7)
|
||||
assertThat(TaskFiltering.matches(task, TaskFilter.OfList(7), todayStart, todayEnd)).isTrue()
|
||||
assertThat(TaskFiltering.matches(task, TaskFilter.OfList(8), todayStart, todayEnd)).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class TaskFormTest {
|
||||
|
||||
private fun at(millis: Long) = Instant.fromEpochMilliseconds(millis)
|
||||
|
||||
@Test
|
||||
fun `a titled task in a list is valid`() {
|
||||
assertThat(TaskForm(title = "Write tests", listId = 1).validate()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank title and missing list are errors`() {
|
||||
val errors = TaskForm(title = " ", listId = 0).validate()
|
||||
assertThat(errors).containsExactly(TaskFormError.BLANK_TITLE, TaskFormError.NO_LIST)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `due before start is an error`() {
|
||||
val errors = TaskForm(
|
||||
title = "x",
|
||||
listId = 1,
|
||||
start = at(1000),
|
||||
due = at(500),
|
||||
).validate()
|
||||
assertThat(errors).contains(TaskFormError.DUE_BEFORE_START)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder requires a due date`() {
|
||||
val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate()
|
||||
assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class TaskSortingTest {
|
||||
|
||||
private fun at(millis: Long) = Instant.fromEpochMilliseconds(millis)
|
||||
|
||||
@Test
|
||||
fun `open before completed, due-soonest first, dateless last`() {
|
||||
val completed = testTask(id = 1, title = "done", status = TaskStatus.COMPLETED, due = at(10))
|
||||
val dueLater = testTask(id = 2, title = "later", due = at(200))
|
||||
val dueSooner = testTask(id = 3, title = "sooner", due = at(100))
|
||||
val noDate = testTask(id = 4, title = "someday", due = null)
|
||||
|
||||
val sorted = listOf(completed, noDate, dueLater, dueSooner).sortedWith(TaskSorting.DEFAULT)
|
||||
|
||||
assertThat(sorted.map { it.id }).containsExactly(3L, 2L, 4L, 1L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `higher priority wins when due dates tie`() {
|
||||
val low = testTask(id = 1, title = "low", priority = Priority.LOW, due = at(100))
|
||||
val high = testTask(id = 2, title = "high", priority = Priority.HIGH, due = at(100))
|
||||
|
||||
val sorted = listOf(low, high).sortedWith(TaskSorting.DEFAULT)
|
||||
|
||||
assertThat(sorted.map { it.id }).containsExactly(2L, 1L).inOrder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Builds a [Task] for tests with sensible defaults; override what matters. */
|
||||
fun testTask(
|
||||
id: Long = 1,
|
||||
listId: Long = 1,
|
||||
title: String = "task",
|
||||
status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||
priority: Priority = Priority.NONE,
|
||||
due: Instant? = null,
|
||||
): Task = Task(
|
||||
id = id,
|
||||
taskId = id,
|
||||
listId = listId,
|
||||
title = title,
|
||||
description = null,
|
||||
location = null,
|
||||
url = null,
|
||||
priority = priority,
|
||||
status = status,
|
||||
percentComplete = null,
|
||||
start = null,
|
||||
due = due,
|
||||
isAllDay = false,
|
||||
timeZone = null,
|
||||
completedAt = null,
|
||||
listColor = 0,
|
||||
taskColor = null,
|
||||
listName = null,
|
||||
accountName = null,
|
||||
parentId = null,
|
||||
isRecurring = false,
|
||||
distanceFromCurrent = 0,
|
||||
created = null,
|
||||
lastModified = null,
|
||||
)
|
||||
Reference in New Issue
Block a user