From 744d2afdc1468d65a4b8c68f05ad46d61eaa1408 Mon Sep 17 00:00:00 2001 From: Geoffroy Bonneville <24917789+wismna@users.noreply.github.com> Date: Sun, 14 Sep 2025 19:37:28 -0400 Subject: [PATCH] Replace tabs with navigation drawer WIP on overdue task count on list names --- .../donext/data/local/dao/TaskListDao.kt | 20 +++ .../local/repository/TaskRepositoryImpl.kt | 13 ++ .../domain/model/TaskListWIthOverdue.kt | 7 + .../domain/repository/TaskRepository.kt | 2 + .../usecase/GetTaskListsWithOverdueUseCase.kt | 14 ++ .../donext/presentation/screen/MainScreen.kt | 127 ++++++++++-------- .../presentation/screen/TaskListScreen.kt | 5 +- .../presentation/viewmodel/MainViewModel.kt | 10 +- 8 files changed, 137 insertions(+), 61 deletions(-) create mode 100644 donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskListWIthOverdue.kt create mode 100644 donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsWithOverdueUseCase.kt 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 affbde2..3cc608b 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 @@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import com.wismna.geoffroy.donext.data.entities.TaskListEntity +import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue import kotlinx.coroutines.flow.Flow @Dao @@ -13,6 +14,25 @@ interface TaskListDao { @Query("SELECT * FROM task_lists WHERE deleted = 0 ORDER BY display_order ASC") fun getTaskLists(): Flow> + @Query(""" + SELECT + tl.id AS id, + tl.name AS name, + COALESCE(SUM( + CASE + WHEN t.done = 0 + AND t.due_date IS NOT NULL + AND t.due_date < :today + THEN 1 + ELSE 0 + END + ), 0) AS overdueCount + FROM task_lists tl + LEFT JOIN tasks t ON t.task_list_id = tl.id + GROUP BY tl.id + """) + fun getTaskListsWithOverdue(today: Long): Flow> + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTaskList(taskList: TaskListEntity) diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskRepositoryImpl.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskRepositoryImpl.kt index c6518e1..00346ff 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskRepositoryImpl.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskRepositoryImpl.kt @@ -6,9 +6,12 @@ import com.wismna.geoffroy.donext.data.toDomain import com.wismna.geoffroy.donext.data.toEntity import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.TaskList +import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue import com.wismna.geoffroy.donext.domain.repository.TaskRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import java.time.LocalDate +import java.time.ZoneOffset import javax.inject.Inject class TaskRepositoryImpl @Inject constructor( @@ -47,4 +50,14 @@ class TaskRepositoryImpl @Inject constructor( taskDao.deleteAllTasksFromList(taskListId, isDeleted) taskListDao.deleteTaskList(taskListId, isDeleted) } + + override fun getTaskListsWithOverdue(): Flow> { + val todayMillis = LocalDate.now() + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() + + return taskListDao.getTaskListsWithOverdue(todayMillis) + } + } \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskListWIthOverdue.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskListWIthOverdue.kt new file mode 100644 index 0000000..6da12d1 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskListWIthOverdue.kt @@ -0,0 +1,7 @@ +package com.wismna.geoffroy.donext.domain.model + +data class TaskListWithOverdue( + val id: Long, + val name: String, + val overdueCount: Int +) \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskRepository.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskRepository.kt index 540c2c6..0ba1cb9 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskRepository.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskRepository.kt @@ -2,6 +2,7 @@ package com.wismna.geoffroy.donext.domain.repository import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.TaskList +import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue import kotlinx.coroutines.flow.Flow interface TaskRepository { @@ -14,4 +15,5 @@ interface TaskRepository { fun getTaskLists(): Flow> suspend fun insertTaskList(taskList: TaskList) suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) + fun getTaskListsWithOverdue(): Flow> } \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsWithOverdueUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsWithOverdueUseCase.kt new file mode 100644 index 0000000..336add0 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsWithOverdueUseCase.kt @@ -0,0 +1,14 @@ +package com.wismna.geoffroy.donext.domain.usecase + +import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue +import com.wismna.geoffroy.donext.domain.repository.TaskRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetTaskListsWithOverdueUseCase @Inject constructor( + private val taskRepository: TaskRepository +) { + operator fun invoke(): Flow> { + return taskRepository.getTaskListsWithOverdue() + } +} \ 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 68b01c3..8aad423 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 @@ -7,29 +7,37 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Menu import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.IconButton +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.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable 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.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -39,6 +47,7 @@ import androidx.navigation.navArgument import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel +import kotlinx.coroutines.launch @Composable fun MainScreen( @@ -57,70 +66,82 @@ fun MainScreen( 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() if (showBottomSheet) { TaskBottomSheet(taskViewModel, { showBottomSheet = false }) } - - Scaffold( - modifier = modifier, - floatingActionButton = { - AddNewTaskButton { - val currentListId = viewModel.taskLists[selectedDestination].id - taskViewModel.startNewTask(currentListId) - showBottomSheet = true - } - }, topBar = { - PrimaryTabRow(selectedTabIndex = selectedDestination) { - viewModel.taskLists.forEachIndexed { index, destination -> - Tab( + ModalNavigationDrawer( + drawerContent = { + ModalDrawerSheet { + Text( + text = "Task Lists", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp) + ) + viewModel.taskLists.forEachIndexed { index, list -> + NavigationDrawerItem( + label = { Text(list.name) }, selected = selectedDestination == index, onClick = { - navController.navigate(route = "taskList/${destination.id}") selectedDestination = index + scope.launch { drawerState.close() } }, - text = { - /*BadgedBox( - badge = { - if (overdueCount > 0) { - Badge { Text(overdueCount.toString()) } - } + badge = { + if (list.overdueCount > 0) { + Badge { + Text(list.overdueCount.toString()) } - ) {*/ - Text( - text = destination.name, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - //} - } + } + }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) ) } } - }) { contentPadding -> - Box(modifier = Modifier - .padding(contentPadding) - .fillMaxSize() - ) { - NavHost( - navController, - startDestination = "taskList/${startDestination.id}" - ) { - viewModel.taskLists.forEach { destination -> - composable( - route = "taskList/{taskListId}", - arguments = listOf(navArgument("taskListId") { - type = NavType.LongType - }) - ) { - TaskListScreen( - onTaskClick = { task -> - taskViewModel.startEditTask(task) - showBottomSheet = true + }, + drawerState = drawerState + ) { + Scaffold( + modifier = modifier, + floatingActionButton = { + AddNewTaskButton { + val currentListId = viewModel.taskLists[selectedDestination].id + taskViewModel.startNewTask(currentListId) + showBottomSheet = true + } + }, topBar = { + // TODO: add list title + // TODO: add button such as edit and delete + TopAppBar( + title = { Text(viewModel.taskLists.getOrNull(selectedDestination)?.name ?: "Tasks") }, + navigationIcon = { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + 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 + }) + } } } - } } } } 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 704fa80..093ae3f 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 @@ -4,7 +4,6 @@ import androidx.compose.foundation.clickable 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 @@ -34,11 +33,11 @@ import java.time.LocalDate import java.time.ZoneOffset @Composable -fun TaskListScreen(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() + //modifier = Modifier.fillMaxSize() ) { itemsIndexed(tasks, key = { _, task -> task.id!! }) { index, task -> if (index > 0) { 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 5e80845..2e70238 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 @@ -5,8 +5,8 @@ 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.GetTaskListsUseCase +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 @@ -14,16 +14,16 @@ import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( - getTaskLists: GetTaskListsUseCase + getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase ) : ViewModel() { - var taskLists by mutableStateOf>(emptyList()) + var taskLists by mutableStateOf>(emptyList()) private set var isLoading by mutableStateOf(true) private set init { - getTaskLists() + getTaskListsWithOverdue() .onEach { lists -> taskLists = lists isLoading = false