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
+870 -46

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.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<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) {
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")
}
},
)
}
}
}
}
@@ -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<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.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,
@@ -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<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(
task: KanbanTask,
action: KanbanTaskAction,
@@ -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<TaskEstimate?>(null) }
var isEstimating by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
// Advanced options
var showAdvanced by remember { mutableStateOf(false) }
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 = {