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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
<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 -->
|
||||
<string name="lists_header">Lists</string>
|
||||
|
||||
Reference in New Issue
Block a user