Replace tabs with navigation drawer

WIP on overdue task count on list names
This commit is contained in:
Geoffroy Bonneville
2025-09-14 19:37:28 -04:00
parent 83f441a618
commit 744d2afdc1
8 changed files with 137 additions and 61 deletions

View File

@@ -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<List<TaskListEntity>>
@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<List<TaskListWithOverdue>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTaskList(taskList: TaskListEntity)

View File

@@ -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<List<TaskListWithOverdue>> {
val todayMillis = LocalDate.now()
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
return taskListDao.getTaskListsWithOverdue(todayMillis)
}
}

View File

@@ -0,0 +1,7 @@
package com.wismna.geoffroy.donext.domain.model
data class TaskListWithOverdue(
val id: Long,
val name: String,
val overdueCount: Int
)

View File

@@ -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<List<TaskList>>
suspend fun insertTaskList(taskList: TaskList)
suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean)
fun getTaskListsWithOverdue(): Flow<List<TaskListWithOverdue>>
}

View File

@@ -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<List<TaskListWithOverdue>> {
return taskRepository.getTaskListsWithOverdue()
}
}

View File

@@ -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
})
}
}
}
}
}
}
}

View File

@@ -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) {

View File

@@ -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<List<TaskList>>(emptyList())
var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set
var isLoading by mutableStateOf(true)
private set
init {
getTaskLists()
getTaskListsWithOverdue()
.onEach { lists ->
taskLists = lists
isLoading = false