feat(kanban): add bulk operations, board management, and file attachments
This commit is contained in:
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,
|
||||
|
||||
+46
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user