From 05e78ba13585c88b424aa261aa6dc105f9f5237c Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:01:33 +0300 Subject: [PATCH] feat(kanban): add bulk operations, board management, and file attachments --- .../hermescontrol/ui/kanban/KanbanScreen.kt | 378 +++++++++++++++--- .../ui/kanban/KanbanTaskScreen.kt | 138 ++++++- .../ui/kanban/KanbanTaskViewModel.kt | 14 + .../ui/kanban/KanbanViewModel.kt | 166 ++++++++ .../components/KanbanCreateTaskDialog.kt | 46 +++ .../ui/kanban/KanbanMutationTest.kt | 149 +++++++ .../ui/kanban/KanbanTaskViewModelTest.kt | 25 ++ 7 files changed, 870 insertions(+), 46 deletions(-) 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 46397afc..34a09bef 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 @@ -17,14 +17,21 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Person import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -33,6 +40,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -105,6 +113,17 @@ fun KanbanScreen( var confirmTarget by remember { mutableStateOf?>(null) } var summaryTarget by remember { mutableStateOf?>(null) } + // Multi-select & Bulk operations state + var isMultiSelectMode by remember { mutableStateOf(false) } + var selectedTaskIds by remember { mutableStateOf(setOf()) } + var showBulkMoveDialog by remember { mutableStateOf(false) } + + // Board management dialogs + var showBoardMenu by remember { mutableStateOf(false) } + var showCreateBoardDialog by remember { mutableStateOf(false) } + var showRenameBoardDialog by remember { mutableStateOf(false) } + var showDeleteBoardDialog by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { viewModel.loadBoards() } @@ -114,6 +133,60 @@ fun KanbanScreen( HermesScaffold( title = { Text(stringResource(R.string.kanban_board_title)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, + actions = { + IconButton(onClick = { showFilterSheet = true }) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = "Filter", + ) + } + Box { + IconButton(onClick = { showBoardMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Board options", + ) + } + DropdownMenu( + expanded = showBoardMenu, + onDismissRequest = { showBoardMenu = false }, + ) { + DropdownMenuItem( + text = { Text("New Board") }, + onClick = { + showBoardMenu = false + showCreateBoardDialog = true + }, + ) + if (state.selectedBoard != null) { + DropdownMenuItem( + text = { Text("Rename Board") }, + onClick = { + showBoardMenu = false + showRenameBoardDialog = true + }, + ) + if (state.boards.size > 1) { + DropdownMenuItem( + text = { Text("Delete Board") }, + onClick = { + showBoardMenu = false + showDeleteBoardDialog = true + }, + ) + } + } + DropdownMenuItem( + text = { Text(if (isMultiSelectMode) "Exit Multi-Select" else "Select Multiple") }, + onClick = { + showBoardMenu = false + isMultiSelectMode = !isMultiSelectMode + if (!isMultiSelectMode) selectedTaskIds = emptySet() + }, + ) + } + } + }, isRefreshing = state.isLoading, onRefresh = { viewModel.loadBoards() }, ) { paddingValues -> @@ -256,18 +329,38 @@ fun KanbanScreen( items(colTasks, key = { it.id }) { task -> KanbanTaskCard( task = task, + isSelected = + isMultiSelectMode && task.id in selectedTaskIds, onTaskClick = { clickedTask -> - state.selectedBoard?.let { board -> - NavigationController.navigateTo( - KanbanTaskDetailKey( - boardSlug = board.id, - taskId = clickedTask.id, - ), - ) + if (isMultiSelectMode) { + selectedTaskIds = + if (clickedTask.id in selectedTaskIds) { + selectedTaskIds - clickedTask.id + } else { + selectedTaskIds + clickedTask.id + } + } else { + state.selectedBoard?.let { board -> + NavigationController.navigateTo( + KanbanTaskDetailKey( + boardSlug = board.id, + taskId = clickedTask.id, + ), + ) + } } }, onActionClick = { clickedTask -> - taskForActions = clickedTask + if (isMultiSelectMode) { + selectedTaskIds = + if (clickedTask.id in selectedTaskIds) { + selectedTaskIds - clickedTask.id + } else { + selectedTaskIds + clickedTask.id + } + } else { + taskForActions = clickedTask + } }, ) } @@ -287,6 +380,7 @@ fun KanbanScreen( profiles = state.profiles, existingTasks = state.tasks, isCreating = state.isCreatingTask, + onEstimate = viewModel::estimateNewTask, onDismiss = { showAddTaskDialog = false }, onConfirm = { body, targetStatus -> viewModel.createTask(body, targetStatus) @@ -374,44 +468,238 @@ fun KanbanScreen( onDismiss = { showFilterSheet = false }, ) } - } - } - } - } -} -@Composable -fun TaskCard( - task: KanbanTask, - onTaskClick: (KanbanTask) -> Unit, -) { - Card(onClick = { onTaskClick(task) }, modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(12.dp)) { - Text(text = task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.height(4.dp)) - task.description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - task.assignedTo?.let { assignee -> - Spacer(modifier = Modifier.height(6.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Filled.Person, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = assignee, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (isMultiSelectMode) { + Card( + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(), + ) { + Text( + text = "${selectedTaskIds.size} selected", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = { + selectedTaskIds = + if (selectedTaskIds.size == filteredTasks.size) { + emptySet() + } else { + filteredTasks.map { it.id }.toSet() + } + }, + ) { + Text(if (selectedTaskIds.size == filteredTasks.size) "Clear" else "All") + } + Button( + onClick = { showBulkMoveDialog = true }, + enabled = selectedTaskIds.isNotEmpty(), + ) { + Text("Move") + } + Button( + onClick = { + viewModel.bulkArchive(selectedTaskIds.toList()) + isMultiSelectMode = false + selectedTaskIds = emptySet() + }, + enabled = selectedTaskIds.isNotEmpty(), + ) { + Text("Archive") + } + IconButton( + onClick = { + isMultiSelectMode = false + selectedTaskIds = emptySet() + }, + ) { + Icon(Icons.Default.Close, contentDescription = "Close multi-select") + } + } + } + } + } + + if (showBulkMoveDialog) { + AlertDialog( + onDismissRequest = { showBulkMoveDialog = false }, + title = { Text("Move ${selectedTaskIds.size} tasks") }, + text = { + Column { + state.columns.forEach { col -> + TextButton( + onClick = { + viewModel.bulkMove(selectedTaskIds.toList(), col.name) + showBulkMoveDialog = false + isMultiSelectMode = false + selectedTaskIds = emptySet() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(col.name.uppercase(), modifier = Modifier.fillMaxWidth()) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { showBulkMoveDialog = false }) { + Text("Cancel") + } + }, + ) + } + + if (showCreateBoardDialog) { + var boardSlug by remember { mutableStateOf("") } + var boardName by remember { mutableStateOf("") } + var boardDesc by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { showCreateBoardDialog = false }, + title = { Text("Create New Board") }, + text = { + Column { + OutlinedTextField( + value = boardSlug, + onValueChange = { + boardSlug = + it.lowercase().filter { c -> + c.isLetterOrDigit() || c == '-' || c == '_' + } + }, + label = { Text("Board ID / Slug") }, + placeholder = { Text("e.g. sprint-3") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = boardName, + onValueChange = { boardName = it }, + label = { Text("Display Name") }, + placeholder = { Text("e.g. Sprint 3") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = boardDesc, + onValueChange = { boardDesc = it }, + label = { Text("Description") }, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { + if (boardSlug.isNotBlank()) { + viewModel.createBoard( + slug = boardSlug.trim(), + name = boardName.trim().ifBlank { null }, + description = boardDesc.trim().ifBlank { null }, + ) + showCreateBoardDialog = false + } + }, + enabled = boardSlug.isNotBlank(), + ) { + Text("Create") + } + }, + dismissButton = { + TextButton(onClick = { showCreateBoardDialog = false }) { + Text("Cancel") + } + }, + ) + } + + if (showRenameBoardDialog && state.selectedBoard != null) { + var newName by remember { mutableStateOf(state.selectedBoard?.name ?: "") } + AlertDialog( + onDismissRequest = { showRenameBoardDialog = false }, + title = { Text("Rename Board") }, + text = { + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + label = { Text("Board Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + }, + confirmButton = { + Button( + onClick = { + if (newName.isNotBlank()) { + state.selectedBoard?.let { board -> + viewModel.renameBoard(board.id, newName.trim()) + } + showRenameBoardDialog = false + } + }, + enabled = newName.isNotBlank(), + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = { showRenameBoardDialog = false }) { + Text("Cancel") + } + }, + ) + } + + if (showDeleteBoardDialog && state.selectedBoard != null) { + AlertDialog( + onDismissRequest = { showDeleteBoardDialog = false }, + title = { Text("Delete Board") }, + text = { + Text( + "Are you sure you want to delete board '${state.selectedBoard?.displayName}'? This action cannot be undone.", + ) + }, + confirmButton = { + Button( + onClick = { + state.selectedBoard?.let { board -> + viewModel.deleteBoard(board.id) + } + showDeleteBoardDialog = false + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteBoardDialog = false }) { + Text("Cancel") + } + }, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt index 8b6a1f08..87d796ae 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt @@ -1,5 +1,9 @@ package com.m57.hermescontrol.ui.kanban +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -51,6 +55,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -75,7 +80,7 @@ fun KanbanTaskScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() var selectedTabIndex by remember { mutableIntStateOf(0) } - val tabs = listOf("Overview", "Discussion", "Runs", "Activity") + val tabs = listOf("Overview", "Discussion", "Runs", "Activity", "Files") LaunchedEffect(boardSlug, taskId) { viewModel.loadTask(boardSlug, taskId) @@ -171,6 +176,16 @@ fun KanbanTaskScreen( events = detail.events, ) } + + 4 -> { + TaskFilesTab( + attachments = detail.attachments ?: emptyList(), + isUploading = state.isUploadingAttachment, + onUpload = { filename, mimeType, bytes -> + viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes) + }, + ) + } } } } @@ -612,3 +627,124 @@ private fun MetaRow( Text(text = value, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium) } } + +@Composable +private fun TaskFilesTab( + attachments: List, + isUploading: Boolean, + onUpload: (filename: String, mimeType: String, bytes: ByteArray) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri: Uri? -> + if (uri != null) { + var filename = "attachment" + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1 && cursor.moveToFirst()) { + filename = cursor.getString(nameIndex) ?: "attachment" + } + } + val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream" + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + if (bytes != null) { + onUpload(filename, mimeType, bytes) + } + } + } + + LazyColumn( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Text( + text = "Attached Files (${attachments.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Button( + onClick = { launcher.launch("*/*") }, + enabled = !isUploading, + ) { + if (isUploading) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(6.dp)) + } + Text("Attach File") + } + } + } + + if (attachments.isEmpty()) { + item { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No files attached to this task.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + items(attachments, key = { it.id }) { att -> + Card( + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(12.dp) + .fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Filled.Description, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = att.filename, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + val sizeStr = + att.size?.let { s -> + if (s > 1024 * 1024) "${s / (1024 * 1024)} MB" else "${s / 1024} KB" + } ?: "Unknown size" + Text( + text = "$sizeStr • ${att.uploadedBy ?: "unknown"}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt index 9ac1a067..8fe7cc1c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt @@ -16,7 +16,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody data class KanbanTaskUiState( val isLoading: Boolean = false, @@ -251,6 +253,18 @@ class KanbanTaskViewModel( } } + fun uploadAttachment( + board: String, + taskId: String, + filename: String, + mimeType: String, + bytes: ByteArray, + ) { + val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) + val part = MultipartBody.Part.createFormData("file", filename, requestBody) + uploadAttachment(board, taskId, part) + } + fun uploadAttachment( board: String, taskId: String, 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 a79b3db8..5c9499f4 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 @@ -5,11 +5,15 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore import com.m57.hermescontrol.data.local.KanbanPreferencesStore +import com.m57.hermescontrol.data.model.BulkTasksBody +import com.m57.hermescontrol.data.model.CreateBoardBody import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.KanbanBoard import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanProfile import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.data.model.RenameBoardBody +import com.m57.hermescontrol.data.model.TaskEstimate import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.repository.KanbanRepository @@ -260,6 +264,168 @@ class KanbanViewModel( ) } + suspend fun estimateNewTask( + title: String, + body: String?, + ): TaskEstimate? = + when (val result = repository.estimateNew(title, body)) { + is NetworkResult.Success -> result.data + is NetworkResult.Failure -> null + } + + fun createBoard( + slug: String, + name: String? = null, + description: String? = null, + ) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val body = + CreateBoardBody( + slug = slug, + name = name, + description = description, + ) + when (val res = repository.createBoard(body)) { + is NetworkResult.Success -> { + val boardName = res.data.board?.name ?: slug + _uiState.update { it.copy(toastMessage = "Board created: $boardName") } + loadBoards() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + toastMessage = "Failed to create board: ${res.error.message}", + ) + } + } + } + } + } + + fun renameBoard( + slug: String, + newName: String, + ) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + when (val res = repository.updateBoard(slug, RenameBoardBody(name = newName))) { + is NetworkResult.Success -> { + val boardName = res.data.board?.name ?: newName + _uiState.update { it.copy(toastMessage = "Board renamed to $boardName") } + loadBoards() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + toastMessage = "Failed to rename board: ${res.error.message}", + ) + } + } + } + } + } + + fun deleteBoard(slug: String) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + when (val res = repository.deleteBoard(slug, delete = true)) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Board deleted") } + loadBoards() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + toastMessage = "Failed to delete board: ${res.error.message}", + ) + } + } + } + } + } + + fun bulkMove( + taskIds: List, + targetStatus: String, + ) { + val board = _uiState.value.selectedBoard ?: return + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, status = targetStatus))) { + is NetworkResult.Success -> { + val count = res.data.results.count { it.ok } + _uiState.update { it.copy(toastMessage = "Moved $count tasks to $targetStatus") } + reloadBoardSilently() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + toastMessage = "Bulk move failed: ${res.error.message}", + ) + } + } + } + } + } + + fun bulkArchive(taskIds: List) { + val board = _uiState.value.selectedBoard ?: return + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, archive = true))) { + is NetworkResult.Success -> { + val count = res.data.results.count { it.ok } + _uiState.update { it.copy(toastMessage = "Archived $count tasks") } + reloadBoardSilently() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + toastMessage = "Bulk archive failed: ${res.error.message}", + ) + } + } + } + } + } + + fun bulkAssign( + taskIds: List, + assignee: String?, + ) { + val board = _uiState.value.selectedBoard ?: return + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, assignee = assignee))) { + is NetworkResult.Success -> { + val count = res.data.results.count { it.ok } + _uiState.update { it.copy(toastMessage = "Assigned $count tasks") } + reloadBoardSilently() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + toastMessage = "Bulk assign failed: ${res.error.message}", + ) + } + } + } + } + } + fun moveTask( task: KanbanTask, action: KanbanTaskAction, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt index 4b57f9c5..cfe5d865 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -47,6 +48,8 @@ import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanProfile import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.data.model.TaskEstimate +import kotlinx.coroutines.launch private const val PARKED_VALUE = "__parked__" @@ -61,6 +64,7 @@ fun KanbanCreateTaskDialog( onDismiss: () -> Unit, onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit, modifier: Modifier = Modifier, + onEstimate: (suspend (title: String, body: String?) -> TaskEstimate?)? = null, ) { var title by remember { mutableStateOf("") } var desc by remember { mutableStateOf("") } @@ -69,6 +73,11 @@ fun KanbanCreateTaskDialog( var priority by remember { mutableIntStateOf(0) } var goalMode by remember { mutableStateOf(false) } + // Estimation state + var estimate by remember { mutableStateOf(null) } + var isEstimating by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + // Advanced options var showAdvanced by remember { mutableStateOf(false) } var parentTaskId by remember { mutableStateOf(null) } @@ -437,6 +446,43 @@ fun KanbanCreateTaskDialog( ) } } + + if (onEstimate != null) { + Spacer(modifier = Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + if (estimate != null && estimate?.ok == true) { + Text( + text = "~${estimate?.estTokens ?: 0} tokens · ${estimate?.complexity ?: "normal"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + TextButton( + onClick = { + if (title.isNotBlank() && !isEstimating) { + coroutineScope.launch { + isEstimating = true + estimate = onEstimate(title, desc.ifBlank { null }) + isEstimating = false + } + } + }, + enabled = title.isNotBlank() && !isEstimating && !isCreating, + ) { + if (isEstimating) { + CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp) + Spacer(modifier = Modifier.width(4.dp)) + } + Text(if (estimate != null) "Re-estimate" else "Estimate tokens") + } + } + } } }, confirmButton = { 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 6d4d96f2..fa9d51ab 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 @@ -1,12 +1,18 @@ package com.m57.hermescontrol.ui.kanban import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore +import com.m57.hermescontrol.data.model.BulkTaskResult +import com.m57.hermescontrol.data.model.BulkTasksResponse +import com.m57.hermescontrol.data.model.CreateBoardResponse import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskResponse +import com.m57.hermescontrol.data.model.DeleteBoardResponse import com.m57.hermescontrol.data.model.KanbanBoard import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.data.model.RenameBoardResponse +import com.m57.hermescontrol.data.model.TaskEstimate import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.remote.NetworkError @@ -341,4 +347,147 @@ class KanbanMutationTest { .name, ) } + + @Test + fun testCreateBoardSuccess() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "sprint-2", name = "Sprint 2") + coEvery { mockRepository.createBoard(any()) } returns + NetworkResult.Success(CreateBoardResponse(board = board)) + coEvery { mockRepository.getBoards() } returns + NetworkResult.Success( + com.m57.hermescontrol.data.model + .KanbanBoardsResponse(listOf(board), "sprint-2"), + ) + + val vm = createViewModel() + vm.createBoard(slug = "sprint-2", name = "Sprint 2", description = "Next cycle") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockRepository.createBoard( + match { + it.slug == "sprint-2" && it.name == "Sprint 2" && it.description == "Next cycle" + }, + ) + } + assertEquals("Board created: Sprint 2", vm.uiState.value.toastMessage) + } + + @Test + fun testRenameBoardSuccess() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "dev", name = "Dev Renamed") + coEvery { mockRepository.updateBoard("dev", any()) } returns + NetworkResult.Success(RenameBoardResponse(board = board)) + coEvery { mockRepository.getBoards() } returns + NetworkResult.Success( + com.m57.hermescontrol.data.model + .KanbanBoardsResponse(listOf(board), "dev"), + ) + + val vm = createViewModel() + vm.renameBoard("dev", "Dev Renamed") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockRepository.updateBoard("dev", match { it.name == "Dev Renamed" }) + } + assertEquals("Board renamed to Dev Renamed", vm.uiState.value.toastMessage) + } + + @Test + fun testDeleteBoardSuccess() = + runTest(testDispatcher) { + coEvery { mockRepository.deleteBoard("old-board", delete = true) } returns + NetworkResult.Success(DeleteBoardResponse(current = null)) + coEvery { mockRepository.getBoards() } returns + NetworkResult.Success( + com.m57.hermescontrol.data.model + .KanbanBoardsResponse(emptyList(), null), + ) + + val vm = createViewModel() + vm.deleteBoard("old-board") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { mockRepository.deleteBoard("old-board", delete = true) } + assertEquals("Board deleted", vm.uiState.value.toastMessage) + } + + @Test + fun testBulkMoveTasksSuccess() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "dev", name = "Dev") + coEvery { mockRepository.getBoard("dev") } returns + NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("done", emptyList())))) + coEvery { mockRepository.bulkTasks("dev", any()) } returns + NetworkResult.Success( + BulkTasksResponse( + listOf( + BulkTaskResult("t_1", true), + BulkTaskResult("t_2", true), + ), + ), + ) + + val vm = createViewModel() + vm.selectBoard(board) + testDispatcher.scheduler.advanceUntilIdle() + + vm.bulkMove(listOf("t_1", "t_2"), "done") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockRepository.bulkTasks( + "dev", + match { it.ids == listOf("t_1", "t_2") && it.status == "done" }, + ) + } + assertEquals("Moved 2 tasks to done", vm.uiState.value.toastMessage) + } + + @Test + fun testBulkArchiveTasksSuccess() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "dev", name = "Dev") + coEvery { mockRepository.getBoard("dev") } returns + NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList())))) + coEvery { mockRepository.bulkTasks("dev", any()) } returns + NetworkResult.Success( + BulkTasksResponse( + listOf( + BulkTaskResult("t_1", true), + ), + ), + ) + + val vm = createViewModel() + vm.selectBoard(board) + testDispatcher.scheduler.advanceUntilIdle() + + vm.bulkArchive(listOf("t_1")) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockRepository.bulkTasks( + "dev", + match { it.ids == listOf("t_1") && it.archive }, + ) + } + assertEquals("Archived 1 tasks", vm.uiState.value.toastMessage) + } + + @Test + fun testEstimateNewTaskSuccess() = + runTest(testDispatcher) { + coEvery { mockRepository.estimateNew("Title", "Body") } returns + NetworkResult.Success(TaskEstimate(ok = true, estTokens = 1200, complexity = "low")) + + val vm = createViewModel() + val estimate = vm.estimateNewTask("Title", "Body") + + assertEquals(1200, estimate?.estTokens) + assertEquals("low", estimate?.complexity) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt index c9f19908..fd6c612a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt @@ -172,4 +172,29 @@ class KanbanTaskViewModelTest { ?.contains("coder") == true, ) } + + @Test + fun testUploadAttachmentSuccess() = + runTest(testDispatcher) { + val task = KanbanTaskFull(id = "t_1", title = "Task with file", status = "todo") + coEvery { mockRepository.getTask("t_1", "dev") } returns + NetworkResult.Success(KanbanTaskDetailResponse(task = task)) + coEvery { mockRepository.uploadAttachment("t_1", "dev", any()) } returns + NetworkResult.Success( + com.m57.hermescontrol.data.model.AttachmentUploadResponse( + attachment = + com.m57.hermescontrol.data.model.KanbanAttachment( + id = 101L, + filename = "test.png", + ), + ), + ) + + val vm = createViewModel() + vm.uploadAttachment("dev", "t_1", "test.png", "image/png", byteArrayOf(1, 2, 3)) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) } + assertEquals("Attachment uploaded", vm.uiState.value.toastMessage) + } }