feat(kanban): add rich task card, filter sheet, and task detail screen
This commit is contained in:
@@ -174,6 +174,15 @@ private fun appEntryProvider(
|
|||||||
onBack = { NavigationController.goBack() },
|
onBack = { NavigationController.goBack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Kanban Task Detail ─────────────────────────────────────────────
|
||||||
|
entry<KanbanTaskDetailKey> { key ->
|
||||||
|
com.m57.hermescontrol.ui.kanban.KanbanTaskScreen(
|
||||||
|
boardSlug = key.boardSlug,
|
||||||
|
taskId = key.taskId,
|
||||||
|
onBack = { NavigationController.goBack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object KanbanScreen : NavKey
|
@Serializable data object KanbanScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data class KanbanTaskDetailKey(
|
||||||
|
val boardSlug: String,
|
||||||
|
val taskId: String,
|
||||||
|
) : NavKey
|
||||||
|
|
||||||
@Serializable data object ProcessesScreen : NavKey
|
@Serializable data object ProcessesScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object ProvidersScreen : NavKey
|
@Serializable data object ProvidersScreen : NavKey
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -46,6 +47,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.KanbanTaskDetailKey
|
||||||
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.KanbanTask
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
@@ -54,6 +57,8 @@ 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.KanbanFilterSheet
|
||||||
|
import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard
|
||||||
|
|
||||||
private const val DEFAULT_COLUMN = "todo"
|
private const val DEFAULT_COLUMN = "todo"
|
||||||
|
|
||||||
@@ -67,14 +72,26 @@ fun KanbanScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
var selectedAssignee by remember { mutableStateOf<String?>(null) }
|
||||||
|
var selectedTenant by remember { mutableStateOf<String?>(null) }
|
||||||
|
var includeArchived by remember { mutableStateOf(false) }
|
||||||
|
var groupRunning by remember { mutableStateOf(false) }
|
||||||
|
var showFilterSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val filteredTasks =
|
val filteredTasks =
|
||||||
remember(query, state.tasks) {
|
remember(query, state.tasks, selectedAssignee, selectedTenant) {
|
||||||
state.tasks.filter { task ->
|
state.tasks.filter { task ->
|
||||||
task.title.contains(query, ignoreCase = true) ||
|
val matchesQuery =
|
||||||
task.description?.contains(query, ignoreCase = true) == true ||
|
query.isBlank() ||
|
||||||
task.status.contains(query, ignoreCase = true) ||
|
task.title.contains(query, ignoreCase = true) ||
|
||||||
task.assignedTo?.contains(query, ignoreCase = true) == true
|
task.description?.contains(query, ignoreCase = true) == true ||
|
||||||
|
task.status.contains(query, ignoreCase = true) ||
|
||||||
|
task.assignedTo?.contains(query, ignoreCase = true) == true ||
|
||||||
|
task.id.contains(query, ignoreCase = true)
|
||||||
|
val matchesAssignee =
|
||||||
|
selectedAssignee == null || task.assignee.equals(selectedAssignee, ignoreCase = true)
|
||||||
|
val matchesTenant = selectedTenant == null || task.tenant.equals(selectedTenant, ignoreCase = true)
|
||||||
|
matchesQuery && matchesAssignee && matchesTenant
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +184,14 @@ fun KanbanScreen(
|
|||||||
) {
|
) {
|
||||||
LiveStatusPill(isLive = state.isLive)
|
LiveStatusPill(isLive = state.isLive)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
IconButton(onClick = { showFilterSheet = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.FilterList,
|
||||||
|
contentDescription = "Filters",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
IconButton(onClick = { showAddTaskDialog = true }) {
|
IconButton(onClick = { showAddTaskDialog = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Add,
|
imageVector = Icons.Filled.Add,
|
||||||
@@ -228,9 +253,18 @@ fun KanbanScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(colTasks, key = { it.id }) { task ->
|
items(colTasks, key = { it.id }) { task ->
|
||||||
TaskCard(
|
KanbanTaskCard(
|
||||||
task = task,
|
task = task,
|
||||||
onTaskClick = { taskForActions = it },
|
onTaskClick = { clickedTask ->
|
||||||
|
state.selectedBoard?.let { board ->
|
||||||
|
NavigationController.navigateTo(
|
||||||
|
KanbanTaskDetailKey(
|
||||||
|
boardSlug = board.id,
|
||||||
|
taskId = clickedTask.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,10 +322,49 @@ fun KanbanScreen(
|
|||||||
|
|
||||||
summaryTarget?.let { (task, action) ->
|
summaryTarget?.let { (task, action) ->
|
||||||
CompleteTaskDialog(
|
CompleteTaskDialog(
|
||||||
onConfirm = { summary -> viewModel.moveTask(task, action, summary) },
|
onConfirm = { summary ->
|
||||||
|
summaryTarget = null
|
||||||
|
viewModel.moveTask(task, action, summary)
|
||||||
|
},
|
||||||
onDismiss = { summaryTarget = null },
|
onDismiss = { summaryTarget = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showFilterSheet) {
|
||||||
|
val assignees =
|
||||||
|
remember(state.tasks) {
|
||||||
|
state.tasks
|
||||||
|
.mapNotNull { it.assignee }
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
|
val tenants =
|
||||||
|
remember(state.tasks) {
|
||||||
|
state.tasks
|
||||||
|
.mapNotNull { it.tenant }
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
|
KanbanFilterSheet(
|
||||||
|
assignees = assignees,
|
||||||
|
tenants = tenants,
|
||||||
|
selectedAssignee = selectedAssignee,
|
||||||
|
selectedTenant = selectedTenant,
|
||||||
|
includeArchived = includeArchived,
|
||||||
|
groupRunning = groupRunning,
|
||||||
|
onSelectAssignee = { selectedAssignee = it },
|
||||||
|
onSelectTenant = { selectedTenant = it },
|
||||||
|
onToggleIncludeArchived = { includeArchived = it },
|
||||||
|
onToggleGroupRunning = { groupRunning = it },
|
||||||
|
onClearFilters = {
|
||||||
|
selectedAssignee = null
|
||||||
|
selectedTenant = null
|
||||||
|
includeArchived = false
|
||||||
|
groupRunning = false
|
||||||
|
},
|
||||||
|
onDismiss = { showFilterSheet = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,603 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
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.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.ChatBubbleOutline
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Description
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.History
|
||||||
|
import androidx.compose.material.icons.filled.RestartAlt
|
||||||
|
import androidx.compose.material.icons.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.PrimaryTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
|
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||||
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun KanbanTaskScreen(
|
||||||
|
boardSlug: String,
|
||||||
|
taskId: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: KanbanTaskViewModel = viewModel { KanbanTaskViewModel() },
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
||||||
|
val tabs = listOf("Overview", "Discussion", "Runs", "Activity")
|
||||||
|
|
||||||
|
LaunchedEffect(boardSlug, taskId) {
|
||||||
|
viewModel.loadTask(boardSlug, taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||||
|
|
||||||
|
HermesScaffold(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = state.detail?.task?.title ?: "Task Details",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "#${taskId.removePrefix("t_").take(8)} • $boardSlug",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = NavIcon.Back(onBack),
|
||||||
|
drawerGesturesEnabled = false,
|
||||||
|
isRefreshing = state.isLoading,
|
||||||
|
onRefresh = { viewModel.loadTask(boardSlug, taskId) },
|
||||||
|
) { paddingValues ->
|
||||||
|
when {
|
||||||
|
state.isLoading && state.detail == null -> {
|
||||||
|
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
||||||
|
}
|
||||||
|
|
||||||
|
state.errorMessage != null && state.detail == null -> {
|
||||||
|
ErrorState(
|
||||||
|
message = state.errorMessage ?: "",
|
||||||
|
onRetry = { viewModel.loadTask(boardSlug, taskId) },
|
||||||
|
modifier = Modifier.padding(paddingValues),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.detail != null -> {
|
||||||
|
val detail = state.detail!!
|
||||||
|
val task = detail.task
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTabIndex == index,
|
||||||
|
onClick = { selectedTabIndex = index },
|
||||||
|
text = { Text(title) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (selectedTabIndex) {
|
||||||
|
0 -> {
|
||||||
|
TaskOverviewTab(
|
||||||
|
task = task,
|
||||||
|
onSaveDescription = { newBody ->
|
||||||
|
viewModel.updateDescription(boardSlug, taskId, newBody)
|
||||||
|
},
|
||||||
|
onReclaim = {
|
||||||
|
viewModel.reclaim(boardSlug, taskId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
TaskDiscussionTab(
|
||||||
|
comments = detail.comments,
|
||||||
|
isRunning = task.status.equals("running", ignoreCase = true),
|
||||||
|
isSubmitting = state.isSubmittingComment,
|
||||||
|
onAddComment = { body ->
|
||||||
|
viewModel.addComment(boardSlug, taskId, body)
|
||||||
|
},
|
||||||
|
onNoteAndRequeue = { body ->
|
||||||
|
viewModel.postNoteAndRequeue(boardSlug, taskId, body)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
2 -> {
|
||||||
|
TaskRunsTab(
|
||||||
|
runs = detail.runs,
|
||||||
|
workerLog = state.workerLog,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
3 -> {
|
||||||
|
TaskActivityTab(
|
||||||
|
events = detail.events,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TaskOverviewTab(
|
||||||
|
task: com.m57.hermescontrol.data.model.KanbanTaskFull,
|
||||||
|
onSaveDescription: (String) -> Unit,
|
||||||
|
onReclaim: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var isEditingDescription by remember { mutableStateOf(false) }
|
||||||
|
var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") }
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
// Diagnostics alerts
|
||||||
|
if (task.diagnostics.isNotEmpty()) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
task.diagnostics.forEach { diag ->
|
||||||
|
Card(
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = statusColors.error.copy(alpha = 0.15f),
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = statusColors.error,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = diag.title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = statusColors.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = diag.detail,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
if (diag.actions.any { it.kind == "reclaim" }) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onReclaim,
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
) {
|
||||||
|
Text("Reclaim task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata card
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Attributes",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
MetaRow(label = "Status", value = task.status.uppercase())
|
||||||
|
task.assignee?.let { MetaRow(label = "Assignee", value = it) }
|
||||||
|
task.priority?.let { MetaRow(label = "Priority", value = it.toString()) }
|
||||||
|
task.tenant?.let { MetaRow(label = "Tenant", value = it) }
|
||||||
|
task.modelOverride?.let { MetaRow(label = "Model Override", value = it) }
|
||||||
|
task.reasoningEffort?.let { MetaRow(label = "Reasoning Effort", value = it) }
|
||||||
|
task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") }
|
||||||
|
task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description Section
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Description",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
if (!isEditingDescription) {
|
||||||
|
IconButton(onClick = { isEditingDescription = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Edit,
|
||||||
|
contentDescription = "Edit Description",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditingDescription) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = descriptionDraft,
|
||||||
|
onValueChange = { descriptionDraft = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 4,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(onClick = { isEditingDescription = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
onSaveDescription(descriptionDraft)
|
||||||
|
isEditingDescription = false
|
||||||
|
}) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = task.body?.ifBlank { "No description provided." } ?: "No description provided.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result/Summary Section if completed
|
||||||
|
val summary = task.result ?: task.latestSummary
|
||||||
|
if (!summary.isNullOrBlank()) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Latest Outcome / Summary",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = summary,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TaskDiscussionTab(
|
||||||
|
comments: List<com.m57.hermescontrol.data.model.KanbanComment>,
|
||||||
|
isRunning: Boolean,
|
||||||
|
isSubmitting: Boolean,
|
||||||
|
onAddComment: (String) -> Unit,
|
||||||
|
onNoteAndRequeue: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var commentText by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (comments.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
"No comments yet. Post a note to steer the agent.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(comments, key = { it.id }) { comment ->
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(10.dp)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = comment.author,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(text = comment.body, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = commentText,
|
||||||
|
onValueChange = { commentText = it },
|
||||||
|
placeholder = { Text("Write a steering note...") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
if (isRunning) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
onNoteAndRequeue(commentText)
|
||||||
|
commentText = ""
|
||||||
|
},
|
||||||
|
enabled = commentText.isNotBlank() && !isSubmitting,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Note & Requeue")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onAddComment(commentText)
|
||||||
|
commentText = ""
|
||||||
|
},
|
||||||
|
enabled = commentText.isNotBlank() && !isSubmitting,
|
||||||
|
) {
|
||||||
|
if (isSubmitting) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Post")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TaskRunsTab(
|
||||||
|
runs: List<com.m57.hermescontrol.data.model.KanbanRun>,
|
||||||
|
workerLog: com.m57.hermescontrol.data.model.WorkerLog?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize().padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text("Attempt History", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runs.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text("No worker attempts recorded yet.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(runs, key = { it.id }) { run ->
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Run #${run.id}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = (run.outcome ?: run.status).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color =
|
||||||
|
if (run.outcome == "completed") {
|
||||||
|
LocalHermesStatusColors.current.success
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
run.profile?.let {
|
||||||
|
Text("Profile: $it", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
val summary = run.error ?: run.summary
|
||||||
|
if (!summary.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(text = summary, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live Log Output
|
||||||
|
if (workerLog != null && workerLog.exists && workerLog.content.isNotBlank()) {
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("Worker Log Output", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = workerLog.content,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TaskActivityTab(
|
||||||
|
events: List<com.m57.hermescontrol.data.model.KanbanDetailEvent>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize().padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text("Event Timeline", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text("No activity events.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(events, key = { it.id }) { event ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, shape = CircleShape),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = event.kind,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetaRow(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(text = value, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ReassignTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.ReclaimTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.WorkerLog
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||||
|
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
|
||||||
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
|
||||||
|
data class KanbanTaskUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val detail: KanbanTaskDetailResponse? = null,
|
||||||
|
val workerLog: WorkerLog? = null,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val toastMessage: String? = null,
|
||||||
|
val isSubmittingComment: Boolean = false,
|
||||||
|
val isSavingDescription: Boolean = false,
|
||||||
|
val isReclaiming: Boolean = false,
|
||||||
|
val isUploadingAttachment: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class KanbanTaskViewModel(
|
||||||
|
private val repository: KanbanRepository = KanbanRepositoryImpl(),
|
||||||
|
) : ViewModel(),
|
||||||
|
ToastHost {
|
||||||
|
private val _uiState = MutableStateFlow(KanbanTaskUiState())
|
||||||
|
val uiState: StateFlow<KanbanTaskUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadTask(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
) {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.getTask(taskId = taskId, board = board)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
detail = result.data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (result.data.task.status
|
||||||
|
.equals("running", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
loadLog(board, taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Failed to load task details: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadLog(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.getTaskLog(taskId = taskId, board = board)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(workerLog = result.data) }
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
// Failures to read log don't blank the task screen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDescription(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
newBody: String,
|
||||||
|
) {
|
||||||
|
_uiState.update { it.copy(isSavingDescription = true) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result =
|
||||||
|
repository.updateTask(
|
||||||
|
taskId = taskId,
|
||||||
|
board = board,
|
||||||
|
body = UpdateTaskBody(body = newBody),
|
||||||
|
)
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
isSavingDescription = false,
|
||||||
|
detail =
|
||||||
|
state.detail?.let { d ->
|
||||||
|
d.copy(task = d.task.copy(body = newBody))
|
||||||
|
},
|
||||||
|
toastMessage = "Description saved",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isSavingDescription = false,
|
||||||
|
toastMessage = "Failed to save: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addComment(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
body: String,
|
||||||
|
) {
|
||||||
|
if (body.isBlank()) return
|
||||||
|
_uiState.update { it.copy(isSubmittingComment = true) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.addComment(taskId = taskId, board = board, body = body)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isSubmittingComment = false,
|
||||||
|
toastMessage = "Comment added",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loadTask(board, taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isSubmittingComment = false,
|
||||||
|
toastMessage = "Failed to add comment: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postNoteAndRequeue(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
body: String,
|
||||||
|
) {
|
||||||
|
if (body.isBlank()) return
|
||||||
|
_uiState.update { it.copy(isSubmittingComment = true) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val commentRes = repository.addComment(taskId = taskId, board = board, body = body)
|
||||||
|
if (commentRes is NetworkResult.Failure) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isSubmittingComment = false,
|
||||||
|
toastMessage = "Failed to post note: ${commentRes.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val reclaimRes = repository.reclaimTask(taskId = taskId, board = board, reason = "Requeued with note")
|
||||||
|
when (reclaimRes) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isSubmittingComment = false,
|
||||||
|
toastMessage = "Note posted & task requeued",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loadTask(board, taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isSubmittingComment = false,
|
||||||
|
toastMessage = "Note posted, but reclaim failed: ${reclaimRes.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reclaim(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
reason: String? = null,
|
||||||
|
) {
|
||||||
|
_uiState.update { it.copy(isReclaiming = true) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.reclaimTask(taskId = taskId, board = board, reason = reason)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isReclaiming = false,
|
||||||
|
toastMessage = "Task reclaimed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loadTask(board, taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isReclaiming = false,
|
||||||
|
toastMessage = "Failed to reclaim: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reassign(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
profile: String,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.reassignTask(taskId = taskId, board = board, profile = profile)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(toastMessage = "Task reassigned to $profile") }
|
||||||
|
loadTask(board, taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(toastMessage = "Failed to reassign: ${result.error.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uploadAttachment(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
part: MultipartBody.Part,
|
||||||
|
) {
|
||||||
|
_uiState.update { it.copy(isUploadingAttachment = true) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.uploadAttachment(taskId = taskId, board = board, file = part)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isUploadingAttachment = false,
|
||||||
|
toastMessage = "Attachment uploaded",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loadTask(board, taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isUploadingAttachment = false,
|
||||||
|
toastMessage = "Failed to upload: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearToast() {
|
||||||
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun KanbanFilterSheet(
|
||||||
|
assignees: List<String>,
|
||||||
|
tenants: List<String>,
|
||||||
|
selectedAssignee: String?,
|
||||||
|
selectedTenant: String?,
|
||||||
|
includeArchived: Boolean,
|
||||||
|
groupRunning: Boolean,
|
||||||
|
onSelectAssignee: (String?) -> Unit,
|
||||||
|
onSelectTenant: (String?) -> Unit,
|
||||||
|
onToggleIncludeArchived: (Boolean) -> Unit,
|
||||||
|
onToggleGroupRunning: (Boolean) -> Unit,
|
||||||
|
onClearFilters: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Filters & View Options",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
TextButton(onClick = onClearFilters) {
|
||||||
|
Text("Clear all")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Show Archived Switch
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text("Show archived tasks", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Switch(
|
||||||
|
checked = includeArchived,
|
||||||
|
onCheckedChange = onToggleIncludeArchived,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group Running Switch
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text("Group running lane by profile", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Switch(
|
||||||
|
checked = groupRunning,
|
||||||
|
onCheckedChange = onToggleGroupRunning,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
// Assignee Filter
|
||||||
|
Text(
|
||||||
|
text = "Assignee",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelectAssignee(null) }
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedAssignee == null,
|
||||||
|
onClick = { onSelectAssignee(null) },
|
||||||
|
)
|
||||||
|
Text("All assignees", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignees.forEach { assignee ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelectAssignee(assignee) }
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedAssignee == assignee,
|
||||||
|
onClick = { onSelectAssignee(assignee) },
|
||||||
|
)
|
||||||
|
Text(assignee, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenants.isNotEmpty()) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Tenant",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelectTenant(null) }
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedTenant == null,
|
||||||
|
onClick = { onSelectTenant(null) },
|
||||||
|
)
|
||||||
|
Text("All tenants", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
tenants.forEach { tenant ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelectTenant(tenant) }
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedTenant == tenant,
|
||||||
|
onClick = { onSelectTenant(tenant) },
|
||||||
|
)
|
||||||
|
Text(tenant, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
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.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.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun KanbanTaskCard(
|
||||||
|
task: KanbanTask,
|
||||||
|
onTaskClick: (KanbanTask) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isSelected: Boolean = false,
|
||||||
|
) {
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
val statusTone =
|
||||||
|
when (task.status.lowercase()) {
|
||||||
|
"ready" -> statusColors.info
|
||||||
|
"running" -> statusColors.success
|
||||||
|
"blocked" -> statusColors.error
|
||||||
|
"review" -> statusColors.warning
|
||||||
|
"done" -> statusColors.success
|
||||||
|
else -> MaterialTheme.colorScheme.outline
|
||||||
|
}
|
||||||
|
|
||||||
|
val cardColors =
|
||||||
|
if (isSelected) {
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CardDefaults.cardColors()
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = { onTaskClick(task) },
|
||||||
|
colors = cardColors,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(6.dp)
|
||||||
|
.background(color = statusTone, shape = CircleShape),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = task.status.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = statusTone,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "#${task.id.removePrefix("t_").take(6)}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = task.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
val summary = task.latestSummary ?: task.body
|
||||||
|
if (!summary.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = summary,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
task.assignee?.let { assignee ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(3.dp))
|
||||||
|
Text(
|
||||||
|
text = assignee,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Priority
|
||||||
|
val priority = task.priority ?: 0
|
||||||
|
if (priority > 0) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowUpward,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = statusColors.warning,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = priority.toString(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = statusColors.warning,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child progress
|
||||||
|
task.progress?.let { prog ->
|
||||||
|
if (prog.total > 0) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
Text(
|
||||||
|
text = "${prog.done}/${prog.total}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
if (task.commentCount > 0) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ChatBubbleOutline,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
Text(
|
||||||
|
text = task.commentCount.toString(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links
|
||||||
|
val links = task.linkCounts?.let { it.parents + it.children } ?: 0
|
||||||
|
if (links > 0) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Hub,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
Text(
|
||||||
|
text = links.toString(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warnings
|
||||||
|
val warnings = task.warnings?.count ?: 0
|
||||||
|
if (warnings > 0) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = statusColors.error,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
Text(
|
||||||
|
text = warnings.toString(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = statusColors.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTaskFull
|
||||||
|
import com.m57.hermescontrol.data.model.ReassignTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.WorkerLog
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class KanbanTaskViewModelTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
||||||
|
|
||||||
|
private fun createViewModel(): KanbanTaskViewModel =
|
||||||
|
KanbanTaskViewModel(
|
||||||
|
repository = mockRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLoadTaskSuccess() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNotNull(vm.uiState.value.detail)
|
||||||
|
assertEquals(
|
||||||
|
"Task 1",
|
||||||
|
vm.uiState.value.detail
|
||||||
|
?.task
|
||||||
|
?.title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLoadRunningTaskLoadsLog() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Running task", status = "running")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
coEvery { mockRepository.getTaskLog("t_1", "dev", any()) } returns
|
||||||
|
NetworkResult.Success(WorkerLog(taskId = "t_1", content = "Log line 1", exists = true))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"Log line 1",
|
||||||
|
vm.uiState.value.workerLog
|
||||||
|
?.content,
|
||||||
|
)
|
||||||
|
coVerify { mockRepository.getTaskLog("t_1", "dev", any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testUpdateDescription() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Task 1", body = "Old body", status = "todo")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
coEvery { mockRepository.updateTask("t_1", "dev", any()) } returns
|
||||||
|
NetworkResult.Success(UpdateTaskResponse())
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.updateDescription("dev", "t_1", "New description")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"New description",
|
||||||
|
vm.uiState.value.detail
|
||||||
|
?.task
|
||||||
|
?.body,
|
||||||
|
)
|
||||||
|
assertEquals("Description saved", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAddComment() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
coEvery { mockRepository.addComment("t_1", "dev", "Steering note") } returns
|
||||||
|
NetworkResult.Success(Unit)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.addComment("dev", "t_1", "Steering note")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify { mockRepository.addComment("t_1", "dev", "Steering note") }
|
||||||
|
assertEquals("Comment added", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testPostNoteAndRequeue() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Running task", status = "running")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
coEvery { mockRepository.addComment("t_1", "dev", "Stop now") } returns
|
||||||
|
NetworkResult.Success(Unit)
|
||||||
|
coEvery { mockRepository.reclaimTask("t_1", "dev", any()) } returns
|
||||||
|
NetworkResult.Success(ReclaimTaskResponse(taskId = "t_1"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.postNoteAndRequeue("dev", "t_1", "Stop now")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify { mockRepository.addComment("t_1", "dev", "Stop now") }
|
||||||
|
coVerify { mockRepository.reclaimTask("t_1", "dev", match { it.contains("Requeued") }) }
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("requeued") == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testReassign() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockRepository.reassignTask("t_1", "dev", "coder", true) } returns
|
||||||
|
NetworkResult.Success(ReassignTaskResponse(taskId = "t_1", assignee = "coder"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.reassign("dev", "t_1", "coder")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify { mockRepository.reassignTask("t_1", "dev", "coder", true) }
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("coder") == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user