M2: wire navigation host + base screen scaffolds

Replace the RootScreen nav stubs with a real NavHost. Each destination
binds its existing M1 ViewModel from route args.

- ui/navigation: Dest route table (string-based; no serialization plugin)
  + FloretNavHost (lists -> task list -> detail / edit).
- Base scaffolds for TaskListScreen, TaskDetailScreen, TaskEditScreen,
  wired to their ViewModels — skeletons for the rich M2/M3 UI.
- RootScreen READY branch now hands off to FloretNavHost.
- Add navigation-compose 2.9.0; supporting strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 09:44:18 +02:00
parent 1b3bb72c84
commit b196d9ebe0
10 changed files with 594 additions and 16 deletions

View File

@@ -110,6 +110,7 @@ dependencies {
implementation(libs.hilt.android) implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)

View File

@@ -19,13 +19,12 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R import de.jeanlucmakiola.floret.R
import de.jeanlucmakiola.floret.data.tasks.ProviderStatus 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 import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel
/** /**
* App root: gates on the tasks-provider permission, then shows the home * App root: gates on the tasks-provider permission, then hands off to
* ([ListsScreen]). Navigation to the task-list / edit screens is wired as those * [FloretNavHost] (lists → task list → detail / edit).
* screens land (next milestones); the callbacks are stubs for now.
*/ */
@Composable @Composable
fun RootScreen( fun RootScreen(
@@ -50,11 +49,7 @@ fun RootScreen(
action = stringResource(R.string.onboarding_permission_button), action = stringResource(R.string.onboarding_permission_button),
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) }, onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
) )
ProviderStatus.READY -> ListsScreen( ProviderStatus.READY -> FloretNavHost(modifier = modifier)
modifier = modifier,
onOpenFilter = { /* TODO: navigate to TaskListScreen */ },
onNewTask = { /* TODO: navigate to TaskEditScreen */ },
)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,28 @@
<!-- Generic --> <!-- Generic -->
<string name="task_untitled">Untitled task</string> <string name="task_untitled">Untitled task</string>
<string name="back">Back</string>
<string name="save">Save</string>
<string name="delete">Delete</string>
<!-- Task list -->
<string name="tasks_title">Tasks</string>
<string name="task_list_empty">No tasks here yet.</string>
<string name="task_list_failure">Could not load these tasks.</string>
<string name="smart_no_date">No date</string>
<string name="smart_completed">Completed</string>
<!-- Task detail -->
<string name="task_detail_title">Task</string>
<string name="task_detail_not_found">This task no longer exists.</string>
<string name="edit">Edit</string>
<!-- Task edit -->
<string name="edit_task_title">Edit task</string>
<string name="new_task_title">New task</string>
<string name="field_title">Title</string>
<string name="field_description">Description</string>
<string name="edit_save_failed">Could not save. Try again.</string>
<!-- Lists overview --> <!-- Lists overview -->
<string name="lists_header">Lists</string> <string name="lists_header">Lists</string>

View File

@@ -13,7 +13,7 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract` The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
provider is **done and unit-tested**; the Material 3 Expressive UI is being 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, 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. by screen, against the already-built ViewModels.
- ✅ Provider/permission onboarding gate (`RootScreen`). - ✅ Provider/permission onboarding gate (`RootScreen`).
- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account. - ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
- 🚧 Navigation host wiring (the `onOpenFilter` / `onNewTask` callbacks in - Navigation host wiring (`FloretNavHost` + `Dest` route table: lists → task
`RootScreen` are currently stubs). list → detail / edit, each binding its M1 ViewModel from route args).
- Task list screen — checkbox rows, swipe-to-complete / swipe-to-delete, - 🚧 Task list screen — base scaffold lands (checkbox rows, toggle-complete, open
inline add field, section headers for smart lists, FAB. / new-task nav). Remaining: swipe-to-complete / -delete, inline add field,
- ⬜ Toggle complete (swipe + checkbox), create / edit / delete, local-list section headers for smart lists.
creation wired to the UI. - 🚧 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 ### ⬜ M3 — Detail / edit polish
Task detail and edit screens: due/start date-time pickers, priority, Task detail and edit screens: due/start date-time pickers, priority,

View File

@@ -22,6 +22,7 @@ kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.10.2" kotlinxCoroutines = "1.10.2"
turbine = "1.2.0" turbine = "1.2.0"
hiltNavigationCompose = "1.3.0" hiltNavigationCompose = "1.3.0"
navigationCompose = "2.9.0"
lifecycleCompose = "2.10.0" lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). # 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) # Hilt navigation-compose (for hiltViewModel() in Composables)
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } 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) # Lifecycle compose (for collectAsStateWithLifecycle)
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }