diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5710471..0ff3531 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -110,6 +110,7 @@ dependencies {
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
+ implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler)
implementation(libs.androidx.datastore.preferences)
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt
index d87e505..a967257 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt
@@ -19,13 +19,12 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R
import de.jeanlucmakiola.floret.data.tasks.ProviderStatus
-import de.jeanlucmakiola.floret.ui.lists.ListsScreen
+import de.jeanlucmakiola.floret.ui.navigation.FloretNavHost
import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel
/**
- * App root: gates on the tasks-provider permission, then shows the home
- * ([ListsScreen]). Navigation to the task-list / edit screens is wired as those
- * screens land (next milestones); the callbacks are stubs for now.
+ * App root: gates on the tasks-provider permission, then hands off to
+ * [FloretNavHost] (lists → task list → detail / edit).
*/
@Composable
fun RootScreen(
@@ -50,11 +49,7 @@ fun RootScreen(
action = stringResource(R.string.onboarding_permission_button),
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
)
- ProviderStatus.READY -> ListsScreen(
- modifier = modifier,
- onOpenFilter = { /* TODO: navigate to TaskListScreen */ },
- onNewTask = { /* TODO: navigate to TaskEditScreen */ },
- )
+ ProviderStatus.READY -> FloretNavHost(modifier = modifier)
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailScreen.kt
new file mode 100644
index 0000000..4e837ae
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailScreen.kt
@@ -0,0 +1,127 @@
+package de.jeanlucmakiola.floret.ui.detail
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material.icons.rounded.Edit
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.jeanlucmakiola.floret.R
+import de.jeanlucmakiola.floret.domain.TaskDetail
+
+/**
+ * One task's detail. **Base scaffold** — title, description and subtask titles,
+ * with edit / delete in the top bar, against the already-built
+ * [TaskDetailViewModel]. M3 fleshes this out with dates, priority,
+ * percent-complete and subtask presentation.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TaskDetailScreen(
+ onEdit: () -> Unit,
+ onDeleted: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: TaskDetailViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.task_detail_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ )
+ }
+ },
+ actions = {
+ val content = state as? TaskDetailUiState.Content
+ IconButton(onClick = onEdit, enabled = content != null) {
+ Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.edit))
+ }
+ IconButton(
+ onClick = {
+ content?.let {
+ viewModel.delete(it.detail.task)
+ onDeleted()
+ }
+ },
+ enabled = content != null,
+ ) {
+ Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.delete))
+ }
+ },
+ )
+ },
+ ) { inner ->
+ when (val s = state) {
+ TaskDetailUiState.Loading -> Unit
+ TaskDetailUiState.NotFound ->
+ CenteredMessage(stringResource(R.string.task_detail_not_found), inner)
+ is TaskDetailUiState.Content -> DetailBody(s.detail, inner)
+ }
+ }
+}
+
+@Composable
+private fun DetailBody(detail: TaskDetail, inner: androidx.compose.foundation.layout.PaddingValues) {
+ val task = detail.task
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(inner)
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ ) {
+ Text(
+ text = task.title.ifBlank { stringResource(R.string.task_untitled) },
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ task.description?.takeIf { it.isNotBlank() }?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 12.dp),
+ )
+ }
+ detail.subtasks.forEach { sub ->
+ Text(
+ text = "• " + sub.title.ifBlank { stringResource(R.string.task_untitled) },
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun CenteredMessage(text: String, inner: androidx.compose.foundation.layout.PaddingValues) {
+ Box(
+ modifier = Modifier.fillMaxSize().padding(inner).padding(24.dp),
+ contentAlignment = Alignment.Center,
+ ) { Text(text) }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt
new file mode 100644
index 0000000..33874b5
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt
@@ -0,0 +1,108 @@
+package de.jeanlucmakiola.floret.ui.edit
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.Check
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.jeanlucmakiola.floret.R
+
+/**
+ * Create / edit form. **Base scaffold** — title + description fields and save,
+ * against the already-built [TaskEditViewModel]; it flips `saved` for us to pop
+ * back. M3 layers on date-time pickers, priority, list selection,
+ * percent-complete and per-task reminders.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TaskEditScreen(
+ onSaved: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: TaskEditViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ LaunchedEffect(state.saved) {
+ if (state.saved) onSaved()
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(
+ if (state.isNew) R.string.new_task_title else R.string.edit_task_title,
+ ),
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = viewModel::save, enabled = !state.loading) {
+ Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.save))
+ }
+ },
+ )
+ },
+ ) { inner ->
+ if (state.loading) return@Scaffold
+
+ Column(
+ modifier = Modifier
+ .padding(inner)
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ ) {
+ OutlinedTextField(
+ value = state.title,
+ onValueChange = viewModel::onTitleChange,
+ label = { Text(stringResource(R.string.field_title)) },
+ singleLine = true,
+ isError = state.errors.isNotEmpty(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = state.description,
+ onValueChange = viewModel::onDescriptionChange,
+ label = { Text(stringResource(R.string.field_description)) },
+ modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
+ )
+ if (state.saveFailed) {
+ Text(
+ text = stringResource(R.string.edit_save_failed),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 12.dp),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/Destinations.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/Destinations.kt
new file mode 100644
index 0000000..e4ee64a
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/Destinations.kt
@@ -0,0 +1,88 @@
+package de.jeanlucmakiola.floret.ui.navigation
+
+import androidx.navigation.NavType
+import androidx.navigation.navArgument
+import de.jeanlucmakiola.floret.domain.SmartList
+import de.jeanlucmakiola.floret.domain.TaskFilter
+
+/**
+ * String-based route table for the app's [androidx.navigation.NavHost].
+ *
+ * Routes are kept stringly-typed (rather than the type-safe `@Serializable`
+ * API) so we don't have to pull in the kotlinx-serialization Gradle plugin for
+ * four small destinations. Each destination exposes a `route` template (for
+ * `composable(...)`) and a builder that fills the arguments (for `navigate`).
+ *
+ * Argument convention: optional `Long`s default to [NO_ID] (`-1`) rather than
+ * being nullable, which sidesteps the lack of a nullable-Long [NavType].
+ */
+object Dest {
+
+ const val NO_ID = -1L
+
+ /** Home — smart lists + the user's lists. */
+ const val LISTS = "lists"
+
+ /**
+ * One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
+ * `smart` (a [SmartList] name); whichever is set decides the filter.
+ */
+ object TaskList {
+ const val ARG_LIST_ID = "listId"
+ const val ARG_SMART = "smart"
+ const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}"
+
+ val arguments = listOf(
+ navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
+ navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null },
+ )
+
+ fun build(filter: TaskFilter): String = when (filter) {
+ is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
+ is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
+ }
+
+ /** Reconstruct the filter from a destination's arguments. */
+ fun filterOf(smart: String?, listId: Long): TaskFilter =
+ if (smart != null) TaskFilter.Smart(SmartList.valueOf(smart))
+ else TaskFilter.OfList(listId)
+ }
+
+ /** A single task's detail. */
+ object TaskDetail {
+ const val ARG_TASK_ID = "taskId"
+ const val route = "taskDetail/{$ARG_TASK_ID}"
+ val arguments = listOf(navArgument(ARG_TASK_ID) { type = NavType.LongType })
+ fun build(taskId: Long): String = "taskDetail/$taskId"
+ }
+
+ /**
+ * The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
+ * task, `presetListId` / `parentId` optionally seed the form.
+ */
+ object TaskEdit {
+ const val ARG_TASK_ID = "taskId"
+ const val ARG_PRESET_LIST_ID = "presetListId"
+ const val ARG_PARENT_ID = "parentId"
+ const val route =
+ "taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
+ "&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
+ "&$ARG_PARENT_ID={$ARG_PARENT_ID}"
+
+ val arguments = listOf(
+ navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
+ navArgument(ARG_PRESET_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
+ navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID },
+ )
+
+ fun buildEdit(taskId: Long): String = "taskEdit?$ARG_TASK_ID=$taskId"
+
+ fun buildNew(presetListId: Long? = null, parentId: Long? = null): String {
+ val params = buildList {
+ if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
+ if (parentId != null) add("$ARG_PARENT_ID=$parentId")
+ }
+ return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/FloretNavHost.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/FloretNavHost.kt
new file mode 100644
index 0000000..6195f95
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/FloretNavHost.kt
@@ -0,0 +1,102 @@
+package de.jeanlucmakiola.floret.ui.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import de.jeanlucmakiola.floret.domain.TaskFilter
+import de.jeanlucmakiola.floret.ui.detail.TaskDetailScreen
+import de.jeanlucmakiola.floret.ui.detail.TaskDetailViewModel
+import de.jeanlucmakiola.floret.ui.edit.TaskEditScreen
+import de.jeanlucmakiola.floret.ui.edit.TaskEditViewModel
+import de.jeanlucmakiola.floret.ui.lists.ListsScreen
+import de.jeanlucmakiola.floret.ui.tasklist.TaskListScreen
+import de.jeanlucmakiola.floret.ui.tasklist.TaskListViewModel
+
+/**
+ * The single in-app back stack: lists → task list → detail / edit.
+ *
+ * Each destination resolves its own Hilt [androidx.lifecycle.ViewModel] and
+ * feeds it the route arguments through that VM's `bind(...)` method (the VMs
+ * were built render-only in M1 and take their input this way rather than from a
+ * `SavedStateHandle`). Screens receive plain navigation lambdas so they stay
+ * unaware of the nav library.
+ */
+@Composable
+fun FloretNavHost(modifier: Modifier = Modifier) {
+ val nav = rememberNavController()
+
+ NavHost(
+ navController = nav,
+ startDestination = Dest.LISTS,
+ modifier = modifier,
+ ) {
+ composable(Dest.LISTS) {
+ ListsScreen(
+ onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
+ onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
+ )
+ }
+
+ composable(Dest.TaskList.route, arguments = Dest.TaskList.arguments) { entry ->
+ val args = entry.arguments
+ val filter = Dest.TaskList.filterOf(
+ smart = args?.getString(Dest.TaskList.ARG_SMART),
+ listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID,
+ )
+ val vm: TaskListViewModel = hiltViewModel()
+ LaunchedEffect(filter) { vm.bind(filter) }
+
+ TaskListScreen(
+ filter = filter,
+ viewModel = vm,
+ onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) },
+ onNewTask = {
+ val presetListId = (filter as? TaskFilter.OfList)?.listId
+ nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId))
+ },
+ onBack = { nav.popBackStack() },
+ )
+ }
+
+ composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry ->
+ val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID
+ val vm: TaskDetailViewModel = hiltViewModel()
+ LaunchedEffect(taskId) { vm.bind(taskId) }
+
+ TaskDetailScreen(
+ viewModel = vm,
+ onEdit = { nav.navigate(Dest.TaskEdit.buildEdit(taskId)) },
+ onDeleted = { nav.popBackStack() },
+ onBack = { nav.popBackStack() },
+ )
+ }
+
+ composable(Dest.TaskEdit.route, arguments = Dest.TaskEdit.arguments) { entry ->
+ val args = entry.arguments
+ val taskId = args?.getLong(Dest.TaskEdit.ARG_TASK_ID) ?: Dest.NO_ID
+ val presetListId = args?.getLong(Dest.TaskEdit.ARG_PRESET_LIST_ID) ?: Dest.NO_ID
+ val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID
+ val vm: TaskEditViewModel = hiltViewModel()
+ LaunchedEffect(taskId, presetListId, parentId) {
+ if (taskId != Dest.NO_ID) {
+ vm.bindEdit(taskId)
+ } else {
+ vm.bindNew(
+ presetListId = presetListId.takeIf { it != Dest.NO_ID },
+ parentId = parentId.takeIf { it != Dest.NO_ID },
+ )
+ }
+ }
+
+ TaskEditScreen(
+ viewModel = vm,
+ onSaved = { nav.popBackStack() },
+ onBack = { nav.popBackStack() },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListScreen.kt
new file mode 100644
index 0000000..7004ca5
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListScreen.kt
@@ -0,0 +1,129 @@
+package de.jeanlucmakiola.floret.ui.tasklist
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.jeanlucmakiola.floret.R
+import de.jeanlucmakiola.floret.domain.SmartList
+import de.jeanlucmakiola.floret.domain.Task
+import de.jeanlucmakiola.floret.domain.TaskFilter
+
+/**
+ * The tasks for one [TaskFilter]. **Base scaffold** — renders the rows, toggle,
+ * and open/new navigation against the already-built [TaskListViewModel]. The
+ * richer M2 UI (swipe-to-complete / -delete, inline add field, smart-list
+ * section headers) layers on top of this.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TaskListScreen(
+ filter: TaskFilter,
+ onOpenTask: (Long) -> Unit,
+ onNewTask: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: TaskListViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(titleFor(filter)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ )
+ }
+ },
+ )
+ },
+ floatingActionButton = {
+ ExtendedFloatingActionButton(
+ onClick = onNewTask,
+ icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
+ text = { Text(stringResource(R.string.new_task)) },
+ )
+ },
+ ) { inner ->
+ when (val s = state) {
+ TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
+ TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
+ is TaskListUiState.Content ->
+ if (s.tasks.isEmpty()) {
+ CenteredMessage(stringResource(R.string.task_list_empty), inner)
+ } else {
+ LazyColumn(contentPadding = inner) {
+ items(s.tasks, key = { it.taskId }) { task ->
+ TaskRow(
+ task = task,
+ onToggle = { viewModel.toggleComplete(task) },
+ onClick = { onOpenTask(task.taskId) },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TaskRow(task: Task, onToggle: () -> Unit, onClick: () -> Unit) {
+ ListItem(
+ modifier = Modifier.clickable(onClick = onClick),
+ leadingContent = { Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() }) },
+ headlineContent = {
+ Text(task.title.ifBlank { stringResource(R.string.task_untitled) })
+ },
+ supportingContent = task.description?.takeIf { it.isNotBlank() }?.let { { Text(it) } },
+ )
+}
+
+@Composable
+private fun CenteredMessage(text: String, inner: androidx.compose.foundation.layout.PaddingValues) {
+ Box(
+ modifier = Modifier.fillMaxSize().padding(inner).padding(24.dp),
+ contentAlignment = Alignment.Center,
+ ) { Text(text) }
+}
+
+@Composable
+private fun titleFor(filter: TaskFilter): String = when (filter) {
+ is TaskFilter.OfList -> stringResource(R.string.tasks_title)
+ is TaskFilter.Smart -> stringResource(
+ when (filter.list) {
+ SmartList.TODAY -> R.string.smart_today
+ SmartList.UPCOMING -> R.string.smart_upcoming
+ SmartList.OVERDUE -> R.string.smart_overdue
+ SmartList.ALL -> R.string.smart_all
+ SmartList.NO_DATE -> R.string.smart_no_date
+ SmartList.COMPLETED -> R.string.smart_completed
+ },
+ )
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 846a17f..4c31abf 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4,6 +4,28 @@
Untitled task
+ Back
+ Save
+ Delete
+
+
+ Tasks
+ No tasks here yet.
+ Could not load these tasks.
+ No date
+ Completed
+
+
+ Task
+ This task no longer exists.
+ Edit
+
+
+ Edit task
+ New task
+ Title
+ Description
+ Could not save. Try again.
Lists
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 38f5c16..2f55ab2 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -13,7 +13,7 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
provider is **done and unit-tested**; the Material 3 Expressive UI is being
built screen by screen on top of it. App builds, gates on provider/permission,
-and renders the lists overview.
+and navigates lists → task list → detail / edit through base screen scaffolds.
---
@@ -44,12 +44,14 @@ Replace the functional scaffold with the real Material 3 Expressive UI, screen
by screen, against the already-built ViewModels.
- ✅ Provider/permission onboarding gate (`RootScreen`).
- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
-- 🚧 Navigation host wiring (the `onOpenFilter` / `onNewTask` callbacks in
- `RootScreen` are currently stubs).
-- ⬜ Task list screen — checkbox rows, swipe-to-complete / swipe-to-delete,
- inline add field, section headers for smart lists, FAB.
-- ⬜ Toggle complete (swipe + checkbox), create / edit / delete, local-list
- creation wired to the UI.
+- ✅ Navigation host wiring (`FloretNavHost` + `Dest` route table: lists → task
+ list → detail / edit, each binding its M1 ViewModel from route args).
+- 🚧 Task list screen — base scaffold lands (checkbox rows, toggle-complete, open
+ / new-task nav). Remaining: swipe-to-complete / -delete, inline add field,
+ section headers for smart lists.
+- 🚧 Detail + edit screens — base scaffolds land (detail shows title /
+ description / subtasks with edit+delete; edit has title / description / save).
+ Remaining: full CRUD fields and local-list creation wired to the UI.
### ⬜ M3 — Detail / edit polish
Task detail and edit screens: due/start date-time pickers, priority,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0aee61f..b691c2a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -22,6 +22,7 @@ kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.10.2"
turbine = "1.2.0"
hiltNavigationCompose = "1.3.0"
+navigationCompose = "2.9.0"
lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
@@ -78,6 +79,9 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine
# Hilt navigation-compose (for hiltViewModel() in Composables)
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
+# Navigation-compose (the NavHost / back stack)
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+
# Lifecycle compose (for collectAsStateWithLifecycle)
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }