diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index d64d5f75..68565cc7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -151,6 +151,7 @@ fun KanbanScreen( var isMultiSelectMode by remember { mutableStateOf(false) } var selectedTaskIds by remember { mutableStateOf(setOf()) } var showBulkMoveDialog by remember { mutableStateOf(false) } + var showBulkCompleteDialog by remember { mutableStateOf(false) } var showBulkAssignDialog by remember { mutableStateOf(false) } var showBulkDeleteDialog by remember { mutableStateOf(false) } @@ -1052,9 +1053,13 @@ fun KanbanScreen( writableMoveColumns.forEach { col -> TextButton( onClick = { - viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed -> - selectedTaskIds = failed - if (failed.isEmpty()) isMultiSelectMode = false + if (col.name.equals("done", ignoreCase = true)) { + showBulkCompleteDialog = true + } else { + viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } } showBulkMoveDialog = false }, @@ -1074,6 +1079,24 @@ fun KanbanScreen( ) } + if (showBulkCompleteDialog) { + BulkCompleteTasksDialog( + taskCount = selectedTaskIds.size, + onConfirm = { summary -> + viewModel.bulkMove( + taskIds = selectedTaskIds.toList(), + targetStatus = "done", + summary = summary, + ) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } + showBulkCompleteDialog = false + }, + onDismiss = { showBulkCompleteDialog = false }, + ) + } + if (showBulkAssignDialog) { AlertDialog( onDismissRequest = { showBulkAssignDialog = false }, @@ -1431,6 +1454,43 @@ private fun CompleteTaskDialog( ) } +@Composable +private fun BulkCompleteTasksDialog( + taskCount: Int, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var summary by remember { mutableStateOf("") } + val trimmedSummary = summary.trim() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.kanban_bulk_complete_title, taskCount)) }, + text = { + OutlinedTextField( + value = summary, + onValueChange = { summary = it }, + label = { Text(stringResource(R.string.kanban_complete_summary_label)) }, + placeholder = { Text(stringResource(R.string.kanban_complete_summary_hint)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) + }, + confirmButton = { + Button( + onClick = { onConfirm(trimmedSummary) }, + enabled = trimmedSummary.isNotEmpty(), + ) { + Text(stringResource(R.string.kanban_action_complete)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + private fun KanbanTaskAction.labelRes(): Int = when (this) { KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index a148ab85..874f7ee4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -618,12 +618,26 @@ class KanbanViewModel( fun bulkMove( taskIds: List, targetStatus: String, + summary: String? = null, onComplete: ((failedIds: Set) -> Unit)? = null, ) { val board = _uiState.value.selectedBoard ?: return + val completionSummary = summary?.trim()?.takeIf { it.isNotEmpty() } + if (targetStatus.equals("done", ignoreCase = true) && completionSummary == null) { + _uiState.update { it.copy(toastMessage = "Completion summary is required") } + onComplete?.invoke(taskIds.toSet()) + return + } viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, status = targetStatus))) { + val body = + BulkTasksBody( + ids = taskIds, + status = targetStatus, + summary = completionSummary, + result = completionSummary, + ) + when (val res = repository.bulkTasks(board.id, body)) { is NetworkResult.Success -> { val results = res.data.results val succeeded = results.filter { it.ok }.map { it.id }.toSet() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d6e2222..374f6406 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -443,6 +443,7 @@ Unassign Delete Delete %1$d selected tasks? This cannot be undone. + Complete %1$d tasks Export Board Import Board Working diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt index 8e9b9709..89aad88e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -510,7 +510,7 @@ class KanbanMutationTest { testDispatcher.scheduler.advanceUntilIdle() var reportedFailed: Set? = null - vm.bulkMove(listOf("t_1", "t_2"), "done") { failed -> + vm.bulkMove(listOf("t_1", "t_2"), "done", summary = "Finished selected tasks") { failed -> reportedFailed = failed } testDispatcher.scheduler.advanceUntilIdle() @@ -565,18 +565,45 @@ class KanbanMutationTest { vm.selectBoard(board) testDispatcher.scheduler.advanceUntilIdle() - vm.bulkMove(listOf("t_1", "t_2"), "done") + vm.bulkMove(listOf("t_1", "t_2"), "done", summary = "Finished selected tasks") testDispatcher.scheduler.advanceUntilIdle() coVerify { mockRepository.bulkTasks( "dev", - match { it.ids == listOf("t_1", "t_2") && it.status == "done" }, + match { + it.ids == listOf("t_1", "t_2") && + it.status == "done" && + it.summary == "Finished selected tasks" && + it.result == "Finished selected tasks" + }, ) } assertEquals("Moved 2 tasks to done", vm.uiState.value.toastMessage) } + @Test + fun testBulkMoveToDoneRejectsEmptyCompletionEvidence() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "dev", name = "Dev") + coEvery { mockRepository.getBoard("dev") } returns + NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("done", emptyList())))) + + val vm = createViewModel() + vm.selectBoard(board) + testDispatcher.scheduler.advanceUntilIdle() + + var reportedFailed: Set? = null + vm.bulkMove(listOf("t_1", "t_2"), "done", summary = " ") { failed -> + reportedFailed = failed + } + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { mockRepository.bulkTasks(any(), any()) } + assertEquals(setOf("t_1", "t_2"), reportedFailed) + assertEquals("Completion summary is required", vm.uiState.value.toastMessage) + } + @Test fun testBulkArchiveTasksSuccess() = runTest(testDispatcher) {