Add the Due Today screen

This commit is contained in:
Geoffroy Bonneville
2025-09-24 16:09:24 -04:00
parent 2d4be63d81
commit 208f8bab3a
14 changed files with 275 additions and 12 deletions

View File

@@ -87,4 +87,75 @@ class TaskDaoTest {
TestCase.assertEquals(1, lists.first().first().overdueCount)
}
@Test
fun dueToday_correctlyCalculated() = runBlocking {
listDao.insertTaskList(TaskListEntity(name = "Tasks", order = 0))
val listId = listDao.getTaskLists().first().first().id
val todayStart = Instant.parse("2025-09-15T00:00:00Z").toEpochMilli()
val todayEnd = Instant.parse("2025-09-15T23:59:99Z").toEpochMilli()
// One task due yesterday
taskDao.insertTask(
TaskEntity(
name = "Yesterday",
taskListId = listId,
dueDate = Instant.parse("2025-09-14T12:00:00Z").toEpochMilli(),
isDone = false,
description = null,
priority = Priority.NORMAL
)
)
// One task due today
taskDao.insertTask(
TaskEntity(
name = "Today",
taskListId = listId,
dueDate = Instant.parse("2025-09-15T12:00:00Z").toEpochMilli(),
isDone = false,
description = null,
priority = Priority.NORMAL
)
)
// One task due in the future
taskDao.insertTask(
TaskEntity(
name = "Tomorrow",
taskListId = listId,
dueDate = Instant.parse("2025-09-16T12:00:00Z").toEpochMilli(),
isDone = false,
description = null,
priority = Priority.NORMAL
)
)
// One task due in the future
taskDao.insertTask(
TaskEntity(
name = "TodayDone",
taskListId = listId,
dueDate = Instant.parse("2025-09-15T12:00:00Z").toEpochMilli(),
isDone = true,
description = null,
priority = Priority.NORMAL
)
)
// One task due in the future
taskDao.insertTask(
TaskEntity(
name = "TodayDeleted",
taskListId = listId,
dueDate = Instant.parse("2025-09-15T12:00:00Z").toEpochMilli(),
isDone = false,
isDeleted = true,
description = null,
priority = Priority.NORMAL
)
)
val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd)
TestCase.assertEquals(1, tasks.first().count())
TestCase.assertEquals("Prepare slides", tasks.first().first().name)
}
}

View File

@@ -13,6 +13,14 @@ interface TaskDao {
@Query("SELECT * FROM tasks WHERE task_list_id = :listId AND deleted = 0 ORDER BY done ASC, priority DESC")
fun getTasksForList(listId: Long): Flow<List<TaskEntity>>
// TODO: fix WHERE clause
@Query("""
SELECT * FROM tasks
WHERE due_date BETWEEN :todayStart AND :todayEnd AND deleted = 0 AND done = 0
ORDER BY done ASC, priority DESC
""")
fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE deleted = 1")
suspend fun getDeletedTasks(): List<TaskEntity>

View File

@@ -20,6 +20,10 @@ class TaskRepositoryImpl @Inject constructor(
return taskDao.getTasksForList(listId).map {entity -> entity.map { it.toDomain() }}
}
override fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow<List<Task>> {
return taskDao.getDueTodayTasks(todayStart, todayEnd).map {entity -> entity.map { it.toDomain() }}
}
override suspend fun getDeletedTasks(): List<Task> {
return taskDao.getDeletedTasks().map {entity -> entity.toDomain() }
}

View File

@@ -10,6 +10,11 @@ sealed class AppDestination(
title = name,
)
object DueTodayList : AppDestination(
route = "todayList",
title = "Due Today",
showBackButton = true,
)
object ManageLists : AppDestination(
route = "manageLists",
title = "Manage Lists",

View File

@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow
interface TaskRepository {
fun getTasksForList(listId: Long): Flow<List<Task>>
fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow<List<Task>>
suspend fun getDeletedTasks(): List<Task>
suspend fun insertTask(task: Task)
suspend fun updateTask(task: Task)

View File

@@ -0,0 +1,26 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject
class GetDueTodayTasksUseCase @Inject constructor(private val repository: TaskRepository) {
operator fun invoke(): Flow<List<Task>> {
val todayStart = LocalDate.now()
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
val todayEnd = LocalDate.now()
.plusDays(1)
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli() - 1
return repository.getDueTodayTasks(
todayStart, todayEnd
)
}
}

View File

@@ -7,13 +7,11 @@ import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject
class GetTaskListsWithOverdueUseCase @Inject constructor(
private val taskRepository: TaskRepository
) {
class GetTaskListsWithOverdueUseCase @Inject constructor(private val taskRepository: TaskRepository) {
operator fun invoke(): Flow<List<TaskListWithOverdue>> {
return taskRepository.getTaskListsWithOverdue(
LocalDate.now()
.atStartOfDay(ZoneOffset.UTC) // or system default
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
)

View File

@@ -0,0 +1,67 @@
package com.wismna.geoffroy.donext.presentation.screen
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.DueTodayViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
@Composable
fun DueTodayTasksScreen(
modifier: Modifier = Modifier,
viewModel: DueTodayViewModel = hiltViewModel(),
onTaskClick: (task: Task) -> Unit
) {
val tasks = viewModel.dueTodayTasks
if (tasks.isEmpty()) {
// Placeholder when recycle bin is empty
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Nothing due today !")
}
} else {
val context = LocalContext.current
LazyColumn(
modifier = modifier.padding(8.dp)
) {
items(tasks, key = { it.id!! }) { task ->
Card(
onClick = { onTaskClick(task) },
//elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
TaskItemScreen(
viewModel = TaskItemViewModel(task),
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!)
Toast.makeText(context, "Task done", Toast.LENGTH_SHORT).show()
},
onSwipeRight = {
viewModel.deleteTask(task.id!!)
Toast.makeText(context, "Task moved to recycle bin", Toast.LENGTH_SHORT).show()
}
)
}
}
}
}
}

View File

@@ -203,7 +203,15 @@ fun AppContent(
showAddListSheet = {viewModel.showAddListSheet = true}
)
}
composable(AppDestination.DueTodayList.route) {
DueTodayTasksScreen (
modifier = Modifier,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
)
}
composable(AppDestination.RecycleBin.route) {
RecycleBinScreen(
modifier = Modifier

View File

@@ -2,9 +2,12 @@ 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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List
@@ -46,6 +49,19 @@ fun MenuScreen(
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
NavigationDrawerItem(
label = {
Row (modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Due Today")
Text(viewModel.dueTodayTasksCount.toString())
}
},
icon = { Icon(Icons.Default.DateRange, contentDescription = "Due Today") },
selected = currentDestination is AppDestination.DueTodayList,
onClick = { onNavigate(AppDestination.DueTodayList.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
HorizontalDivider()
viewModel.taskLists.forEach { list ->
NavigationDrawerItem(
label = {

View File

@@ -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.Task
import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
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 DueTodayViewModel @Inject constructor(
getDueTodayTasks: GetDueTodayTasksUseCase,
private val toggleTaskDeleted: ToggleTaskDeletedUseCase,
private val toggleTaskDone: ToggleTaskDoneUseCase
) : ViewModel() {
var dueTodayTasks by mutableStateOf<List<Task>>(emptyList())
private set
init {
getDueTodayTasks()
.onEach { tasks ->
dueTodayTasks = tasks
}
.launchIn(viewModelScope)
}
fun updateTaskDone(taskId: Long) {
viewModelScope.launch {
toggleTaskDone(taskId, true)
}
}
fun deleteTask(taskId: Long) {
viewModelScope.launch {
toggleTaskDeleted(taskId, true)
}
}
}

View File

@@ -38,7 +38,10 @@ class MainViewModel @Inject constructor(
.onEach { lists ->
destinations = lists.map { taskList ->
AppDestination.TaskList(taskList.id!!, taskList.name)
} + AppDestination.ManageLists + AppDestination.RecycleBin
} +
AppDestination.ManageLists +
AppDestination.RecycleBin +
AppDestination.DueTodayList
isLoading = false
if (startDestination == AppDestination.ManageLists && destinations.isNotEmpty()) {
startDestination = destinations.first()

View File

@@ -1,11 +1,13 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.GetDueTodayTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsWithOverdueUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
@@ -14,17 +16,26 @@ import javax.inject.Inject
@HiltViewModel
class MenuViewModel @Inject constructor(
getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase
getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase,
getDueTodayTasks: GetDueTodayTasksUseCase
) : ViewModel() {
var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set
var dueTodayTasksCount by mutableIntStateOf(0)
private set
init {
getTaskListsWithOverdue()
.onEach { lists ->
taskLists = lists
}
.launchIn(viewModelScope)
getDueTodayTasks()
.onEach { tasks ->
dueTodayTasksCount = tasks.count()
}
.launchIn(viewModelScope)
}
}

View File

@@ -7,7 +7,6 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTasksForListUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
@@ -20,8 +19,8 @@ import javax.inject.Inject
@HiltViewModel
class TaskListViewModel @Inject constructor(
getTasks: GetTasksForListUseCase,
private val toggleTaskDoneUseCase: ToggleTaskDoneUseCase,
private val toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase,
private val toggleTaskDone: ToggleTaskDoneUseCase,
private val toggleTaskDeleted: ToggleTaskDeletedUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
@@ -43,12 +42,12 @@ class TaskListViewModel @Inject constructor(
fun updateTaskDone(taskId: Long, isDone: Boolean) {
viewModelScope.launch {
toggleTaskDoneUseCase(taskId, isDone)
toggleTaskDone(taskId, isDone)
}
}
fun deleteTask(taskId: Long) {
viewModelScope.launch {
toggleTaskDeletedUseCase(taskId, true)
toggleTaskDeleted(taskId, true)
}
}
}