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.Query
import androidx.room.Update import androidx.room.Update
import com.wismna.geoffroy.donext.data.entities.TaskListEntity import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@@ -13,6 +14,25 @@ interface TaskListDao {
@Query("SELECT * FROM task_lists WHERE deleted = 0 ORDER BY display_order ASC") @Query("SELECT * FROM task_lists WHERE deleted = 0 ORDER BY display_order ASC")
fun getTaskLists(): Flow<List<TaskListEntity>> 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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTaskList(taskList: TaskListEntity) 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.data.toEntity
import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskList 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 com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject import javax.inject.Inject
class TaskRepositoryImpl @Inject constructor( class TaskRepositoryImpl @Inject constructor(
@@ -47,4 +50,14 @@ class TaskRepositoryImpl @Inject constructor(
taskDao.deleteAllTasksFromList(taskListId, isDeleted) taskDao.deleteAllTasksFromList(taskListId, isDeleted)
taskListDao.deleteTaskList(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.Task
import com.wismna.geoffroy.donext.domain.model.TaskList import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface TaskRepository { interface TaskRepository {
@@ -14,4 +15,5 @@ interface TaskRepository {
fun getTaskLists(): Flow<List<TaskList>> fun getTaskLists(): Flow<List<TaskList>>
suspend fun insertTaskList(taskList: TaskList) suspend fun insertTaskList(taskList: TaskList)
suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) 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.foundation.layout.padding
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.Add
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon 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.Scaffold
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost 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.domain.model.Priority
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import kotlinx.coroutines.launch
@Composable @Composable
fun MainScreen( fun MainScreen(
@@ -57,70 +66,82 @@ fun MainScreen(
val startDestination = viewModel.taskLists[0] val startDestination = viewModel.taskLists[0]
// TODO: get last opened tab from saved settings // TODO: get last opened tab from saved settings
var selectedDestination by rememberSaveable { mutableIntStateOf(0) } var selectedDestination by rememberSaveable { mutableIntStateOf(0) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
if (showBottomSheet) { if (showBottomSheet) {
TaskBottomSheet(taskViewModel, { showBottomSheet = false }) TaskBottomSheet(taskViewModel, { showBottomSheet = false })
} }
ModalNavigationDrawer(
Scaffold( drawerContent = {
modifier = modifier, ModalDrawerSheet {
floatingActionButton = { Text(
AddNewTaskButton { text = "Task Lists",
val currentListId = viewModel.taskLists[selectedDestination].id style = MaterialTheme.typography.titleMedium,
taskViewModel.startNewTask(currentListId) modifier = Modifier.padding(16.dp)
showBottomSheet = true )
} viewModel.taskLists.forEachIndexed { index, list ->
}, topBar = { NavigationDrawerItem(
PrimaryTabRow(selectedTabIndex = selectedDestination) { label = { Text(list.name) },
viewModel.taskLists.forEachIndexed { index, destination ->
Tab(
selected = selectedDestination == index, selected = selectedDestination == index,
onClick = { onClick = {
navController.navigate(route = "taskList/${destination.id}")
selectedDestination = index selectedDestination = index
scope.launch { drawerState.close() }
}, },
text = { badge = {
/*BadgedBox( if (list.overdueCount > 0) {
badge = { Badge {
if (overdueCount > 0) { Text(list.overdueCount.toString())
Badge { Text(overdueCount.toString()) }
}
} }
) {*/ }
Text( },
text = destination.name, modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
//}
}
) )
} }
} }
}) { contentPadding -> },
Box(modifier = Modifier drawerState = drawerState
.padding(contentPadding) ) {
.fillMaxSize() Scaffold(
) { modifier = modifier,
NavHost( floatingActionButton = {
navController, AddNewTaskButton {
startDestination = "taskList/${startDestination.id}" val currentListId = viewModel.taskLists[selectedDestination].id
) { taskViewModel.startNewTask(currentListId)
viewModel.taskLists.forEach { destination -> showBottomSheet = true
composable( }
route = "taskList/{taskListId}", }, topBar = {
arguments = listOf(navArgument("taskListId") { // TODO: add list title
type = NavType.LongType // TODO: add button such as edit and delete
}) TopAppBar(
) { title = { Text(viewModel.taskLists.getOrNull(selectedDestination)?.name ?: "Tasks") },
TaskListScreen( navigationIcon = {
onTaskClick = { task -> IconButton(onClick = { scope.launch { drawerState.open() } }) {
taskViewModel.startEditTask(task) Icon(Icons.Default.Menu, contentDescription = "Open navigation drawer")
showBottomSheet = true }
}
)
}) { 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -34,11 +33,11 @@ import java.time.LocalDate
import java.time.ZoneOffset import java.time.ZoneOffset
@Composable @Composable
fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel(), onTaskClick: (Task) -> Unit) { fun TaskListScreen(modifier: Modifier = Modifier, viewModel: TaskListViewModel = hiltViewModel(), onTaskClick: (Task) -> Unit) {
val tasks = viewModel.tasks val tasks = viewModel.tasks
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize() //modifier = Modifier.fillMaxSize()
) { ) {
itemsIndexed(tasks, key = { _, task -> task.id!! }) { index, task -> itemsIndexed(tasks, key = { _, task -> task.id!! }) { index, task ->
if (index > 0) { if (index > 0) {

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.TaskList import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsWithOverdueUseCase
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,16 +14,16 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
getTaskLists: GetTaskListsUseCase getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase
) : ViewModel() { ) : ViewModel() {
var taskLists by mutableStateOf<List<TaskList>>(emptyList()) var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set private set
var isLoading by mutableStateOf(true) var isLoading by mutableStateOf(true)
private set private set
init { init {
getTaskLists() getTaskListsWithOverdue()
.onEach { lists -> .onEach { lists ->
taskLists = lists taskLists = lists
isLoading = false isLoading = false