Refactor MainScreen and MainViewModel

Add list button now open bottom sheet
Add list bottom sheet works but design is WIP
WIP on inline list editing
This commit is contained in:
Geoffroy Bonneville
2025-09-17 18:18:14 -04:00
parent 0c5bf77b4d
commit f8fd041f8e
6 changed files with 202 additions and 80 deletions

View File

@@ -0,0 +1,18 @@
package com.wismna.geoffroy.donext.domain.model
sealed class AppDestination(
val route: String,
val title: String,
val showBackButton: Boolean = false,
) {
data class TaskList(val taskListId: Long, val name: String) : AppDestination(
route = "taskList/$taskListId",
title = name,
)
object ManageLists : AppDestination(
route = "manageLists",
title = "Manage Lists",
showBackButton = true,
)
}

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -39,45 +40,26 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
sealed class AppDestination(
val route: String,
val title: String,
val showBackButton: Boolean = false,
val actions: @Composable (() -> Unit)? = null
) {
data class TaskList(val taskListId: Long, val name: String) : AppDestination(
route = "taskList/$taskListId",
title = name,
)
object ManageLists : AppDestination(
route = "manageLists",
title = "Manage Lists",
showBackButton = true,
actions = { ManageListsActions() }
)
}
@Composable @Composable
fun MainScreen( fun MainScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MainViewModel = hiltViewModel() viewModel: MainViewModel = hiltViewModel()
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
var showBottomSheet by remember { mutableStateOf(false) } var showTaskSheet by remember { mutableStateOf(false) }
var showAddListSheet by remember { mutableStateOf(false) }
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val taskViewModel: TaskViewModel = hiltViewModel() val taskViewModel: TaskViewModel = hiltViewModel()
@@ -89,20 +71,22 @@ fun MainScreen(
return return
} }
val firstListId = viewModel.taskLists.firstOrNull()?.id val startDestination = viewModel.destinations.firstOrNull() ?: AppDestination.ManageLists
if (showBottomSheet) { if (showTaskSheet) {
TaskBottomSheet(taskViewModel) { showBottomSheet = false } TaskBottomSheet(taskViewModel) { showTaskSheet = false }
}
if (showAddListSheet) {
AddListBottomSheet { showAddListSheet = false }
} }
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = remember(navBackStackEntry, viewModel.taskLists) { val currentDestination =
deriveDestination(navBackStackEntry, viewModel.taskLists) viewModel.deriveDestination(navBackStackEntry?.destination?.route)
} ?: startDestination
ModalNavigationDrawer( ModalNavigationDrawer(
drawerContent = { drawerContent = {
MenuScreen ( MenuScreen (
taskLists = viewModel.taskLists,
currentDestination = currentDestination, currentDestination = currentDestination,
onNavigate = { route -> onNavigate = { route ->
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@@ -119,7 +103,7 @@ fun MainScreen(
containerColor = Color.Transparent, containerColor = Color.Transparent,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(currentDestination.title) }, title = { Text(currentDestination!!.title) },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
@@ -127,7 +111,7 @@ fun MainScreen(
), ),
navigationIcon = { navigationIcon = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
if (currentDestination.showBackButton) { if (currentDestination!!.showBackButton) {
IconButton(onClick = { navController.popBackStack() }) { IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") Icon(Icons.Default.ArrowBack, contentDescription = "Back")
} }
@@ -141,7 +125,13 @@ fun MainScreen(
} }
} }
}, },
actions = { currentDestination.actions?.invoke() } actions = {
if (currentDestination is AppDestination.ManageLists) {
IconButton(onClick = { showAddListSheet = true }) {
Icon(Icons.Default.Add, contentDescription = "Add List")
}
}
}
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -149,7 +139,7 @@ fun MainScreen(
is AppDestination.TaskList -> { is AppDestination.TaskList -> {
TaskListFab( TaskListFab(
taskListId = dest.taskListId, taskListId = dest.taskListId,
showBottomSheet = { showBottomSheet = it } showBottomSheet = { showTaskSheet = it }
) )
} }
else -> null else -> null
@@ -165,8 +155,7 @@ fun MainScreen(
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = firstListId?.let { "taskList/$it" } startDestination = startDestination.route,
?: AppDestination.ManageLists.route,
enterTransition = { enterTransition = {
slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300)) slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
}, },
@@ -180,7 +169,7 @@ fun MainScreen(
slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300)) slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
} }
) { ) {
viewModel.taskLists.forEach { list -> viewModel.destinations.forEach { destination ->
composable( composable(
route = "taskList/{taskListId}", route = "taskList/{taskListId}",
arguments = listOf(navArgument("taskListId") { arguments = listOf(navArgument("taskListId") {
@@ -192,40 +181,20 @@ fun MainScreen(
viewModel = viewModel, viewModel = viewModel,
onTaskClick = { task -> onTaskClick = { task ->
taskViewModel.startEditTask(task) taskViewModel.startEditTask(task)
showBottomSheet = true showTaskSheet = true
} }
) )
} }
} }
composable(AppDestination.ManageLists.route) { composable(AppDestination.ManageLists.route) {
ManageListsScreen(modifier = Modifier) ManageListsScreen(
modifier = Modifier,
showAddListSheet = {showAddListSheet = true}
)
} }
} }
} }
} }
} }
}
fun deriveDestination(
navBackStackEntry: NavBackStackEntry?,
taskLists: List<TaskListWithOverdue>
): AppDestination {
val route = navBackStackEntry?.destination?.route
return when {
route == AppDestination.ManageLists.route -> AppDestination.ManageLists
route?.startsWith("taskList/") == true || route == "taskList/{taskListId}" -> {
val idArg = navBackStackEntry.arguments?.getLong("taskListId")
val taskListId = idArg ?: route.substringAfter("taskList/", "").toLongOrNull()
val matching = taskLists.find { it.id == taskListId }
matching?.let { AppDestination.TaskList(it.id, it.name) }
?: taskLists.firstOrNull()?.let { AppDestination.TaskList(it.id, it.name) }
?: AppDestination.ManageLists
}
else -> {
taskLists.firstOrNull()?.let { AppDestination.TaskList(it.id, it.name) }
?: AppDestination.ManageLists
}
}
} }

View File

@@ -1,14 +1,20 @@
package com.wismna.geoffroy.donext.presentation.screen package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -16,24 +22,38 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.presentation.viewmodel.ManageListsViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.ManageListsViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ManageListsScreen( fun ManageListsScreen(
modifier: Modifier, modifier: Modifier,
viewModel: ManageListsViewModel = hiltViewModel() viewModel: ManageListsViewModel = hiltViewModel(),
showAddListSheet: () -> Unit
) { ) {
val lists = viewModel.taskLists val lists = viewModel.taskLists
LazyColumn(modifier = modifier.fillMaxWidth().padding()) { LazyColumn(modifier = modifier.fillMaxWidth().padding()) {
itemsIndexed(lists, key = { _, list -> list.id!! }) { index, list -> itemsIndexed(lists, key = { _, list -> list.id!! }) { index, list ->
ListItem( ListItem(
modifier = Modifier.animateItem(),
headlineContent = { Text(list.name) }, headlineContent = { Text(list.name) },
trailingContent = { trailingContent = {
Row { Row {
@@ -54,10 +74,83 @@ fun ManageListsScreen(
} }
@Composable @Composable
fun ManageListsActions( fun EditableListRow(
viewModel: ManageListsViewModel = hiltViewModel() list: TaskList,
onNameChange: (String) -> Unit,
//onTypeChange: (ListType) -> Unit,
onDone: () -> Unit
) { ) {
IconButton(onClick = { viewModel.createTaskList("Test", 1) }) { var name by remember { mutableStateOf(list.name) }
Icon(Icons.Default.Add, contentDescription = "Add List") //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(
viewModel: ManageListsViewModel = hiltViewModel(),
onDismiss: () -> Unit
) {
val titleFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
titleFocusRequester.requestFocus()
}
ModalBottomSheet(onDismissRequest = onDismiss) {
var name by remember { mutableStateOf("") }
//var type by remember { mutableStateOf(ListType.Default) }
//var description by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
Text("Create New List", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
TextField(
value = name,
onValueChange = { name = it },
label = { Text("List Name") },
singleLine = true
)
Spacer(Modifier.height(8.dp))
//DropdownSelector(selected = type, onSelect = { type = it })
/*Spacer(Modifier.height(8.dp))
TextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
maxLines = 3
)*/
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = onDismiss) { Text("Cancel") }
Spacer(Modifier.width(8.dp))
Button(onClick = {
viewModel.createTaskList(name/*, type, description*/, 1)
onDismiss()
}) {
Text("Add")
}
}
}
} }
} }

View File

@@ -18,11 +18,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.presentation.viewmodel.MenuViewModel
@Composable @Composable
fun MenuScreen( fun MenuScreen(
taskLists: List<TaskListWithOverdue>, viewModel: MenuViewModel = hiltViewModel(),
currentDestination: AppDestination, currentDestination: AppDestination,
onNavigate: (String) -> Unit onNavigate: (String) -> Unit
) { ) {
@@ -42,7 +44,7 @@ fun MenuScreen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
taskLists.forEach { list -> viewModel.taskLists.forEach { list ->
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(list.name) }, label = { Text(list.name) },
icon = { Icon(Icons.Default.List, contentDescription = list.name) }, icon = { Icon(Icons.Default.List, contentDescription = list.name) },

View File

@@ -5,8 +5,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsWithOverdueUseCase import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -14,23 +14,33 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase getTaskLists: GetTaskListsUseCase
) : ViewModel() { ) : ViewModel() {
var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set
/*val destinations: List<AppDestination>
get() = taskLists.map { AppDestination.TaskList(it.id, it.name) } +
AppDestination.ManageLists*/
var isLoading by mutableStateOf(true) var isLoading by mutableStateOf(true)
private set private set
var destinations by mutableStateOf<List<AppDestination>>(emptyList())
private set
init { init {
getTaskListsWithOverdue() getTaskLists()
.onEach { lists -> .onEach { lists ->
taskLists = lists destinations = lists.map { taskList ->
AppDestination.TaskList(taskList.id!!, taskList.name)
} + AppDestination.ManageLists
isLoading = false isLoading = false
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
fun deriveDestination(route: String?): AppDestination? {
if (route == null) return null
return destinations.firstOrNull { dest ->
when (dest) {
is AppDestination.TaskList -> route.startsWith("tasklist/")
else -> dest.route == route
}
}
}
} }

View File

@@ -0,0 +1,30 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsWithOverdueUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
@HiltViewModel
class MenuViewModel @Inject constructor(
getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase
) : ViewModel() {
var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set
init {
getTaskListsWithOverdue()
.onEach { lists ->
taskLists = lists
}
.launchIn(viewModelScope)
}
}