From 926a9bf66b14a65422580c83947ff5d179368b79 Mon Sep 17 00:00:00 2001 From: Geoffroy Bonneville <24917789+wismna@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:18:01 -0400 Subject: [PATCH] Add create task lists button (WIP) Implement delete task list button Set theme colors Add animations Refactor main screen Refactor task item with a view model Put Edit Lists at the bottom of the Navigation Drawer --- .../geoffroy/donext/DonextApplication.kt | 3 +- .../wismna/geoffroy/donext/data/Mappers.kt | 2 +- .../geoffroy/donext/data/local/dao/TaskDao.kt | 2 +- .../donext/data/local/dao/TaskListDao.kt | 1 + .../geoffroy/donext/domain/model/TaskList.kt | 2 +- .../domain/usecase/AddTaskListUseCase.kt | 19 ++ .../domain/usecase/DeleteTaskListUseCase.kt | 12 + .../donext/presentation/screen/MainScreen.kt | 305 +++++++++--------- .../presentation/screen/ManageListsScreen.kt | 32 +- .../donext/presentation/screen/MenuScreen.kt | 74 +++++ .../presentation/screen/TaskItemScreen.kt | 132 ++++++++ .../presentation/screen/TaskListScreen.kt | 155 ++------- .../donext/presentation/screen/TaskScreen.kt | 25 ++ .../presentation/viewmodel/MainViewModel.kt | 25 +- .../viewmodel/ManageListsViewModel.kt | 46 +++ .../viewmodel/TaskItemViewModel.kt | 44 +++ .../viewmodel/TaskListViewModel.kt | 1 - 17 files changed, 566 insertions(+), 314 deletions(-) create mode 100644 donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/AddTaskListUseCase.kt create mode 100644 donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskListUseCase.kt create mode 100644 donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MenuScreen.kt create mode 100644 donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskItemScreen.kt create mode 100644 donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/ManageListsViewModel.kt create mode 100644 donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModel.kt diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/DonextApplication.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/DonextApplication.kt index 49da01c..82db6c8 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/DonextApplication.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/DonextApplication.kt @@ -4,5 +4,4 @@ import android.app.Application import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp -class DonextApplication: Application() { -} \ No newline at end of file +class DonextApplication: Application() \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/Mappers.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/Mappers.kt index 7ead5f0..7b6dc41 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/Mappers.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/Mappers.kt @@ -35,7 +35,7 @@ fun TaskListEntity.toDomain() = TaskList( ) fun TaskList.toEntity() = TaskListEntity( - id = id, + id = id ?: 0, name = name, isDeleted = isDeleted, order = order diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt index 64497fe..f91da92 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt @@ -25,6 +25,6 @@ interface TaskDao { @Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskId") suspend fun markTaskDeleted(taskId: Long, deleted: Boolean) - @Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskListId") + @Query("UPDATE tasks SET deleted = :deleted WHERE task_list_id = :taskListId") suspend fun deleteAllTasksFromList(taskListId: Long, deleted: Boolean) } \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDao.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDao.kt index 45587f3..285d8ec 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDao.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDao.kt @@ -29,6 +29,7 @@ interface TaskListDao { ), 0) AS overdueCount FROM task_lists tl LEFT JOIN tasks t ON t.task_list_id = tl.id + WHERE tl.deleted = 0 GROUP BY tl.id """) fun getTaskListsWithOverdue(nowMillis: Long): Flow> diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskList.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskList.kt index dc88650..d05670f 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskList.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskList.kt @@ -1,7 +1,7 @@ package com.wismna.geoffroy.donext.domain.model data class TaskList( - val id: Long, + val id: Long? = null, val name: String, val isDeleted: Boolean, val order: Int diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/AddTaskListUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/AddTaskListUseCase.kt new file mode 100644 index 0000000..b210810 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/AddTaskListUseCase.kt @@ -0,0 +1,19 @@ +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 AddTaskListUseCase @Inject constructor( + private val repository: TaskRepository +) { + suspend operator fun invoke(title: String, order: Int) { + repository.insertTaskList( + TaskList( + name = title, + order = order, + isDeleted = false + ) + ) + } +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskListUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskListUseCase.kt new file mode 100644 index 0000000..f77d347 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskListUseCase.kt @@ -0,0 +1,12 @@ +package com.wismna.geoffroy.donext.domain.usecase + +import com.wismna.geoffroy.donext.domain.repository.TaskRepository +import javax.inject.Inject + +class DeleteTaskListUseCase@Inject constructor( + private val repository: TaskRepository +) { + suspend operator fun invoke(taskListId: Long) { + repository.deleteTaskList(taskListId, true) + } +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt index c485a0b..b15e434 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt @@ -2,220 +2,231 @@ package com.wismna.geoffroy.donext.presentation.screen +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape 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.Edit -import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Menu -import androidx.compose.material3.Badge import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.NavigationDrawerItem -import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable 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.NavBackStackEntry import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument -import com.wismna.geoffroy.donext.domain.model.Priority +import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue 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.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 fun MainScreen( modifier: Modifier = Modifier, - viewModel: MainViewModel = hiltViewModel()) { + viewModel: MainViewModel = hiltViewModel() +) { val navController = rememberNavController() var showBottomSheet by remember { mutableStateOf(false) } + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + val taskViewModel: TaskViewModel = hiltViewModel() if (viewModel.isLoading) { - // Show loading or empty state Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - } else { - val taskViewModel: TaskViewModel = hiltViewModel() - val startDestination = viewModel.taskLists[0] - // TODO: get last opened tab from saved settings - var selectedDestination by rememberSaveable { mutableIntStateOf(0) } - val drawerState = rememberDrawerState(DrawerValue.Closed) - val scope = rememberCoroutineScope() - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination?.route - val isManageLists = currentDestination == "manageLists" + return + } - if (showBottomSheet) { - TaskBottomSheet(taskViewModel, { showBottomSheet = false }) - } - ModalNavigationDrawer( - drawerContent = { - ModalDrawerSheet { - Text( - text = "Task Lists", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp) - ) - viewModel.taskLists.forEachIndexed { index, destination -> - NavigationDrawerItem( - label = { Text(destination.name) }, - icon = { - Icon( - imageVector = Icons.Default.List, - contentDescription = destination.name - )}, - selected = selectedDestination == index, - onClick = { - scope.launch { drawerState.close() } - navController.navigate(route = "taskList/${destination.id}") - selectedDestination = index - }, - badge = { - if (destination.overdueCount > 0) { - Badge { - Text(destination.overdueCount.toString()) - } - } - }, - modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) - ) + val firstListId = viewModel.taskLists.firstOrNull()?.id + if (showBottomSheet) { + TaskBottomSheet(taskViewModel) { showBottomSheet = false } + } + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = remember(navBackStackEntry, viewModel.taskLists) { + deriveDestination(navBackStackEntry, viewModel.taskLists) + } + + ModalNavigationDrawer( + drawerContent = { + MenuScreen ( + taskLists = viewModel.taskLists, + currentDestination = currentDestination, + onNavigate = { route -> + scope.launch { drawerState.close() } + navController.navigate(route) { + //launchSingleTop = true + restoreState = true } - - HorizontalDivider(modifier = Modifier) - NavigationDrawerItem( - label = { Text("Edit Lists") }, - icon = { Icon(Icons.Default.Edit, contentDescription = "Edit Lists") }, - selected = false, - onClick = { - scope.launch { drawerState.close() } - navController.navigate("manageLists") - }, - modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) - ) } - }, - drawerState = drawerState - ) { - Scaffold( - modifier = modifier, - floatingActionButton = { - if (!isManageLists) { - AddNewTaskButton { - val currentListId = viewModel.taskLists[selectedDestination].id - taskViewModel.startNewTask(currentListId) - showBottomSheet = true - } - } - }, topBar = { - TopAppBar( - title = { - Text( - if (isManageLists) "Manage Lists" - else viewModel.taskLists.getOrNull(selectedDestination)?.name ?: "Tasks" - ) - }, - navigationIcon = { - if (isManageLists) { + ) + }, + 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") + Icon( + Icons.Default.Menu, + contentDescription = "Open navigation drawer" + ) } } } - ) - }) { contentPadding -> - NavHost( - navController, - startDestination = "taskList/${startDestination.id}" - ) { - viewModel.taskLists.forEach { destination -> - composable( - route = "taskList/{taskListId}", - arguments = listOf(navArgument("taskListId") { - type = NavType.LongType - }) - ) { - TaskListScreen( - modifier = Modifier.padding(contentPadding), - onTaskClick = { task -> - taskViewModel.startEditTask(task) - showBottomSheet = true - }) - } - } - composable("manageLists") { - ManageListsScreen( - modifier = Modifier.padding(contentPadding), - onBackClick = { navController.popBackStack() } + }, + actions = { currentDestination.actions?.invoke() } + ) + }, + floatingActionButton = { + when (val dest = currentDestination) { + is AppDestination.TaskList -> { + TaskListFab( + taskListId = dest.taskListId, + showBottomSheet = { showBottomSheet = 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 = firstListId?.let { "taskList/$it" } + ?: AppDestination.ManageLists.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.taskLists.forEach { list -> + 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) + showBottomSheet = true + } ) } } + + composable(AppDestination.ManageLists.route) { + ManageListsScreen(modifier = Modifier) + } + } } } } } -@Composable -fun AddNewTaskButton(onClick: () -> Unit) { - ExtendedFloatingActionButton( - onClick = onClick, - icon = { Icon(Icons.Filled.Add, "Create a task.") }, - text = { Text(text = "Create a task") }, - ) -} +fun deriveDestination( + navBackStackEntry: NavBackStackEntry?, + taskLists: List +): AppDestination { + val route = navBackStackEntry?.destination?.route -@Composable -fun SingleChoiceSegmentedButton( - value: Priority, - onValueChange: (Priority) -> Unit) { - val options = listOf(Priority.LOW.label, Priority.NORMAL.label, Priority.HIGH.label) - - SingleChoiceSegmentedButtonRow { - options.forEachIndexed { index, label -> - SegmentedButton( - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = options.size - ), - onClick = { onValueChange(Priority.fromValue(index)) }, - selected = index == value.value, - label = { Text(label) } - ) + 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 } } } \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/ManageListsScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/ManageListsScreen.kt index cb702fe..0c4b3c8 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/ManageListsScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/ManageListsScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.ExperimentalMaterial3Api @@ -13,32 +14,36 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel +import com.wismna.geoffroy.donext.presentation.viewmodel.ManageListsViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun ManageListsScreen( modifier: Modifier, - viewModel: MainViewModel = hiltViewModel(), - onBackClick: () -> Unit + viewModel: ManageListsViewModel = hiltViewModel() ) { val lists = viewModel.taskLists LazyColumn(modifier = modifier.fillMaxWidth().padding()) { - itemsIndexed(lists, key = { _, list -> list.id }) { index, list -> + itemsIndexed(lists, key = { _, list -> list.id!! }) { index, list -> ListItem( headlineContent = { Text(list.name) }, trailingContent = { Row { - IconButton(onClick = { /* TODO: edit list */ }) { - Icon(Icons.Default.Edit, contentDescription = "Edit") - } - IconButton(onClick = { /* TODO: delete list */ }) { - Icon(Icons.Default.Delete, contentDescription = "Delete") + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) { + IconButton(onClick = { /* TODO: edit list */ }) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + IconButton(onClick = { viewModel.deleteTaskList(list.id!!) }) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } } } } @@ -47,3 +52,12 @@ fun ManageListsScreen( } } } + +@Composable +fun ManageListsActions( + viewModel: ManageListsViewModel = hiltViewModel() +) { + IconButton(onClick = { viewModel.createTaskList("Test", 1) }) { + Icon(Icons.Default.Add, contentDescription = "Add List") + } +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MenuScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MenuScreen.kt new file mode 100644 index 0000000..db7bd29 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MenuScreen.kt @@ -0,0 +1,74 @@ +package com.wismna.geoffroy.donext.presentation.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.List +import androidx.compose.material3.Badge +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue + +@Composable +fun MenuScreen( + taskLists: List, + currentDestination: AppDestination, + onNavigate: (String) -> Unit +) { + ModalDrawerSheet( + drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant, + drawerContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "Task Lists", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp) + ) + taskLists.forEach { list -> + NavigationDrawerItem( + label = { Text(list.name) }, + icon = { Icon(Icons.Default.List, contentDescription = list.name) }, + selected = currentDestination is AppDestination.TaskList && + currentDestination.taskListId == list.id, + onClick = { onNavigate("taskList/${list.id}") }, + badge = { + if (list.overdueCount > 0) { + Badge { Text(list.overdueCount.toString()) } + } + }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + } + } + + Column { + HorizontalDivider() + NavigationDrawerItem( + label = { Text("Edit Lists") }, + icon = { Icon(Icons.Default.Edit, contentDescription = "Edit Lists") }, + selected = currentDestination is AppDestination.ManageLists, + onClick = { onNavigate(AppDestination.ManageLists.route) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + } + } + } +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskItemScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskItemScreen.kt new file mode 100644 index 0000000..3e3e845 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskItemScreen.kt @@ -0,0 +1,132 @@ +package com.wismna.geoffroy.donext.presentation.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.wismna.geoffroy.donext.domain.model.Priority +import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel + +@Composable +fun TaskItemScreen( + modifier: Modifier = Modifier, + viewModel: TaskItemViewModel, + onClick: () -> Unit, + onToggleDone: (Boolean) -> Unit +) { + val baseStyle = MaterialTheme.typography.bodyLarge.copy( + fontWeight = when (viewModel.priority) { + Priority.HIGH -> FontWeight.Bold + Priority.NORMAL -> FontWeight.Normal + Priority.LOW -> FontWeight.Normal + }, + color = when (viewModel.priority) { + Priority.HIGH -> MaterialTheme.colorScheme.onSurface + Priority.NORMAL -> MaterialTheme.colorScheme.onSurface + Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant + }, + textDecoration = if (viewModel.isDone) TextDecoration.LineThrough else TextDecoration.None) + + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(8.dp) + .alpha(if (viewModel.isDone || viewModel.priority == Priority.LOW) 0.5f else 1f), + verticalAlignment = Alignment.CenterVertically // centers checkbox + content + ) { + // Done checkbox + Checkbox( + checked = viewModel.isDone, + onCheckedChange = onToggleDone, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + ) + + Box( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + .height(IntrinsicSize.Min) // shrink to fit title/description + ) { + // Title + Text( + text = viewModel.name, + style = baseStyle, + modifier = Modifier + .align( + if (viewModel.description.isNullOrBlank()) Alignment.CenterStart + else Alignment.TopStart + ) + ) + + // Due date badge + viewModel.dueDateText?.let { dueMillis -> + Box( + modifier = Modifier + .align( + if (viewModel.description.isNullOrBlank()) Alignment.CenterEnd + else Alignment.TopEnd + ) + .background( + color = if (viewModel.isOverdue) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = viewModel.dueDateText, + style = MaterialTheme.typography.bodySmall, + color = if (viewModel.isOverdue) Color.White else MaterialTheme.colorScheme.primary + ) + } + } + + // Optional description + this@Row.AnimatedVisibility( + visible = !viewModel.description.isNullOrBlank(), + modifier = Modifier.align(Alignment.BottomStart), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Text( + text = viewModel.description!!, + style = baseStyle.copy(fontSize = MaterialTheme.typography.bodyMedium.fontSize), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(top = 20.dp) // spacing below title + ) + } + + } + } +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt index 8919300..ad3c020 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt @@ -1,55 +1,38 @@ package com.wismna.geoffroy.donext.presentation.screen -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Checkbox +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.domain.model.Task +import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.format.TextStyle -import java.util.Locale +import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel @Composable -fun TaskListScreen(modifier: Modifier = Modifier, viewModel: TaskListViewModel = hiltViewModel(), onTaskClick: (Task) -> Unit) { +fun TaskListScreen( + modifier: Modifier = Modifier, + viewModel: TaskListViewModel = hiltViewModel(), + onTaskClick: (Task) -> Unit) { val tasks = viewModel.tasks LazyColumn( modifier = modifier.fillMaxSize().padding() ) { - itemsIndexed(tasks, key = { _, task -> task.id!! }) { index, task -> + itemsIndexed(tasks, key = { id, task -> task.id!! }) { index, task -> if (index > 0) { val prev = tasks[index - 1] @@ -70,8 +53,9 @@ fun TaskListScreen(modifier: Modifier = Modifier, viewModel: TaskListViewModel = } } - TaskItem( - task = task, + TaskItemScreen( + modifier = Modifier.animateItem(), + viewModel = TaskItemViewModel(task), onClick = { onTaskClick(task) }, onToggleDone = { isChecked -> viewModel.updateTaskDone(task.id!!, isChecked) @@ -82,104 +66,17 @@ fun TaskListScreen(modifier: Modifier = Modifier, viewModel: TaskListViewModel = } @Composable -fun TaskItem( - task: Task, - onClick: () -> Unit, - onToggleDone: (Boolean) -> Unit +fun TaskListFab( + taskListId: Long, + viewModel: TaskViewModel = hiltViewModel(), + showBottomSheet: (Boolean) -> Unit = {} ) { - val today = remember { - LocalDate.now(ZoneOffset.UTC) - } - val isOverdue = task.dueDate?.let { millis -> - val dueDate = Instant.ofEpochMilli(millis) - .atZone(ZoneOffset.UTC) - .toLocalDate() - dueDate.isBefore(today) - } ?: false - - val baseStyle = MaterialTheme.typography.bodyLarge.copy( - fontWeight = when (task.priority) { - Priority.HIGH -> FontWeight.Bold - Priority.NORMAL -> FontWeight.Normal - Priority.LOW -> FontWeight.Normal - }, - color = when (task.priority) { - Priority.HIGH -> MaterialTheme.colorScheme.onSurface - Priority.NORMAL -> MaterialTheme.colorScheme.onSurface - Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant - }, - textDecoration = if (task.isDone) TextDecoration.LineThrough else TextDecoration.None) - - val dueText = task.dueDate?.let { formatTaskDueDate(it) } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onClick() } - .padding(8.dp) - .alpha(if (task.isDone || task.priority == Priority.LOW) 0.5f else 1f), - ) { - Checkbox( - checked = task.isDone, - onCheckedChange = onToggleDone, - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - ) - - Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween) { - Text( - text = task.name, - style = baseStyle - ) - // Due date badge - dueText?.let { - Box( - modifier = Modifier - .background( - color = if (isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary.copy( - alpha = 0.1f - ), - shape = RoundedCornerShape(12.dp) - ) - .padding(horizontal = 8.dp, vertical = 2.dp) - ) { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = if (isOverdue) Color.White else MaterialTheme.colorScheme.primary - ) - } - } - } - - if (!task.description.isNullOrBlank()) { - Text( - text = task.description, - style = baseStyle.copy( - fontSize = MaterialTheme.typography.bodyMedium.fontSize - ), - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - } - } -} - -private fun formatTaskDueDate(dueMillis: Long): String { - val today = LocalDate.now() - val dueDate = Instant.ofEpochMilli(dueMillis).atZone(ZoneOffset.UTC).toLocalDate() - - return when { - dueDate.isEqual(today) -> "Today" - dueDate.isEqual(today.plusDays(1)) -> "Tomorrow" - dueDate.isEqual(today.minusDays(1)) -> "Yesterday" - dueDate.isAfter(today) && dueDate.isBefore(today.plusDays(7)) -> - dueDate.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()) - else -> - dueDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault())) - } + ExtendedFloatingActionButton( + onClick = { + viewModel.startNewTask(taskListId) + showBottomSheet(true) + }, + icon = { Icon(Icons.Filled.Add, "Create a task.") }, + text = { Text("Create a task") }, + ) } \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt index 7617cf6..5e11f93 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt @@ -20,7 +20,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SelectableDates +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState @@ -35,6 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp +import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel import java.time.Instant import java.time.LocalDate @@ -191,4 +195,25 @@ fun TaskBottomSheet( } } } +} + +@Composable +fun SingleChoiceSegmentedButton( + value: Priority, + onValueChange: (Priority) -> Unit) { + val options = listOf(Priority.LOW.label, Priority.NORMAL.label, Priority.HIGH.label) + + SingleChoiceSegmentedButtonRow { + options.forEachIndexed { index, label -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = options.size + ), + onClick = { onValueChange(Priority.fromValue(index)) }, + selected = index == value.value, + label = { Text(label) } + ) + } + } } \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt index c1c06c5..2699f1a 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt @@ -12,27 +12,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import javax.inject.Inject -sealed class AppDestination( - val route: String, - val title: String, - val showFab: Boolean = false, - val showBackButton: Boolean = false -) { - data class TaskList(val taskListId: Long, val name: String) : AppDestination( - route = "taskList/$taskListId", - title = name, - showFab = true, - showBackButton = false - ) - - object ManageLists : AppDestination( - route = "manageLists", - title = "Manage Lists", - showFab = false, - showBackButton = true - ) -} - @HiltViewModel class MainViewModel @Inject constructor( getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase @@ -40,9 +19,9 @@ class MainViewModel @Inject constructor( var taskLists by mutableStateOf>(emptyList()) private set - val destinations: List + /*val destinations: List get() = taskLists.map { AppDestination.TaskList(it.id, it.name) } + - AppDestination.ManageLists + AppDestination.ManageLists*/ var isLoading by mutableStateOf(true) private set diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/ManageListsViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/ManageListsViewModel.kt new file mode 100644 index 0000000..ae9d3b8 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/ManageListsViewModel.kt @@ -0,0 +1,46 @@ +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.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 dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ManageListsViewModel @Inject constructor( + getTaskListsUseCase: GetTaskListsUseCase, + private val addTaskListUseCase: AddTaskListUseCase, + private val deleteTaskListUseCase: DeleteTaskListUseCase +) : ViewModel() { + + var taskLists by mutableStateOf>(emptyList()) + private set + + init { + getTaskListsUseCase() + .onEach { lists -> + taskLists = lists + } + .launchIn(viewModelScope) + } + + fun createTaskList(title: String, order: Int) { + viewModelScope.launch { + addTaskListUseCase(title, order) + } + } + fun deleteTaskList(taskId: Long) { + viewModelScope.launch { + deleteTaskListUseCase(taskId) + } + } +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModel.kt new file mode 100644 index 0000000..5b2e310 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModel.kt @@ -0,0 +1,44 @@ +package com.wismna.geoffroy.donext.presentation.viewmodel + +import com.wismna.geoffroy.donext.domain.model.Priority +import com.wismna.geoffroy.donext.domain.model.Task +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.format.TextStyle +import java.util.Locale + +class TaskItemViewModel(task: Task) { + val id: Long = task.id!! + val name: String = task.name + val description: String? = task.description + val isDone: Boolean = task.isDone + val priority: Priority = task.priority + + val today: LocalDate = LocalDate.now(ZoneOffset.UTC) + + val isOverdue: Boolean = task.dueDate?.let { millis -> + val dueDate = Instant.ofEpochMilli(millis) + .atZone(ZoneOffset.UTC) + .toLocalDate() + dueDate.isBefore(today) + } ?: false + + val dueDateText: String? = task.dueDate?.let { formatDueDate(it) } + + private fun formatDueDate(dueMillis: Long): String { + val dueDate = Instant.ofEpochMilli(dueMillis).atZone(ZoneOffset.UTC).toLocalDate() + + return when { + dueDate.isEqual(today) -> "Today" + dueDate.isEqual(today.plusDays(1)) -> "Tomorrow" + dueDate.isEqual(today.minusDays(1)) -> "Yesterday" + dueDate.isAfter(today) && dueDate.isBefore(today.plusDays(7)) -> + dueDate.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()) + else -> + dueDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault())) + } + } +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModel.kt index f4874bc..d46b2a2 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModel.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModel.kt @@ -24,7 +24,6 @@ class TaskListViewModel @Inject constructor( var tasks by mutableStateOf>(emptyList()) private set - var isLoading by mutableStateOf(true) private set