M1: full task data layer, reminders and ViewModels over OpenTasks
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:
Jean-Luc Makiola
2026-06-17 21:04:19 +02:00
parent 01d1f558b8
commit bfcfc50cf4
47 changed files with 2502 additions and 40 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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