Fix navigation (once and for all ?)

Implement inline edit lists feature
Improve task list bottom sheet design
This commit is contained in:
Geoffroy Bonneville
2025-09-18 15:40:19 -04:00
parent f8fd041f8e
commit 2be67abffa
8 changed files with 216 additions and 155 deletions

View File

@@ -46,7 +46,7 @@ android {
}
dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
implementation("androidx.activity:activity-compose:1.11.0")
implementation(platform("androidx.compose:compose-bom:2025.09.00"))
implementation("androidx.compose.ui:ui")

View File

@@ -44,6 +44,10 @@ class TaskRepositoryImpl @Inject constructor(
taskListDao.insertTaskList(taskList.toEntity())
}
override suspend fun updateTaskList(taskList: TaskList) {
taskListDao.updateTaskList(taskList.toEntity())
}
override suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) {
taskDao.deleteAllTasksFromList(taskListId, isDeleted)
taskListDao.deleteTaskList(taskListId, isDeleted)

View File

@@ -14,6 +14,7 @@ interface TaskRepository {
fun getTaskLists(): Flow<List<TaskList>>
suspend fun insertTaskList(taskList: TaskList)
suspend fun updateTaskList(taskList: TaskList)
suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean)
fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>>
}

View File

@@ -0,0 +1,20 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject
class UpdateTaskListUseCase @Inject constructor(
private val repository: TaskRepository
) {
suspend operator fun invoke(taskListId: Long, title: String, order: Int) {
repository.updateTaskList(
TaskList(
id = taskListId,
name = title,
order = order,
isDeleted = false
)
)
}
}

View File

@@ -15,6 +15,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -31,15 +32,13 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@@ -50,6 +49,7 @@ import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
@@ -58,8 +58,6 @@ fun MainScreen(
viewModel: MainViewModel = hiltViewModel()
) {
val navController = rememberNavController()
var showTaskSheet by remember { mutableStateOf(false) }
var showAddListSheet by remember { mutableStateOf(false) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val taskViewModel: TaskViewModel = hiltViewModel()
@@ -71,23 +69,20 @@ fun MainScreen(
return
}
val startDestination = viewModel.destinations.firstOrNull() ?: AppDestination.ManageLists
if (showTaskSheet) {
TaskBottomSheet(taskViewModel) { showTaskSheet = false }
if (viewModel.showTaskSheet) {
TaskBottomSheet(taskViewModel) { viewModel.showTaskSheet = false }
}
if (showAddListSheet) {
AddListBottomSheet { showAddListSheet = false }
if (viewModel.showAddListSheet) {
AddListBottomSheet { viewModel.showAddListSheet = false }
}
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination =
viewModel.deriveDestination(navBackStackEntry?.destination?.route)
?: startDestination
viewModel.setCurrentDestination(navBackStackEntry)
ModalNavigationDrawer(
drawerContent = {
MenuScreen (
currentDestination = currentDestination,
currentDestination = viewModel.currentDestination,
onNavigate = { route ->
scope.launch { drawerState.close() }
navController.navigate(route) {
@@ -98,101 +93,113 @@ fun MainScreen(
},
drawerState = drawerState
) {
Scaffold(
modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer),
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = { Text(currentDestination!!.title) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
navigationIcon = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
if (currentDestination!!.showBackButton) {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
} else {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(
Icons.Default.Menu,
contentDescription = "Open navigation drawer"
)
}
}
}
},
actions = {
if (currentDestination is AppDestination.ManageLists) {
IconButton(onClick = { showAddListSheet = true }) {
Icon(Icons.Default.Add, contentDescription = "Add List")
}
}
}
)
},
floatingActionButton = {
when (val dest = currentDestination) {
is AppDestination.TaskList -> {
TaskListFab(
taskListId = dest.taskListId,
showBottomSheet = { showTaskSheet = it }
)
}
else -> null
}
}
) { contentPadding ->
Surface(
modifier = Modifier
.padding(top = contentPadding.calculateTopPadding())
.fillMaxSize(),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
NavHost(
navController = navController,
startDestination = startDestination.route,
enterTransition = {
slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
},
exitTransition = {
slideOutHorizontally(targetOffsetX = { fullWidth -> -fullWidth }, animationSpec = tween(300))
},
popEnterTransition = {
slideInHorizontally(initialOffsetX = { fullWidth -> -fullWidth }, animationSpec = tween(300))
},
popExitTransition = {
slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
}
) {
viewModel.destinations.forEach { destination ->
composable(
route = "taskList/{taskListId}",
arguments = listOf(navArgument("taskListId") {
type = NavType.LongType
})
) { navBackStackEntry ->
val viewModel: TaskListViewModel = hiltViewModel(navBackStackEntry)
TaskListScreen(
viewModel = viewModel,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
showTaskSheet = true
}
)
}
}
AppContent(viewModel = viewModel, taskViewModel = taskViewModel, navController = navController, scope = scope, drawerState = drawerState)
}
}
composable(AppDestination.ManageLists.route) {
ManageListsScreen(
modifier = Modifier,
showAddListSheet = {showAddListSheet = true}
@Composable
fun AppContent(
modifier : Modifier = Modifier,
viewModel: MainViewModel,
taskViewModel: TaskViewModel,
navController: NavHostController,
scope: CoroutineScope,
drawerState: DrawerState
) {
Scaffold(
modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer),
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = { Text(viewModel.currentDestination.title) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
navigationIcon = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
if (viewModel.currentDestination.showBackButton) {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
} else {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(
Icons.Default.Menu,
contentDescription = "Open navigation drawer"
)
}
}
}
},
actions = {
if (viewModel.currentDestination is AppDestination.ManageLists) {
IconButton(onClick = { viewModel.showAddListSheet = true }) {
Icon(Icons.Default.Add, contentDescription = "Add List")
}
}
}
)
},
floatingActionButton = {
when (val dest = viewModel.currentDestination) {
is AppDestination.TaskList -> {
TaskListFab(
taskListId = dest.taskListId,
showBottomSheet = { viewModel.showTaskSheet = it }
)
}
else -> null
}
}
) { contentPadding ->
Surface(
modifier = Modifier
.padding(top = contentPadding.calculateTopPadding())
.fillMaxSize(),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
NavHost(
navController = navController,
startDestination = viewModel.startDestination.route,
enterTransition = {
slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
},
exitTransition = {
slideOutHorizontally(targetOffsetX = { fullWidth -> -fullWidth }, animationSpec = tween(300))
},
popEnterTransition = {
slideInHorizontally(initialOffsetX = { fullWidth -> -fullWidth }, animationSpec = tween(300))
},
popExitTransition = {
slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
}
) {
//viewModel.destinations.forEach { destination ->
composable(
route = "taskList/{taskListId}",
arguments = listOf(navArgument("taskListId") {
type = NavType.LongType
})
) { navBackStackEntry ->
val taskListViewModel: TaskListViewModel = hiltViewModel(navBackStackEntry)
TaskListScreen(
viewModel = taskListViewModel,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
)
}
//}
composable(AppDestination.ManageLists.route) {
ManageListsScreen(
modifier = Modifier,
showAddListSheet = {viewModel.showAddListSheet = true}
)
}
}
}

View File

@@ -7,11 +7,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Button
@@ -23,9 +23,8 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -33,12 +32,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.presentation.viewmodel.ManageListsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@@ -52,17 +50,46 @@ fun ManageListsScreen(
LazyColumn(modifier = modifier.fillMaxWidth().padding()) {
itemsIndexed(lists, key = { _, list -> list.id!! }) { index, list ->
var isInEditMode by remember { mutableStateOf(false) }
var editedName by remember { mutableStateOf(list.name) }
ListItem(
modifier = Modifier.animateItem(),
headlineContent = { Text(list.name) },
headlineContent = {
if (isInEditMode) {
OutlinedTextField(
value = editedName,
onValueChange = { editedName = it },
singleLine = true
)
} else {
Text(list.name)
}
},
trailingContent = {
Row {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
IconButton(onClick = { /* TODO: edit list */ }) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
if (isInEditMode) {
Row {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
IconButton(onClick = { isInEditMode = false }) {
Icon(Icons.Default.Close, contentDescription = "Cancel")
}
IconButton(onClick = {
viewModel.updateTaskListName(list.copy(name = editedName))
isInEditMode = false
}) {
Icon(Icons.Default.Check, contentDescription = "Save")
}
}
IconButton(onClick = { viewModel.deleteTaskList(list.id!!) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
} else {
Row {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
IconButton(onClick = { isInEditMode = true }) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
IconButton(onClick = { viewModel.deleteTaskList(list.id!!) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}
}
@@ -73,34 +100,6 @@ fun ManageListsScreen(
}
}
@Composable
fun EditableListRow(
list: TaskList,
onNameChange: (String) -> Unit,
//onTypeChange: (ListType) -> Unit,
onDone: () -> Unit
) {
var name by remember { mutableStateOf(list.name) }
//var type by remember { mutableStateOf(list.type) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(8.dp)
) {
TextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.weight(1f),
singleLine = true
)
// TODO: implement type
//DropdownSelector(selected = type, onSelect = { type = it; onTypeChange(it) })
IconButton(onClick = onDone) {
Icon(Icons.Default.Check, contentDescription = "Save")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddListBottomSheet(
@@ -122,11 +121,21 @@ fun AddListBottomSheet(
Text("Create New List", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
TextField(
/*TextField(
value = name,
onValueChange = { name = it },
label = { Text("List Name") },
singleLine = true
)*/
OutlinedTextField(
value = name,
singleLine = true,
onValueChange = { name = it },
label = { Text("Title") },
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleFocusRequester),
isError = name.isEmpty(),
)
Spacer(Modifier.height(8.dp))
@@ -142,8 +151,8 @@ fun AddListBottomSheet(
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = onDismiss) { Text("Cancel") }
Spacer(Modifier.width(8.dp))
//TextButton(onClick = onDismiss) { Text("Cancel") }
//Spacer(Modifier.width(8.dp))
Button(onClick = {
viewModel.createTaskList(name/*, type, description*/, 1)
onDismiss()

View File

@@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavBackStackEntry
import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -23,6 +24,15 @@ class MainViewModel @Inject constructor(
var destinations by mutableStateOf<List<AppDestination>>(emptyList())
private set
var startDestination by mutableStateOf<AppDestination>(AppDestination.ManageLists)
private set
var currentDestination by mutableStateOf(startDestination)
private set
var showTaskSheet by mutableStateOf(false)
var showAddListSheet by mutableStateOf(false)
init {
getTaskLists()
.onEach { lists ->
@@ -30,17 +40,20 @@ class MainViewModel @Inject constructor(
AppDestination.TaskList(taskList.id!!, taskList.name)
} + AppDestination.ManageLists
isLoading = false
if (!destinations.isEmpty()) startDestination = destinations.first()
}
.launchIn(viewModelScope)
}
fun deriveDestination(route: String?): AppDestination? {
if (route == null) return null
return destinations.firstOrNull { dest ->
fun setCurrentDestination(navBackStackEntry: NavBackStackEntry?) {
val route = navBackStackEntry?.destination?.route
val taskListId = navBackStackEntry?.arguments?.getLong("taskListId")
currentDestination = destinations.firstOrNull { dest ->
when (dest) {
is AppDestination.TaskList -> route.startsWith("tasklist/")
is AppDestination.TaskList -> taskListId != null && dest.taskListId == taskListId
else -> dest.route == route
}
}
} ?: startDestination
}
}

View File

@@ -9,6 +9,7 @@ import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.usecase.AddTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskListUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -19,6 +20,7 @@ import javax.inject.Inject
class ManageListsViewModel @Inject constructor(
getTaskListsUseCase: GetTaskListsUseCase,
private val addTaskListUseCase: AddTaskListUseCase,
private val updateTaskListUseCase: UpdateTaskListUseCase,
private val deleteTaskListUseCase: DeleteTaskListUseCase
) : ViewModel() {
@@ -38,6 +40,11 @@ class ManageListsViewModel @Inject constructor(
addTaskListUseCase(title, order)
}
}
fun updateTaskListName(taskList: TaskList) {
viewModelScope.launch {
updateTaskListUseCase(taskList.id!!, taskList.name, taskList.order)
}
}
fun deleteTaskList(taskId: Long) {
viewModelScope.launch {
deleteTaskListUseCase(taskId)