feat(kanban): add rich task card, filter sheet, and task detail screen

This commit is contained in:
M57 committed 2026-09-14 22:58:48 +03:00
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,
)
}
}
}
}
}
}