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:
@@ -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)
|
||||||
|
|||||||
@@ -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 */ },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("&")}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user