From dd5ce1079654a7bd0d9e7c703930d9398ada274c Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:58:48 +0300 Subject: [PATCH] feat(kanban): add rich task card, filter sheet, and task detail screen --- .../java/com/m57/hermescontrol/Navigation.kt | 9 + .../com/m57/hermescontrol/NavigationKeys.kt | 5 + .../hermescontrol/ui/kanban/KanbanScreen.kt | 89 ++- .../ui/kanban/KanbanTaskScreen.kt | 603 ++++++++++++++++++ .../ui/kanban/KanbanTaskViewModel.kt | 287 +++++++++ .../ui/kanban/components/KanbanFilterSheet.kt | 189 ++++++ .../ui/kanban/components/KanbanTaskCard.kt | 264 ++++++++ .../ui/kanban/KanbanTaskViewModelTest.kt | 175 +++++ 8 files changed, 1613 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 0f7d575d..8ec9ed90 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -174,6 +174,15 @@ private fun appEntryProvider( onBack = { NavigationController.goBack() }, ) } + + // ── Kanban Task Detail ───────────────────────────────────────────── + entry { key -> + com.m57.hermescontrol.ui.kanban.KanbanTaskScreen( + boardSlug = key.boardSlug, + taskId = key.taskId, + onBack = { NavigationController.goBack() }, + ) + } } @Composable diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 776be2da..7f863c92 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index f0476e22..6a8edecb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -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(null) } + var selectedTenant by remember { mutableStateOf(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 }, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt new file mode 100644 index 00000000..52e7c4d3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt @@ -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, + 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, + 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, + 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) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt new file mode 100644 index 00000000..9ac1a067 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt @@ -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 = _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) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt new file mode 100644 index 00000000..4dabbdfd --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt @@ -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, + tenants: List, + 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)) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt new file mode 100644 index 00000000..a60ac957 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt @@ -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, + ) + } + } + } + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt new file mode 100644 index 00000000..c9f19908 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt @@ -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(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, + ) + } +}