diff --git a/.github/workflows/android.yaml b/.github/workflows/android.yaml index d731116..5ce9a7e 100644 --- a/.github/workflows/android.yaml +++ b/.github/workflows/android.yaml @@ -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 diff --git a/.gitignore b/.gitignore index dd297f1..6021542 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ /captures /fastlane/keys/ /donext*/release/ +.claude \ No newline at end of file diff --git a/donextv2/build.gradle.kts b/donextv2/build.gradle.kts index 4e1b8ae..52bca20 100644 --- a/donextv2/build.gradle.kts +++ b/donextv2/build.gradle.kts @@ -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 { diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/injection/RepositoryModule.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/injection/RepositoryModule.kt index 7a877fa..040d97c 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/injection/RepositoryModule.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/injection/RepositoryModule.kt @@ -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 -} \ No newline at end of file + + @Binds + abstract fun bindTaskListRepository( + impl: TaskListRepositoryImpl + ): TaskListRepository +} diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskListRepositoryImpl.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskListRepositoryImpl.kt new file mode 100644 index 0000000..f5548d1 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskListRepositoryImpl.kt @@ -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> { + 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> { + return taskListDao.getTaskListsWithOverdue(nowMillis) + } +} 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 d4ea58c..dc7b4de 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 @@ -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> { return taskDao.getTasksForList(listId).map {entity -> entity.map { it.toDomain() }} @@ -58,28 +53,7 @@ class TaskRepositoryImpl @Inject constructor( taskDao.permanentDeleteAllDeletedTasks() } - override fun getTaskLists(): Flow> { - 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> { - return taskListDao.getTaskListsWithOverdue(nowMillis).map { it } - } -} \ No newline at end of file +} diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskListRepository.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskListRepository.kt new file mode 100644 index 0000000..5b12cb0 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskListRepository.kt @@ -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> + 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> +} 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 8f6db99..a4bf030 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 @@ -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> - 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> -} \ No newline at end of file + suspend fun toggleAllTasksInListDeleted(taskListId: Long, isDeleted: Boolean) +} diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/AddTaskListUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/AddTaskListUseCase.kt index b210810..0fa7a19 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/AddTaskListUseCase.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/AddTaskListUseCase.kt @@ -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( diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskListUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskListUseCase.kt index cc35ab9..a84fedb 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskListUseCase.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskListUseCase.kt @@ -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) } -} \ No newline at end of file +} diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsUseCase.kt index d6f4245..19c51d9 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsUseCase.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsUseCase.kt @@ -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> = repository.getTaskLists() } \ 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 1a84dc3..e455ce1 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 @@ -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> { - return taskRepository.getTaskListsWithOverdue( + return taskListRepository.getTaskListsWithOverdue( LocalDate.now(clock) .atStartOfDay(clock.zone) .toInstant() diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/ToggleTaskDeletedUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/ToggleTaskDeletedUseCase.kt index 1754d96..ff51b7d 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/ToggleTaskDeletedUseCase.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/ToggleTaskDeletedUseCase.kt @@ -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) } -} \ No newline at end of file +} diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/UpdateTaskListUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/UpdateTaskListUseCase.kt index 422f8b8..68c23b5 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/UpdateTaskListUseCase.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/UpdateTaskListUseCase.kt @@ -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( diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/model/TaskItemUiState.kt similarity index 94% rename from donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModel.kt rename to donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/model/TaskItemUiState.kt index 9aae553..f0d2fd4 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModel.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/model/TaskItemUiState.kt @@ -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())) } } -} \ No newline at end of file +} diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/RecycleBinScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/RecycleBinScreen.kt index 6e7b989..8b1bd0e 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/RecycleBinScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/RecycleBinScreen.kt @@ -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 diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskItemScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskItemScreen.kt index 74ffbc5..2c644fc 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskItemScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskItemScreen.kt @@ -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, diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt index e1b4543..1310fd4 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt @@ -189,7 +189,7 @@ fun TaskScreen( colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.primary ) - ) { Text("Cancel") } + ) { Text(stringResource(R.string.task_cancel)) } // --- Save Button --- Button( diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt index c0eba87..8c6cd73 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt @@ -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() } diff --git a/donextv2/src/main/res/values-fr/strings.xml b/donextv2/src/main/res/values-fr/strings.xml index 1a46f73..096cf8e 100644 --- a/donextv2/src/main/res/values-fr/strings.xml +++ b/donextv2/src/main/res/values-fr/strings.xml @@ -34,6 +34,7 @@ Êtes-vous sûr de vouloir supprimer cette tâche ? Cette action ne peut être annulée. Annuler Supprimer + Tâche supprimée Vider la corbeille Êtes-vous sûr de vider la corbeille ? Cette action ne peut être annulée. Annuler diff --git a/donextv2/src/main/res/values/strings.xml b/donextv2/src/main/res/values/strings.xml index 07a86f4..ee33a94 100644 --- a/donextv2/src/main/res/values/strings.xml +++ b/donextv2/src/main/res/values/strings.xml @@ -44,6 +44,7 @@ Are you sure you want to permanently delete this task? This cannot be undone. Cancel Delete + Task deleted Empty Recycle Bin Are you sure you want to permanently delete all tasks in the recycle bin? This cannot be undone. diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskListUseCaseTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskListUseCaseTest.kt new file mode 100644 index 0000000..0c43edf --- /dev/null +++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskListUseCaseTest.kt @@ -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(relaxed = true) + private val taskListRepository = mockk(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) } + } +} diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsWithOverdueUseCaseTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsWithOverdueUseCaseTest.kt index 4de20ed..4f77f6d 100644 --- a/donextv2/src/test/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsWithOverdueUseCaseTest.kt +++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsWithOverdueUseCaseTest.kt @@ -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() + val repository = mockk() 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() + val repository = mockk() every { repository.getTaskListsWithOverdue(any()) } returns flowOf(lists) val result = GetTaskListsWithOverdueUseCase(repository, clock).invoke().first() diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/domain/usecase/ToggleTaskDeletedUseCaseTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/domain/usecase/ToggleTaskDeletedUseCaseTest.kt new file mode 100644 index 0000000..b3f849f --- /dev/null +++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/domain/usecase/ToggleTaskDeletedUseCaseTest.kt @@ -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(relaxed = true) + private val taskListRepository = mockk(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) } + } +} diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModelTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/model/TaskItemUiStateTest.kt similarity index 66% rename from donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModelTest.kt rename to donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/model/TaskItemUiStateTest.kt index 0beca06..e4519e5 100644 --- a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModelTest.kt +++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/model/TaskItemUiStateTest.kt @@ -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) } } diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModelTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModelTest.kt index 30c7e19..214b93b 100644 --- a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModelTest.kt +++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModelTest.kt @@ -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()