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