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) 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") @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>> 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") @Query("SELECT * FROM tasks WHERE deleted = 1")
suspend fun getDeletedTasks(): List<TaskEntity> 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() }} 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> { override suspend fun getDeletedTasks(): List<Task> {
return taskDao.getDeletedTasks().map {entity -> entity.toDomain() } return taskDao.getDeletedTasks().map {entity -> entity.toDomain() }
} }

View File

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

View File

@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow
interface TaskRepository { interface TaskRepository {
fun getTasksForList(listId: Long): Flow<List<Task>> fun getTasksForList(listId: Long): Flow<List<Task>>
fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow<List<Task>>
suspend fun getDeletedTasks(): List<Task> suspend fun getDeletedTasks(): List<Task>
suspend fun insertTask(task: Task) suspend fun insertTask(task: Task)
suspend fun updateTask(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 java.time.ZoneOffset
import javax.inject.Inject import javax.inject.Inject
class GetTaskListsWithOverdueUseCase @Inject constructor( class GetTaskListsWithOverdueUseCase @Inject constructor(private val taskRepository: TaskRepository) {
private val taskRepository: TaskRepository
) {
operator fun invoke(): Flow<List<TaskListWithOverdue>> { operator fun invoke(): Flow<List<TaskListWithOverdue>> {
return taskRepository.getTaskListsWithOverdue( return taskRepository.getTaskListsWithOverdue(
LocalDate.now() LocalDate.now()
.atStartOfDay(ZoneOffset.UTC) // or system default .atStartOfDay(ZoneOffset.UTC)
.toInstant() .toInstant()
.toEpochMilli() .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} showAddListSheet = {viewModel.showAddListSheet = true}
) )
} }
composable(AppDestination.DueTodayList.route) {
DueTodayTasksScreen (
modifier = Modifier,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
)
}
composable(AppDestination.RecycleBin.route) { composable(AppDestination.RecycleBin.route) {
RecycleBinScreen( RecycleBinScreen(
modifier = Modifier 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
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.DateRange
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.List
@@ -46,6 +49,19 @@ fun MenuScreen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp) 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 -> viewModel.taskLists.forEach { list ->
NavigationDrawerItem( NavigationDrawerItem(
label = { 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 -> .onEach { lists ->
destinations = lists.map { taskList -> destinations = lists.map { taskList ->
AppDestination.TaskList(taskList.id!!, taskList.name) AppDestination.TaskList(taskList.id!!, taskList.name)
} + AppDestination.ManageLists + AppDestination.RecycleBin } +
AppDestination.ManageLists +
AppDestination.RecycleBin +
AppDestination.DueTodayList
isLoading = false isLoading = false
if (startDestination == AppDestination.ManageLists && destinations.isNotEmpty()) { if (startDestination == AppDestination.ManageLists && destinations.isNotEmpty()) {
startDestination = destinations.first() startDestination = destinations.first()

View File

@@ -1,11 +1,13 @@
package com.wismna.geoffroy.donext.presentation.viewmodel package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf 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.TaskListWithOverdue 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 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
@@ -14,17 +16,26 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MenuViewModel @Inject constructor( class MenuViewModel @Inject constructor(
getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase,
getDueTodayTasks: GetDueTodayTasksUseCase
) : ViewModel() { ) : ViewModel() {
var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList()) var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set private set
var dueTodayTasksCount by mutableIntStateOf(0)
private set
init { init {
getTaskListsWithOverdue() getTaskListsWithOverdue()
.onEach { lists -> .onEach { lists ->
taskLists = lists taskLists = lists
} }
.launchIn(viewModelScope) .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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.domain.model.Task 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.GetTasksForListUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
@@ -20,8 +19,8 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TaskListViewModel @Inject constructor( class TaskListViewModel @Inject constructor(
getTasks: GetTasksForListUseCase, getTasks: GetTasksForListUseCase,
private val toggleTaskDoneUseCase: ToggleTaskDoneUseCase, private val toggleTaskDone: ToggleTaskDoneUseCase,
private val toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase, private val toggleTaskDeleted: ToggleTaskDeletedUseCase,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
@@ -43,12 +42,12 @@ class TaskListViewModel @Inject constructor(
fun updateTaskDone(taskId: Long, isDone: Boolean) { fun updateTaskDone(taskId: Long, isDone: Boolean) {
viewModelScope.launch { viewModelScope.launch {
toggleTaskDoneUseCase(taskId, isDone) toggleTaskDone(taskId, isDone)
} }
} }
fun deleteTask(taskId: Long) { fun deleteTask(taskId: Long) {
viewModelScope.launch { viewModelScope.launch {
toggleTaskDeletedUseCase(taskId, true) toggleTaskDeleted(taskId, true)
} }
} }
} }