feat(kanban): add rich create task dialog and quick card action menu
This commit is contained in:
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.SearchBar
|
||||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
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.KanbanFilterSheet
|
||||||
import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard
|
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) {
|
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 },
|
onDismiss = { showAddTaskDialog = false },
|
||||||
onConfirm = { title, desc ->
|
onConfirm = { body, targetStatus ->
|
||||||
viewModel.createTask(title, desc, state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN)
|
viewModel.createTask(body, targetStatus)
|
||||||
showAddTaskDialog = false
|
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 =
|
private fun KanbanTaskAction.labelRes(): Int =
|
||||||
when (this) {
|
when (this) {
|
||||||
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
|
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.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.KanbanTask
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
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
|
||||||
@@ -32,6 +33,7 @@ data class KanbanUiState(
|
|||||||
val selectedBoard: KanbanBoard? = null,
|
val selectedBoard: KanbanBoard? = null,
|
||||||
val columns: List<KanbanColumn> = emptyList(),
|
val columns: List<KanbanColumn> = emptyList(),
|
||||||
val tasks: List<KanbanTask> = emptyList(),
|
val tasks: List<KanbanTask> = emptyList(),
|
||||||
|
val profiles: List<KanbanProfile> = emptyList(),
|
||||||
val isLive: Boolean = false,
|
val isLive: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val toastMessage: String? = null,
|
val toastMessage: String? = null,
|
||||||
@@ -139,6 +141,7 @@ class KanbanViewModel(
|
|||||||
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
|
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
|
||||||
|
|
||||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
loadProfiles()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val result = repository.getBoards()) {
|
when (val result = repository.getBoards()) {
|
||||||
is NetworkResult.Success -> {
|
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(
|
fun createTask(
|
||||||
title: String,
|
body: CreateTaskBody,
|
||||||
description: String?,
|
targetStatus: String = "todo",
|
||||||
status: String = "todo",
|
|
||||||
) {
|
) {
|
||||||
val board = _uiState.value.selectedBoard ?: return
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
if (_uiState.value.isCreatingTask) return
|
if (_uiState.value.isCreatingTask) return
|
||||||
@@ -191,14 +207,21 @@ class KanbanViewModel(
|
|||||||
val result =
|
val result =
|
||||||
repository.createTask(
|
repository.createTask(
|
||||||
board = board.id,
|
board = board.id,
|
||||||
body =
|
body = body,
|
||||||
CreateTaskBody(
|
|
||||||
title = title,
|
|
||||||
body = description,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
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 warning = result.data.warning
|
||||||
val msg =
|
val msg =
|
||||||
if (!warning.isNullOrBlank()) {
|
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(
|
fun moveTask(
|
||||||
task: KanbanTask,
|
task: KanbanTask,
|
||||||
action: KanbanTaskAction,
|
action: KanbanTaskAction,
|
||||||
|
|||||||
+489
@@ -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.ChatBubbleOutline
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Hub
|
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.Person
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -42,6 +44,7 @@ fun KanbanTaskCard(
|
|||||||
onTaskClick: (KanbanTask) -> Unit,
|
onTaskClick: (KanbanTask) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isSelected: Boolean = false,
|
isSelected: Boolean = false,
|
||||||
|
onActionClick: ((KanbanTask) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val statusTone =
|
val statusTone =
|
||||||
@@ -90,12 +93,28 @@ fun KanbanTaskCard(
|
|||||||
color = statusTone,
|
color = statusTone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
text = "#${task.id.removePrefix("t_").take(6)}",
|
Text(
|
||||||
style = MaterialTheme.typography.labelSmall,
|
text = "#${task.id.removePrefix("t_").take(6)}",
|
||||||
fontFamily = FontFamily.Monospace,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|||||||
@@ -342,6 +342,25 @@
|
|||||||
<string name="kanban_live">Live</string>
|
<string name="kanban_live">Live</string>
|
||||||
<string name="kanban_offline">Offline</string>
|
<string name="kanban_offline">Offline</string>
|
||||||
<string name="kanban_no_tasks">No tasks</string>
|
<string name="kanban_no_tasks">No tasks</string>
|
||||||
|
<string name="kanban_priority">Priority</string>
|
||||||
|
<string name="kanban_priority_normal">0 (Normal)</string>
|
||||||
|
<string name="kanban_priority_med">1 (Med)</string>
|
||||||
|
<string name="kanban_priority_high">2 (High)</string>
|
||||||
|
<string name="kanban_priority_urgent">3 (Urgent)</string>
|
||||||
|
<string name="kanban_assignee">Assignee</string>
|
||||||
|
<string name="kanban_assignee_unassigned">Default (Unassigned)</string>
|
||||||
|
<string name="kanban_assignee_parked">Parked (No worker)</string>
|
||||||
|
<string name="kanban_goal_mode">Autonomous Goal Mode</string>
|
||||||
|
<string name="kanban_goal_mode_desc">Judge validates task completion across multi-turn iterations</string>
|
||||||
|
<string name="kanban_advanced_options">Advanced Options</string>
|
||||||
|
<string name="kanban_parent_task">Parent Task</string>
|
||||||
|
<string name="kanban_parent_none">None</string>
|
||||||
|
<string name="kanban_model_override">Model Override</string>
|
||||||
|
<string name="kanban_provider_override">Provider Override</string>
|
||||||
|
<string name="kanban_reasoning_effort">Reasoning Effort</string>
|
||||||
|
<string name="kanban_workspace_kind">Workspace Kind</string>
|
||||||
|
<string name="kanban_skills">Skills</string>
|
||||||
|
<string name="kanban_target_column">Column</string>
|
||||||
<string name="action_add">Add</string>
|
<string name="action_add">Add</string>
|
||||||
<!-- Skills Screen -->
|
<!-- Skills Screen -->
|
||||||
<string name="skills_status_all">All Statuses</string>
|
<string name="skills_status_all">All Statuses</string>
|
||||||
|
|||||||
@@ -244,4 +244,101 @@ class KanbanMutationTest {
|
|||||||
// Only 1 updateTask call initiated
|
// Only 1 updateTask call initiated
|
||||||
coVerify(exactly = 1) { mockRepository.updateTask("t_1", "dev", any()) }
|
coVerify(exactly = 1) { mockRepository.updateTask("t_1", "dev", any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateTaskWithFullOptionsAndStatusAlignment() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
val returnedTask = KanbanTask(id = "t_101", title = "Complex Task", status = "ready")
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
coEvery { mockRepository.createTask("dev", any()) } returns
|
||||||
|
NetworkResult.Success(CreateTaskResponse(task = returnedTask))
|
||||||
|
coEvery { mockRepository.updateTask("t_101", "dev", any()) } returns
|
||||||
|
NetworkResult.Success(UpdateTaskResponse(task = returnedTask.copy(status = "todo")))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val body =
|
||||||
|
CreateTaskBody(
|
||||||
|
title = "Complex Task",
|
||||||
|
body = "Rich spec",
|
||||||
|
assignee = "reviewer",
|
||||||
|
priority = 2,
|
||||||
|
goalMode = true,
|
||||||
|
modelOverride = "claude-3-5-sonnet",
|
||||||
|
providerOverride = "anthropic",
|
||||||
|
reasoningEffort = "high",
|
||||||
|
skills = listOf("kotlin", "compose"),
|
||||||
|
parents = listOf("t_parent"),
|
||||||
|
workspaceKind = "worktree",
|
||||||
|
)
|
||||||
|
|
||||||
|
vm.createTask(body = body, targetStatus = "todo")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.createTask(
|
||||||
|
"dev",
|
||||||
|
match {
|
||||||
|
it.title == "Complex Task" &&
|
||||||
|
it.body == "Rich spec" &&
|
||||||
|
it.assignee == "reviewer" &&
|
||||||
|
it.priority == 2 &&
|
||||||
|
it.goalMode &&
|
||||||
|
it.modelOverride == "claude-3-5-sonnet" &&
|
||||||
|
it.providerOverride == "anthropic" &&
|
||||||
|
it.reasoningEffort == "high" &&
|
||||||
|
it.skills == listOf("kotlin", "compose") &&
|
||||||
|
it.parents == listOf("t_parent") &&
|
||||||
|
it.workspaceKind == "worktree"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since backend returned "ready" but user wanted "todo", status alignment update is sent
|
||||||
|
coVerify {
|
||||||
|
mockRepository.updateTask(
|
||||||
|
"t_101",
|
||||||
|
"dev",
|
||||||
|
match { it.status == "todo" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLoadProfilesPopulatesState() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val profiles =
|
||||||
|
listOf(
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.KanbanProfile(name = "researcher", model = "gpt-4o"),
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.KanbanProfile(name = "reviewer", model = "claude-3-5-sonnet"),
|
||||||
|
)
|
||||||
|
coEvery { mockRepository.getProfiles() } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.KanbanProfilesResponse(profiles),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadProfiles()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(2, vm.uiState.value.profiles.size)
|
||||||
|
assertEquals(
|
||||||
|
"researcher",
|
||||||
|
vm.uiState.value.profiles[0]
|
||||||
|
.name,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"reviewer",
|
||||||
|
vm.uiState.value.profiles[1]
|
||||||
|
.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user