feat(kanban): add bulk operations, board management, and file attachments

This commit is contained in:
M57 committed 2026-09-15 00:01:33 +03:00
1 parent 6e94fa0be2
commit 05e78ba135
7 files changed
+856 -32

No files matched your search

@@ -17,14 +17,21 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.FilterList
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -33,6 +40,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -105,6 +113,17 @@ fun KanbanScreen(
var confirmTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) } var confirmTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
var summaryTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) } var summaryTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
// Multi-select & Bulk operations state
var isMultiSelectMode by remember { mutableStateOf(false) }
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
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) { LaunchedEffect(Unit) {
viewModel.loadBoards() viewModel.loadBoards()
} }
@@ -114,6 +133,60 @@ fun KanbanScreen(
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.kanban_board_title)) }, title = { Text(stringResource(R.string.kanban_board_title)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, 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, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadBoards() }, onRefresh = { viewModel.loadBoards() },
) { paddingValues -> ) { paddingValues ->
@@ -256,7 +329,17 @@ fun KanbanScreen(
items(colTasks, key = { it.id }) { task -> items(colTasks, key = { it.id }) { task ->
KanbanTaskCard( KanbanTaskCard(
task = task, task = task,
isSelected =
isMultiSelectMode && task.id in selectedTaskIds,
onTaskClick = { clickedTask -> onTaskClick = { clickedTask ->
if (isMultiSelectMode) {
selectedTaskIds =
if (clickedTask.id in selectedTaskIds) {
selectedTaskIds - clickedTask.id
} else {
selectedTaskIds + clickedTask.id
}
} else {
state.selectedBoard?.let { board -> state.selectedBoard?.let { board ->
NavigationController.navigateTo( NavigationController.navigateTo(
KanbanTaskDetailKey( KanbanTaskDetailKey(
@@ -265,9 +348,19 @@ fun KanbanScreen(
), ),
) )
} }
}
}, },
onActionClick = { clickedTask -> onActionClick = { clickedTask ->
if (isMultiSelectMode) {
selectedTaskIds =
if (clickedTask.id in selectedTaskIds) {
selectedTaskIds - clickedTask.id
} else {
selectedTaskIds + clickedTask.id
}
} else {
taskForActions = clickedTask taskForActions = clickedTask
}
}, },
) )
} }
@@ -287,6 +380,7 @@ fun KanbanScreen(
profiles = state.profiles, profiles = state.profiles,
existingTasks = state.tasks, existingTasks = state.tasks,
isCreating = state.isCreatingTask, isCreating = state.isCreatingTask,
onEstimate = viewModel::estimateNewTask,
onDismiss = { showAddTaskDialog = false }, onDismiss = { showAddTaskDialog = false },
onConfirm = { body, targetStatus -> onConfirm = { body, targetStatus ->
viewModel.createTask(body, targetStatus) viewModel.createTask(body, targetStatus)
@@ -374,44 +468,238 @@ fun KanbanScreen(
onDismiss = { showFilterSheet = false }, onDismiss = { showFilterSheet = false },
) )
} }
}
}
}
}
}
@Composable if (isMultiSelectMode) {
fun TaskCard( Card(
task: KanbanTask, shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
onTaskClick: (KanbanTask) -> Unit, colors =
) { CardDefaults.cardColors(
Card(onClick = { onTaskClick(task) }, modifier = Modifier.fillMaxWidth()) { containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
Column(modifier = Modifier.padding(12.dp)) { ),
Text(text = task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) modifier =
Spacer(modifier = Modifier.height(4.dp)) Modifier
task.description?.let { .align(Alignment.BottomCenter)
.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
) {
Text( Text(
text = it, text = "${selectedTaskIds.size} selected",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleSmall,
maxLines = 2, fontWeight = FontWeight.Bold,
overflow = TextOverflow.Ellipsis, )
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")
}
},
) )
} }
task.assignedTo?.let { assignee ->
Spacer(modifier = Modifier.height(6.dp)) if (showCreateBoardDialog) {
Row(verticalAlignment = Alignment.CenterVertically) { var boardSlug by remember { mutableStateOf("") }
Icon( var boardName by remember { mutableStateOf("") }
imageVector = Icons.Filled.Person, var boardDesc by remember { mutableStateOf("") }
contentDescription = null, AlertDialog(
modifier = Modifier.size(14.dp), onDismissRequest = { showCreateBoardDialog = false },
tint = MaterialTheme.colorScheme.onSurfaceVariant, 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.width(4.dp)) 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( Text(
text = assignee, "Are you sure you want to delete board '${state.selectedBoard?.displayName}'? This action cannot be undone.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
},
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")
}
},
)
}
} }
} }
} }
@@ -1,5 +1,9 @@
package com.m57.hermescontrol.ui.kanban 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.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -51,6 +55,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -75,7 +80,7 @@ fun KanbanTaskScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
var selectedTabIndex by remember { mutableIntStateOf(0) } var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabs = listOf("Overview", "Discussion", "Runs", "Activity") val tabs = listOf("Overview", "Discussion", "Runs", "Activity", "Files")
LaunchedEffect(boardSlug, taskId) { LaunchedEffect(boardSlug, taskId) {
viewModel.loadTask(boardSlug, taskId) viewModel.loadTask(boardSlug, taskId)
@@ -171,6 +176,16 @@ fun KanbanTaskScreen(
events = detail.events, 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) Text(text = value, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
} }
} }
@Composable
private fun TaskFilesTab(
attachments: List<com.m57.hermescontrol.data.model.KanbanAttachment>,
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,
)
}
}
}
}
}
}
}
@@ -16,7 +16,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
data class KanbanTaskUiState( data class KanbanTaskUiState(
val isLoading: Boolean = false, 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( fun uploadAttachment(
board: String, board: String,
taskId: String, taskId: String,
@@ -5,11 +5,15 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
import com.m57.hermescontrol.data.local.KanbanPreferencesStore 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.CreateTaskBody
import com.m57.hermescontrol.data.model.KanbanBoard import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTask 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.model.UpdateTaskBody
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.repository.KanbanRepository 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<String>,
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<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, 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<String>,
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( fun moveTask(
task: KanbanTask, task: KanbanTask,
action: KanbanTaskAction, action: KanbanTaskAction,
@@ -37,6 +37,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.TaskEstimate
import kotlinx.coroutines.launch
private const val PARKED_VALUE = "__parked__" private const val PARKED_VALUE = "__parked__"
@@ -61,6 +64,7 @@ fun KanbanCreateTaskDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit, onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onEstimate: (suspend (title: String, body: String?) -> TaskEstimate?)? = null,
) { ) {
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var desc by remember { mutableStateOf("") } var desc by remember { mutableStateOf("") }
@@ -69,6 +73,11 @@ fun KanbanCreateTaskDialog(
var priority by remember { mutableIntStateOf(0) } var priority by remember { mutableIntStateOf(0) }
var goalMode by remember { mutableStateOf(false) } var goalMode by remember { mutableStateOf(false) }
// Estimation state
var estimate by remember { mutableStateOf<TaskEstimate?>(null) }
var isEstimating by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
// Advanced options // Advanced options
var showAdvanced by remember { mutableStateOf(false) } var showAdvanced by remember { mutableStateOf(false) }
var parentTaskId by remember { mutableStateOf<String?>(null) } var parentTaskId by remember { mutableStateOf<String?>(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 = { confirmButton = {
@@ -1,12 +1,18 @@
package com.m57.hermescontrol.ui.kanban package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore 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.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse 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.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanTask 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.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkError
@@ -341,4 +347,147 @@ class KanbanMutationTest {
.name, .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)
}
} }
@@ -172,4 +172,29 @@ class KanbanTaskViewModelTest {
?.contains("coder") == true, ?.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)
}
} }