Fixes and technical improvements

This commit is contained in:
Geoffroy Bonneville
2026-07-16 15:56:28 -04:00
parent 897b1892e5
commit 150058437f
26 changed files with 261 additions and 117 deletions
+3
View File
@@ -33,6 +33,9 @@ jobs:
restore-keys: |
${{ runner.os }}-gradle-
- name: Run unit tests
run: ./gradlew :donextv2:testDebugUnitTest
- name: Decode signing key
run: echo "${{ secrets.SIGNING_KEY_BASE64 }}" | base64 --decode > ${{ env.MODULE }}/upload.jks
+1
View File
@@ -10,3 +10,4 @@
/captures
/fastlane/keys/
/donext*/release/
.claude
+2 -5
View File
@@ -20,8 +20,8 @@ android {
applicationId = "com.wismna.geoffroy.donext"
minSdk = 26
targetSdk = 36
versionCode = 35
versionName = "2.0.1"
versionCode = 36
versionName = "2.0.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -65,9 +65,6 @@ android {
compose = true
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.1.1"
}
configurations.all {
resolutionStrategy {
@@ -1,6 +1,8 @@
package com.wismna.geoffroy.donext.data.injection
import com.wismna.geoffroy.donext.data.local.repository.TaskListRepositoryImpl
import com.wismna.geoffroy.donext.data.local.repository.TaskRepositoryImpl
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import dagger.Binds
import dagger.Module
@@ -15,4 +17,9 @@ abstract class RepositoryModule {
abstract fun bindTaskRepository(
impl: TaskRepositoryImpl
): TaskRepository
}
@Binds
abstract fun bindTaskListRepository(
impl: TaskListRepositoryImpl
): TaskListRepository
}
@@ -0,0 +1,39 @@
package com.wismna.geoffroy.donext.data.local.repository
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
import com.wismna.geoffroy.donext.data.toDomain
import com.wismna.geoffroy.donext.data.toEntity
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class TaskListRepositoryImpl @Inject constructor(
private val taskListDao: TaskListDao
): TaskListRepository {
override fun getTaskLists(): Flow<List<TaskList>> {
return taskListDao.getTaskLists().map {entities -> entities.map { it.toDomain() }}
}
override suspend fun getTaskListById(taskListId: Long): TaskList? {
return taskListDao.getTaskListById(taskListId)?.toDomain()
}
override suspend fun insertTaskList(taskList: TaskList) {
taskListDao.insertTaskList(taskList.toEntity())
}
override suspend fun updateTaskList(taskList: TaskList) {
taskListDao.updateTaskList(taskList.toEntity())
}
override suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) {
taskListDao.deleteTaskList(taskListId, isDeleted)
}
override fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>> {
return taskListDao.getTaskListsWithOverdue(nowMillis)
}
}
@@ -1,22 +1,17 @@
package com.wismna.geoffroy.donext.data.local.repository
import com.wismna.geoffroy.donext.data.local.dao.TaskDao
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
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.model.TaskWithListName
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import kotlin.collections.map
class TaskRepositoryImpl @Inject constructor(
private val taskDao: TaskDao,
private val taskListDao: TaskListDao
private val taskDao: TaskDao
): TaskRepository {
override fun getTasksForList(listId: Long): Flow<List<Task>> {
return taskDao.getTasksForList(listId).map {entity -> entity.map { it.toDomain() }}
@@ -58,28 +53,7 @@ class TaskRepositoryImpl @Inject constructor(
taskDao.permanentDeleteAllDeletedTasks()
}
override fun getTaskLists(): Flow<List<TaskList>> {
return taskListDao.getTaskLists().map {entities -> entities.map { it.toDomain() }}
}
override suspend fun getTaskListById(taskListId: Long): TaskList? {
return taskListDao.getTaskListById(taskListId)?.toDomain()
}
override suspend fun insertTaskList(taskList: TaskList) {
taskListDao.insertTaskList(taskList.toEntity())
}
override suspend fun updateTaskList(taskList: TaskList) {
taskListDao.updateTaskList(taskList.toEntity())
}
override suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) {
override suspend fun toggleAllTasksInListDeleted(taskListId: Long, isDeleted: Boolean) {
taskDao.toggleAllTasksFromListDeleted(taskListId, isDeleted)
taskListDao.deleteTaskList(taskListId, isDeleted)
}
override fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>> {
return taskListDao.getTaskListsWithOverdue(nowMillis).map { it }
}
}
}
@@ -0,0 +1,14 @@
package com.wismna.geoffroy.donext.domain.repository
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import kotlinx.coroutines.flow.Flow
interface TaskListRepository {
fun getTaskLists(): Flow<List<TaskList>>
suspend fun getTaskListById(taskListId: Long): TaskList?
suspend fun insertTaskList(taskList: TaskList)
suspend fun updateTaskList(taskList: TaskList)
suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean)
fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>>
}
@@ -1,8 +1,6 @@
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 com.wismna.geoffroy.donext.domain.model.TaskWithListName
import kotlinx.coroutines.flow.Flow
@@ -17,11 +15,5 @@ interface TaskRepository {
suspend fun toggleTaskDone(taskId: Long, isDone: Boolean)
suspend fun permanentlyDeleteTask(taskId: Long)
suspend fun permanentlyDeleteAllDeletedTask()
fun getTaskLists(): Flow<List<TaskList>>
suspend fun getTaskListById(taskListId: Long): TaskList?
suspend fun insertTaskList(taskList: TaskList)
suspend fun updateTaskList(taskList: TaskList)
suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean)
fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>>
}
suspend fun toggleAllTasksInListDeleted(taskListId: Long, isDeleted: Boolean)
}
@@ -1,11 +1,11 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import javax.inject.Inject
class AddTaskListUseCase @Inject constructor(
private val repository: TaskRepository
private val repository: TaskListRepository
) {
suspend operator fun invoke(title: String, order: Int) {
repository.insertTaskList(
@@ -1,12 +1,15 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject
class DeleteTaskListUseCase@Inject constructor(
private val repository: TaskRepository
class DeleteTaskListUseCase @Inject constructor(
private val taskRepository: TaskRepository,
private val taskListRepository: TaskListRepository
) {
suspend operator fun invoke(taskListId: Long, isDeleted: Boolean) {
repository.deleteTaskList(taskListId, isDeleted)
taskRepository.toggleAllTasksInListDeleted(taskListId, isDeleted)
taskListRepository.deleteTaskList(taskListId, isDeleted)
}
}
}
@@ -1,10 +1,10 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetTaskListsUseCase @Inject constructor(private val repository: TaskRepository) {
class GetTaskListsUseCase @Inject constructor(private val repository: TaskListRepository) {
operator fun invoke(): Flow<List<TaskList>> = repository.getTaskLists()
}
@@ -1,18 +1,18 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import kotlinx.coroutines.flow.Flow
import java.time.Clock
import java.time.LocalDate
import javax.inject.Inject
class GetTaskListsWithOverdueUseCase @Inject constructor(
private val taskRepository: TaskRepository,
private val taskListRepository: TaskListRepository,
private val clock: Clock
) {
operator fun invoke(): Flow<List<TaskListWithOverdue>> {
return taskRepository.getTaskListsWithOverdue(
return taskListRepository.getTaskListsWithOverdue(
LocalDate.now(clock)
.atStartOfDay(clock.zone)
.toInstant()
@@ -1,23 +1,25 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject
class ToggleTaskDeletedUseCase @Inject constructor(
private val repository: TaskRepository
private val taskRepository: TaskRepository,
private val taskListRepository: TaskListRepository
) {
suspend operator fun invoke(taskId: Long, isDeleted: Boolean) {
if (!isDeleted) {
val task = repository.getTaskById(taskId)
val task = taskRepository.getTaskById(taskId)
if (task != null) {
// If task list was soft-deleted, restore it as well
val taskList = repository.getTaskListById(task.taskListId)
val taskList = taskListRepository.getTaskListById(task.taskListId)
if (taskList != null && taskList.isDeleted) {
repository.updateTaskList(taskList.copy(isDeleted = false))
taskListRepository.updateTaskList(taskList.copy(isDeleted = false))
}
}
}
repository.toggleTaskDeleted(taskId, isDeleted)
taskRepository.toggleTaskDeleted(taskId, isDeleted)
}
}
}
@@ -1,11 +1,11 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import javax.inject.Inject
class UpdateTaskListUseCase @Inject constructor(
private val repository: TaskRepository
private val repository: TaskListRepository
) {
suspend operator fun invoke(taskListId: Long, title: String, order: Int) {
repository.updateTaskList(
@@ -1,4 +1,4 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
package com.wismna.geoffroy.donext.presentation.model
import com.wismna.geoffroy.donext.domain.extension.toLocalDate
import com.wismna.geoffroy.donext.domain.model.Priority
@@ -10,7 +10,7 @@ import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.Locale
class TaskItemViewModel(
class TaskItemUiState(
task: Task,
private val clock: Clock = Clock.systemDefaultZone()) {
val id: Long = task.id!!
@@ -42,4 +42,4 @@ class TaskItemViewModel(
dueDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()))
}
}
}
}
@@ -63,6 +63,7 @@ fun RecycleBinScreen(
val grouped = tasks.groupBy { it.listName }
val context = LocalContext.current
val taskDeletedMessage = stringResource(R.string.toast_task_permanently_deleted)
if (taskToDelete != null) {
AlertDialog(
@@ -75,7 +76,7 @@ fun RecycleBinScreen(
TextButton(
onClick = {
viewModel.onConfirmDelete()
Toast.makeText(context, "Task deleted", Toast.LENGTH_SHORT).show()
Toast.makeText(context, taskDeletedMessage, Toast.LENGTH_SHORT).show()
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
@@ -28,6 +28,7 @@ import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -41,7 +42,7 @@ import androidx.compose.ui.unit.sp
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
import com.wismna.geoffroy.donext.presentation.model.TaskItemUiState
@Composable
fun TaskItemScreen(
@@ -51,7 +52,7 @@ fun TaskItemScreen(
onSwipeRight: () -> Unit,
onTaskClick: (task: Task) -> Unit
) {
val viewModel = TaskItemViewModel(task)
val uiState = remember(task) { TaskItemUiState(task) }
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
@@ -63,17 +64,17 @@ fun TaskItemScreen(
)
val baseStyle = MaterialTheme.typography.bodyLarge.copy(
fontWeight = when (viewModel.priority) {
fontWeight = when (uiState.priority) {
Priority.HIGH -> FontWeight.Bold
Priority.NORMAL -> FontWeight.Normal
Priority.LOW -> FontWeight.Normal
},
color = when (viewModel.priority) {
color = when (uiState.priority) {
Priority.HIGH -> MaterialTheme.colorScheme.onSurface
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
},
textDecoration = if (viewModel.isDone) TextDecoration.LineThrough else TextDecoration.None
textDecoration = if (uiState.isDone) TextDecoration.LineThrough else TextDecoration.None
)
Card(
modifier = modifier,
@@ -87,8 +88,8 @@ fun TaskItemScreen(
backgroundContent = {
DismissBackground(
dismissState,
viewModel.isDone,
viewModel.isDeleted
uiState.isDone,
uiState.isDeleted
)
},
content = {
@@ -97,7 +98,7 @@ fun TaskItemScreen(
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(8.dp)
.alpha(if (viewModel.isDone || viewModel.priority == Priority.LOW) 0.5f else 1f),
.alpha(if (uiState.isDone || uiState.priority == Priority.LOW) 0.5f else 1f),
verticalAlignment = Alignment.CenterVertically // centers checkbox + content
) {
Box(
@@ -108,12 +109,12 @@ fun TaskItemScreen(
) {
// Title
Text(
text = viewModel.name,
text = uiState.name,
fontSize = 18.sp,
style = baseStyle,
modifier = Modifier
.align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterStart
if (uiState.description.isNullOrBlank()) Alignment.CenterStart
else Alignment.TopStart
),
overflow = TextOverflow.Ellipsis,
@@ -121,19 +122,19 @@ fun TaskItemScreen(
)
// Due date badge
viewModel.dueDateText?.let { dueMillis ->
uiState.dueDateText?.let { dueMillis ->
Badge(
modifier = Modifier
.align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterEnd
if (uiState.description.isNullOrBlank()) Alignment.CenterEnd
else Alignment.TopEnd
),
containerColor = if (viewModel.isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer
containerColor = if (uiState.isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer
) {
Text(
modifier = Modifier.padding(start = 1.dp, end = 1.dp),
text = viewModel.dueDateText,
color = if (viewModel.isOverdue) Color.White else MaterialTheme.colorScheme.onPrimaryContainer,
text = uiState.dueDateText,
color = if (uiState.isOverdue) Color.White else MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.bodySmall
)
}
@@ -147,9 +148,9 @@ fun TaskItemScreen(
.padding(top = 24.dp),
contentAlignment = Alignment.TopStart
) {
if (!viewModel.description.isNullOrBlank()) {
if (!uiState.description.isNullOrBlank()) {
Text(
text = viewModel.description,
text = uiState.description,
color = MaterialTheme.colorScheme.tertiary,
style = baseStyle.copy(
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
@@ -189,7 +189,7 @@ fun TaskScreen(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.primary
)
) { Text("Cancel") }
) { Text(stringResource(R.string.task_cancel)) }
// --- Save Button ---
Button(
@@ -71,12 +71,14 @@ class TaskViewModel @Inject constructor(
fun save(onDone: (() -> Unit)? = null) {
if (title.isBlank()) return
val listId = taskListId ?: return
viewModelScope.launch {
if (isEditing()) {
updateTaskUseCase(editingTaskId!!, taskListId!!, title, description, priority, dueDate, isDone)
val currentEditingTaskId = editingTaskId
if (currentEditingTaskId != null) {
updateTaskUseCase(currentEditingTaskId, listId, title, description, priority, dueDate, isDone)
} else {
createTaskUseCase(taskListId!!, title, description, priority, dueDate)
createTaskUseCase(listId, title, description, priority, dueDate)
}
onDone?.invoke()
}
@@ -34,6 +34,7 @@
<string name="dialog_delete_task_description">Êtes-vous sûr de vouloir supprimer cette tâche ? Cette action ne peut être annulée.</string>
<string name="dialog_delete_task_cancel">Annuler</string>
<string name="dialog_delete_task_delete">Supprimer</string>
<string name="toast_task_permanently_deleted">Tâche supprimée</string>
<string name="dialog_empty_task_title">Vider la corbeille</string>
<string name="dialog_empty_task_description">Êtes-vous sûr de vider la corbeille ? Cette action ne peut être annulée.</string>
<string name="dialog_empty_task_cancel">Annuler</string>
+1
View File
@@ -44,6 +44,7 @@
<string name="dialog_delete_task_description">Are you sure you want to permanently delete this task? This cannot be undone.</string>
<string name="dialog_delete_task_cancel">Cancel</string>
<string name="dialog_delete_task_delete">Delete</string>
<string name="toast_task_permanently_deleted">Task deleted</string>
<string name="dialog_empty_task_title">Empty Recycle Bin</string>
<string name="dialog_empty_task_description">Are you sure you want to permanently delete all tasks in the recycle bin? This cannot be undone.</string>
@@ -0,0 +1,34 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DeleteTaskListUseCaseTest {
private val taskRepository = mockk<TaskRepository>(relaxed = true)
private val taskListRepository = mockk<TaskListRepository>(relaxed = true)
private val useCase = DeleteTaskListUseCase(taskRepository, taskListRepository)
@Test
fun `deleting a list also toggles all its tasks, list last`() = runTest {
useCase(5L, true)
coVerifyOrder {
taskRepository.toggleAllTasksInListDeleted(5L, true)
taskListRepository.deleteTaskList(5L, true)
}
}
@Test
fun `restoring a list also restores all its tasks`() = runTest {
useCase(5L, false)
coVerify { taskRepository.toggleAllTasksInListDeleted(5L, false) }
coVerify { taskListRepository.deleteTaskList(5L, false) }
}
}
@@ -2,7 +2,7 @@ package com.wismna.geoffroy.donext.domain.usecase
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@@ -25,7 +25,7 @@ class GetTaskListsWithOverdueUseCaseTest {
val today = LocalDate.of(2026, 7, 16)
val clock = Clock.fixed(today.atStartOfDay(zone).plusHours(10).toInstant(), zone)
val repository = mockk<TaskRepository>()
val repository = mockk<TaskListRepository>()
every { repository.getTaskListsWithOverdue(any()) } returns flowOf(emptyList())
GetTaskListsWithOverdueUseCase(repository, clock).invoke()
@@ -40,7 +40,7 @@ class GetTaskListsWithOverdueUseCaseTest {
val clock = Clock.fixed(LocalDate.of(2026, 1, 1).atStartOfDay(zone).toInstant(), zone)
val lists = listOf(TaskListWithOverdue(id = 1L, name = "Work", overdueCount = 2))
val repository = mockk<TaskRepository>()
val repository = mockk<TaskListRepository>()
every { repository.getTaskListsWithOverdue(any()) } returns flowOf(lists)
val result = GetTaskListsWithOverdueUseCase(repository, clock).invoke().first()
@@ -0,0 +1,62 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ToggleTaskDeletedUseCaseTest {
private val taskRepository = mockk<TaskRepository>(relaxed = true)
private val taskListRepository = mockk<TaskListRepository>(relaxed = true)
private val useCase = ToggleTaskDeletedUseCase(taskRepository, taskListRepository)
private val task = Task(
id = 1L,
taskListId = 5L,
name = "Task",
description = "",
priority = Priority.NORMAL,
isDone = false,
isDeleted = true
)
@Test
fun `restoring a task also restores its soft-deleted parent list`() = runTest {
val deletedList = TaskList(id = 5L, name = "List", isDeleted = true, order = 0)
coEvery { taskRepository.getTaskById(1L) } returns task
coEvery { taskListRepository.getTaskListById(5L) } returns deletedList
useCase(1L, false)
coVerify { taskListRepository.updateTaskList(deletedList.copy(isDeleted = false)) }
coVerify { taskRepository.toggleTaskDeleted(1L, false) }
}
@Test
fun `restoring a task whose parent list is not deleted leaves the list untouched`() = runTest {
val activeList = TaskList(id = 5L, name = "List", isDeleted = false, order = 0)
coEvery { taskRepository.getTaskById(1L) } returns task
coEvery { taskListRepository.getTaskListById(5L) } returns activeList
useCase(1L, false)
coVerify(exactly = 0) { taskListRepository.updateTaskList(any()) }
coVerify { taskRepository.toggleTaskDeleted(1L, false) }
}
@Test
fun `soft-deleting a task never touches its parent list`() = runTest {
useCase(1L, true)
coVerify(exactly = 0) { taskRepository.getTaskById(any()) }
coVerify(exactly = 0) { taskListRepository.getTaskListById(any()) }
coVerify { taskRepository.toggleTaskDeleted(1L, true) }
}
}
@@ -1,4 +1,4 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
package com.wismna.geoffroy.donext.presentation.model
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Priority
@@ -11,7 +11,7 @@ import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.*
class TaskItemViewModelTest {
class TaskItemUiStateTest {
private val fixedClock: Clock = Clock.fixed(
LocalDate.of(2025, 1, 10)
@@ -47,80 +47,80 @@ class TaskItemViewModelTest {
@Test
fun `initializes fields from Task`() {
val viewModel = TaskItemViewModel(baseTask)
val uiState = TaskItemUiState(baseTask)
assertThat(viewModel.id).isEqualTo(baseTask.id)
assertThat(viewModel.name).isEqualTo(baseTask.name)
assertThat(viewModel.description).isEqualTo(baseTask.description)
assertThat(viewModel.isDone).isFalse()
assertThat(viewModel.isDeleted).isFalse()
assertThat(viewModel.priority).isEqualTo(Priority.NORMAL)
assertThat(uiState.id).isEqualTo(baseTask.id)
assertThat(uiState.name).isEqualTo(baseTask.name)
assertThat(uiState.description).isEqualTo(baseTask.description)
assertThat(uiState.isDone).isFalse()
assertThat(uiState.isDeleted).isFalse()
assertThat(uiState.priority).isEqualTo(Priority.NORMAL)
}
@Test
fun `isOverdue is true when due date is before today`() {
val overdueTask = baseTask.copy(dueDate = millisForDaysFromFixedToday(-1))
val viewModel = TaskItemViewModel(overdueTask)
val uiState = TaskItemUiState(overdueTask)
assertThat(viewModel.isOverdue).isTrue()
assertThat(uiState.isOverdue).isTrue()
}
@Test
fun `isOverdue is false when due date is today`() {
val dueToday = baseTask.copy(dueDate = millisForDaysFromFixedToday(0))
val viewModel = TaskItemViewModel(dueToday, fixedClock)
val uiState = TaskItemUiState(dueToday, fixedClock)
assertThat(viewModel.isOverdue).isFalse()
assertThat(uiState.isOverdue).isFalse()
}
@Test
fun `isOverdue is false when due date is null`() {
val viewModel = TaskItemViewModel(baseTask.copy(dueDate = null))
val uiState = TaskItemUiState(baseTask.copy(dueDate = null))
assertThat(viewModel.isOverdue).isFalse()
assertThat(uiState.isOverdue).isFalse()
}
@Test
fun `dueDateText is Today when due date is today`() {
val dueToday = baseTask.copy(dueDate = millisForDaysFromFixedToday(0))
val viewModel = TaskItemViewModel(dueToday, fixedClock)
val uiState = TaskItemUiState(dueToday, fixedClock)
assertThat(viewModel.dueDateText).isEqualTo("Today")
assertThat(uiState.dueDateText).isEqualTo("Today")
}
@Test
fun `dueDateText is Tomorrow when due date is tomorrow`() {
val dueTomorrow = baseTask.copy(dueDate = millisForDaysFromFixedToday(1))
val viewModel = TaskItemViewModel(dueTomorrow, fixedClock)
val uiState = TaskItemUiState(dueTomorrow, fixedClock)
assertThat(viewModel.dueDateText).isEqualTo("Tomorrow")
assertThat(uiState.dueDateText).isEqualTo("Tomorrow")
}
@Test
fun `dueDateText is Yesterday when due date was yesterday`() {
val dueYesterday = baseTask.copy(dueDate = millisForDaysFromFixedToday(-1))
val viewModel = TaskItemViewModel(dueYesterday, fixedClock)
val uiState = TaskItemUiState(dueYesterday, fixedClock)
assertThat(viewModel.dueDateText).isEqualTo("Yesterday")
assertThat(uiState.dueDateText).isEqualTo("Yesterday")
}
@Test
fun `dueDateText is day of week when within next 7 days`() {
val dueIn3Days = baseTask.copy(dueDate = millisForDaysFromFixedToday(3))
val viewModel = TaskItemViewModel(dueIn3Days, fixedClock)
val uiState = TaskItemUiState(dueIn3Days, fixedClock)
val expected = today
.plusDays(3)
.dayOfWeek
.getDisplayName(TextStyle.SHORT, Locale.getDefault())
assertThat(viewModel.dueDateText).isEqualTo(expected)
assertThat(uiState.dueDateText).isEqualTo(expected)
}
@Test
fun `dueDateText is formatted date when more than 7 days away`() {
val dueIn10Days = baseTask.copy(dueDate = millisForDaysFromFixedToday(10))
val viewModel = TaskItemViewModel(dueIn10Days)
val uiState = TaskItemUiState(dueIn10Days)
val expected = today
.plusDays(10)
@@ -129,6 +129,6 @@ class TaskItemViewModelTest {
.withLocale(Locale.getDefault())
)
assertThat(viewModel.dueDateText).isEqualTo(expected)
assertThat(uiState.dueDateText).isEqualTo(expected)
}
}
@@ -180,6 +180,16 @@ class TaskViewModelTest {
coVerify(exactly = 0) { updateTaskUseCase(any(), any(), any(), any(), any(), any(), any()) }
}
@Test
fun `save before taskListId is set does nothing`() = runTest {
viewModel.onTitleChanged("Task")
viewModel.save()
advanceUntilIdle()
coVerify(exactly = 0) { createTaskUseCase(any(), any(), any(), any(), any()) }
coVerify(exactly = 0) { updateTaskUseCase(any(), any(), any(), any(), any(), any(), any()) }
}
@Test
fun `save creates task when not editing`() = runTest {stickyEventsFlow.emit(UiEvent.CreateNewTask(3L))
advanceUntilIdle()