feat(kanban): add rich create task dialog and quick card action menu

This commit is contained in:
M57 committed 2026-09-14 23:34:26 +03:00
1 parent 3cc5cc6574
commit 11512aeed4
6 files changed
+688 -62

No files matched your search

@@ -57,6 +57,7 @@ import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.kanban.components.KanbanCreateTaskDialog
import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet
import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard
@@ -265,6 +266,9 @@ fun KanbanScreen(
)
}
},
onActionClick = { clickedTask ->
taskForActions = clickedTask
},
)
}
}
@@ -277,10 +281,15 @@ fun KanbanScreen(
}
if (showAddTaskDialog) {
AddTaskDialog(
KanbanCreateTaskDialog(
columns = state.columns,
defaultColumn = state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN,
profiles = state.profiles,
existingTasks = state.tasks,
isCreating = state.isCreatingTask,
onDismiss = { showAddTaskDialog = false },
onConfirm = { title, desc ->
viewModel.createTask(title, desc, state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN)
onConfirm = { body, targetStatus ->
viewModel.createTask(body, targetStatus)
showAddTaskDialog = false
},
)
@@ -433,51 +442,6 @@ private fun LiveStatusPill(isLive: Boolean) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTaskDialog(
onDismiss: () -> Unit,
onConfirm: (title: String, desc: String?) -> Unit,
) {
var title by remember { mutableStateOf("") }
var desc by remember { mutableStateOf("") }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.kanban_add_new_task)) },
text = {
Column {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text(stringResource(R.string.kanban_task_title)) },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = desc,
onValueChange = { desc = it },
label = { Text(stringResource(R.string.kanban_task_desc)) },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(
onClick = { if (title.isNotBlank()) onConfirm(title, desc.ifBlank { null }) },
enabled = title.isNotBlank(),
) {
Text(stringResource(R.string.action_add))
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
private fun KanbanTaskAction.labelRes(): Int =
when (this) {
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
@@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.local.KanbanPreferencesStore
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.UpdateTaskBody
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -32,6 +33,7 @@ data class KanbanUiState(
val selectedBoard: KanbanBoard? = null,
val columns: List<KanbanColumn> = emptyList(),
val tasks: List<KanbanTask> = emptyList(),
val profiles: List<KanbanProfile> = emptyList(),
val isLive: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
@@ -139,6 +141,7 @@ class KanbanViewModel(
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
loadProfiles()
viewModelScope.launch {
when (val result = repository.getBoards()) {
is NetworkResult.Success -> {
@@ -179,10 +182,23 @@ class KanbanViewModel(
}
}
fun loadProfiles() {
viewModelScope.launch {
when (val result = repository.getProfiles()) {
is NetworkResult.Success -> {
_uiState.update { it.copy(profiles = result.data.profiles) }
}
is NetworkResult.Failure -> {
// Do not block UI if profiles fetch fails
}
}
}
}
fun createTask(
title: String,
description: String?,
status: String = "todo",
body: CreateTaskBody,
targetStatus: String = "todo",
) {
val board = _uiState.value.selectedBoard ?: return
if (_uiState.value.isCreatingTask) return
@@ -191,14 +207,21 @@ class KanbanViewModel(
val result =
repository.createTask(
board = board.id,
body =
CreateTaskBody(
title = title,
body = description,
),
body = body,
)
when (result) {
is NetworkResult.Success -> {
val createdTask = result.data.task
if (createdTask != null &&
targetStatus.isNotBlank() &&
!createdTask.status.equals(targetStatus, ignoreCase = true)
) {
repository.updateTask(
taskId = createdTask.id,
board = board.id,
body = UpdateTaskBody(status = targetStatus),
)
}
val warning = result.data.warning
val msg =
if (!warning.isNullOrBlank()) {
@@ -222,6 +245,21 @@ class KanbanViewModel(
}
}
fun createTask(
title: String,
description: String?,
status: String = "todo",
) {
createTask(
body =
CreateTaskBody(
title = title,
body = description,
),
targetStatus = status,
)
}
fun moveTask(
task: KanbanTask,
action: KanbanTaskAction,
@@ -0,0 +1,489 @@
package com.m57.hermescontrol.ui.kanban.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
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
private const val PARKED_VALUE = "__parked__"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KanbanCreateTaskDialog(
columns: List<KanbanColumn>,
defaultColumn: String,
profiles: List<KanbanProfile>,
existingTasks: List<KanbanTask>,
isCreating: Boolean,
onDismiss: () -> Unit,
onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit,
modifier: Modifier = Modifier,
) {
var title by remember { mutableStateOf("") }
var desc by remember { mutableStateOf("") }
var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) }
var selectedAssignee by remember { mutableStateOf<String?>(null) }
var priority by remember { mutableIntStateOf(0) }
var goalMode by remember { mutableStateOf(false) }
// Advanced options
var showAdvanced by remember { mutableStateOf(false) }
var parentTaskId by remember { mutableStateOf<String?>(null) }
var modelOverride by remember { mutableStateOf("") }
var providerOverride by remember { mutableStateOf("") }
var reasoningEffort by remember { mutableStateOf<String?>(null) }
var workspaceKind by remember { mutableStateOf<String?>(null) }
var skillsText by remember { mutableStateOf("") }
var assigneeExpanded by remember { mutableStateOf(false) }
var columnExpanded by remember { mutableStateOf(false) }
var parentExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { if (!isCreating) onDismiss() },
modifier = modifier,
title = { Text(stringResource(R.string.kanban_add_new_task)) },
text = {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text(stringResource(R.string.kanban_task_title)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = desc,
onValueChange = { desc = it },
label = { Text(stringResource(R.string.kanban_task_desc)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(12.dp))
// Target Column selector
if (columns.isNotEmpty()) {
ExposedDropdownMenuBox(
expanded = columnExpanded,
onExpandedChange = { if (!isCreating) columnExpanded = it },
) {
OutlinedTextField(
value = selectedColumn.uppercase(),
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_target_column)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnExpanded) },
modifier =
Modifier
.menuAnchor(
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
).fillMaxWidth(),
enabled = !isCreating,
)
ExposedDropdownMenu(
expanded = columnExpanded,
onDismissRequest = { columnExpanded = false },
) {
columns.forEach { col ->
DropdownMenuItem(
text = { Text(col.name.uppercase()) },
onClick = {
selectedColumn = col.name
columnExpanded = false
},
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
}
// Assignee dropdown
ExposedDropdownMenuBox(
expanded = assigneeExpanded,
onExpandedChange = { if (!isCreating) assigneeExpanded = it },
) {
val displayAssignee =
when (selectedAssignee) {
null -> stringResource(R.string.kanban_assignee_unassigned)
PARKED_VALUE -> stringResource(R.string.kanban_assignee_parked)
else -> selectedAssignee ?: ""
}
OutlinedTextField(
value = displayAssignee,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_assignee)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = assigneeExpanded) },
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
enabled = !isCreating,
)
ExposedDropdownMenu(
expanded = assigneeExpanded,
onDismissRequest = { assigneeExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_unassigned)) },
onClick = {
selectedAssignee = null
assigneeExpanded = false
},
)
profiles.forEach { profile ->
DropdownMenuItem(
text = {
Column {
Text(profile.name)
if (profile.model != null) {
Text(
profile.model,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
onClick = {
selectedAssignee = profile.name
assigneeExpanded = false
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_parked)) },
onClick = {
selectedAssignee = PARKED_VALUE
assigneeExpanded = false
},
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Priority chips
Text(
text = stringResource(R.string.kanban_priority),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
listOf(
0 to stringResource(R.string.kanban_priority_normal),
1 to stringResource(R.string.kanban_priority_med),
2 to stringResource(R.string.kanban_priority_high),
3 to stringResource(R.string.kanban_priority_urgent),
).forEach { (pVal, label) ->
FilterChip(
selected = priority == pVal,
onClick = { priority = pVal },
label = { Text(label) },
enabled = !isCreating,
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Autonomous Goal Mode switch
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxWidth()
.clickable(enabled = !isCreating) { goalMode = !goalMode }
.padding(vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.kanban_goal_mode),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.kanban_goal_mode_desc),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = goalMode,
onCheckedChange = { goalMode = it },
enabled = !isCreating,
)
}
Spacer(modifier = Modifier.height(8.dp))
// Advanced Options Toggle
TextButton(
onClick = { showAdvanced = !showAdvanced },
modifier = Modifier.align(Alignment.Start),
) {
Icon(
imageVector = if (showAdvanced) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.kanban_advanced_options))
}
AnimatedVisibility(visible = showAdvanced) {
Column(modifier = Modifier.fillMaxWidth()) {
// Parent Task
if (existingTasks.isNotEmpty()) {
ExposedDropdownMenuBox(
expanded = parentExpanded,
onExpandedChange = { if (!isCreating) parentExpanded = it },
) {
val displayParent =
existingTasks.find { it.id == parentTaskId }?.let {
"#${it.id.removePrefix("t_").take(6)} • ${it.title}"
} ?: stringResource(R.string.kanban_parent_none)
OutlinedTextField(
value = displayParent,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_parent_task)) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = parentExpanded,
)
},
modifier =
Modifier
.menuAnchor(
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
).fillMaxWidth(),
enabled = !isCreating,
)
ExposedDropdownMenu(
expanded = parentExpanded,
onDismissRequest = { parentExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_parent_none)) },
onClick = {
parentTaskId = null
parentExpanded = false
},
)
existingTasks.forEach { task ->
DropdownMenuItem(
text = { Text("#${task.id.removePrefix("t_").take(6)} • ${task.title}") },
onClick = {
parentTaskId = task.id
parentExpanded = false
},
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
// Model Override
OutlinedTextField(
value = modelOverride,
onValueChange = { modelOverride = it },
label = { Text(stringResource(R.string.kanban_model_override)) },
placeholder = { Text("e.g. claude-3-5-sonnet") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(8.dp))
// Provider Override
OutlinedTextField(
value = providerOverride,
onValueChange = { providerOverride = it },
label = { Text(stringResource(R.string.kanban_provider_override)) },
placeholder = { Text("e.g. anthropic") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(8.dp))
// Reasoning Effort
Text(
text = stringResource(R.string.kanban_reasoning_effort),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
listOf(
null to "Inherit",
"low" to "Low",
"medium" to "Medium",
"high" to "High",
).forEach { (effort, label) ->
FilterChip(
selected = reasoningEffort == effort,
onClick = { reasoningEffort = effort },
label = { Text(label) },
enabled = !isCreating,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Workspace Kind
Text(
text = stringResource(R.string.kanban_workspace_kind),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
listOf(
null to "Scratch (Default)",
"worktree" to "Worktree",
"dir" to "Dir",
).forEach { (kind, label) ->
FilterChip(
selected = workspaceKind == kind,
onClick = { workspaceKind = kind },
label = { Text(label) },
enabled = !isCreating,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Skills
OutlinedTextField(
value = skillsText,
onValueChange = { skillsText = it },
label = { Text(stringResource(R.string.kanban_skills)) },
placeholder = { Text("e.g. git, coding, tests") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
}
}
}
},
confirmButton = {
Button(
onClick = {
if (title.isNotBlank()) {
val skillsList =
skillsText
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.takeIf { it.isNotEmpty() }
val body =
CreateTaskBody(
title = title.trim(),
body = desc.trim().ifBlank { null },
assignee = if (selectedAssignee == PARKED_VALUE) null else selectedAssignee,
priority = priority,
goalMode = goalMode,
parents = parentTaskId?.let { listOf(it) } ?: emptyList(),
modelOverride = modelOverride.trim().ifBlank { null },
providerOverride = providerOverride.trim().ifBlank { null },
reasoningEffort = reasoningEffort,
workspaceKind = workspaceKind,
skills = skillsList,
triage = selectedColumn.equals("triage", ignoreCase = true),
)
onConfirm(body, selectedColumn)
}
},
enabled = title.isNotBlank() && !isCreating,
) {
if (isCreating) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.action_add))
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isCreating,
) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@@ -18,11 +18,13 @@ import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -42,6 +44,7 @@ fun KanbanTaskCard(
onTaskClick: (KanbanTask) -> Unit,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
onActionClick: ((KanbanTask) -> Unit)? = null,
) {
val statusColors = LocalHermesStatusColors.current
val statusTone =
@@ -90,12 +93,28 @@ fun KanbanTaskCard(
color = statusTone,
)
}
Text(
text = "#${task.id.removePrefix("t_").take(6)}",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "#${task.id.removePrefix("t_").take(6)}",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (onActionClick != null) {
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = { onActionClick(task) },
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Task actions",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Spacer(modifier = Modifier.height(6.dp))