diff --git a/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/repository/TaskDaoTest.kt b/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/repository/TaskDaoTest.kt index 4e7e349..0a0331a 100644 --- a/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/repository/TaskDaoTest.kt +++ b/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/repository/TaskDaoTest.kt @@ -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) + } } \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt index 0f3fbbf..75d6340 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt @@ -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> + // 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> + @Query("SELECT * FROM tasks WHERE deleted = 1") suspend fun getDeletedTasks(): List 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 d8c91f0..9ddea76 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 @@ -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> { + return taskDao.getDueTodayTasks(todayStart, todayEnd).map {entity -> entity.map { it.toDomain() }} + } + override suspend fun getDeletedTasks(): List { return taskDao.getDeletedTasks().map {entity -> entity.toDomain() } } diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/AppDestination.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/AppDestination.kt index 4e0c380..4f5843d 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/AppDestination.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/AppDestination.kt @@ -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", 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 1ac2610..01af93e 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 @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow interface TaskRepository { fun getTasksForList(listId: Long): Flow> + fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow> suspend fun getDeletedTasks(): List suspend fun insertTask(task: Task) suspend fun updateTask(task: Task) diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetDueTodayTasksUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetDueTodayTasksUseCase.kt new file mode 100644 index 0000000..290e899 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetDueTodayTasksUseCase.kt @@ -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> { + 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 + ) + } +} \ 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 index 3bcc155..731834a 100644 --- 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 @@ -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> { return taskRepository.getTaskListsWithOverdue( LocalDate.now() - .atStartOfDay(ZoneOffset.UTC) // or system default + .atStartOfDay(ZoneOffset.UTC) .toInstant() .toEpochMilli() ) diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/DueTodayTasksScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/DueTodayTasksScreen.kt new file mode 100644 index 0000000..b980da9 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/DueTodayTasksScreen.kt @@ -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() + } + ) + } + } + } + } +} \ 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 1f45758..4372fd9 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 @@ -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 diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MenuScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MenuScreen.kt index 7a4c925..a1930d3 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MenuScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MenuScreen.kt @@ -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 = { diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/DueTodayViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/DueTodayViewModel.kt new file mode 100644 index 0000000..b4bbb1d --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/DueTodayViewModel.kt @@ -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>(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) + } + } +} \ No newline at end of file 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 04b41c2..bf5753a 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 @@ -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() diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MenuViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MenuViewModel.kt index c2e2097..e848172 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MenuViewModel.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MenuViewModel.kt @@ -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>(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) } } diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModel.kt index e10a922..f4e40bb 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModel.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModel.kt @@ -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) } } } \ No newline at end of file