feat(kanban): add rich task card, filter sheet, and task detail screen
This commit is contained in:
1 parent
da03500b30
commit
dd5ce10796
8 files changed
+1613
-8
No files matched your search
@@ -174,6 +174,15 @@ private fun appEntryProvider(
|
||||
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
|
||||
|
||||
@@ -65,6 +65,11 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object KanbanScreen : NavKey
|
||||
|
||||
@Serializable data class KanbanTaskDetailKey(
|
||||
val boardSlug: String,
|
||||
val taskId: String,
|
||||
) : NavKey
|
||||
|
||||
@Serializable data object ProcessesScreen : 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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
@@ -46,6 +47,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.data.model.KanbanTask
|
||||
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.SkeletonListState
|
||||
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"
|
||||
|
||||
@@ -67,14 +72,26 @@ fun KanbanScreen(
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
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 =
|
||||
remember(query, state.tasks) {
|
||||
remember(query, state.tasks, selectedAssignee, selectedTenant) {
|
||||
state.tasks.filter { task ->
|
||||
task.title.contains(query, ignoreCase = true) ||
|
||||
task.description?.contains(query, ignoreCase = true) == true ||
|
||||
task.status.contains(query, ignoreCase = true) ||
|
||||
task.assignedTo?.contains(query, ignoreCase = true) == true
|
||||
val matchesQuery =
|
||||
query.isBlank() ||
|
||||
task.title.contains(query, ignoreCase = 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)
|
||||
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 }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
@@ -228,9 +253,18 @@ fun KanbanScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(colTasks, key = { it.id }) { task ->
|
||||
TaskCard(
|
||||
KanbanTaskCard(
|
||||
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) ->
|
||||
CompleteTaskDialog(
|
||||
onConfirm = { summary -> viewModel.moveTask(task, action, summary) },
|
||||
onConfirm = { summary ->
|
||||
summaryTarget = null
|
||||
viewModel.moveTask(task, action, summary)
|
||||
},
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user