Merge pull request #1112 from Hy4ri/feat/kanban-parity-foundation
feat(kanban): desktop parity foundation and task inspection
This commit is contained in:
32 files changed
+5950
-247
No files matched your search
@@ -174,6 +174,15 @@ private fun appEntryProvider(
|
|||||||
onBack = { NavigationController.goBack() },
|
onBack = { NavigationController.goBack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Kanban Task Detail ─────────────────────────────────────────────
|
||||||
|
entry<KanbanTaskDetailKey> { key ->
|
||||||
|
com.m57.hermescontrol.ui.kanban.KanbanTaskScreen(
|
||||||
|
boardSlug = key.boardSlug,
|
||||||
|
taskId = key.taskId,
|
||||||
|
onBack = { NavigationController.goBack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object KanbanScreen : NavKey
|
@Serializable data object KanbanScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data class KanbanTaskDetailKey(
|
||||||
|
val boardSlug: String,
|
||||||
|
val taskId: String,
|
||||||
|
) : NavKey
|
||||||
|
|
||||||
@Serializable data object ProcessesScreen : NavKey
|
@Serializable data object ProcessesScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object ProvidersScreen : NavKey
|
@Serializable data object ProvidersScreen : NavKey
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.m57.hermescontrol.data.local
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local preferences for Kanban board viewing:
|
||||||
|
* selected board slug, view mode (list vs board), archive visibility, and group running.
|
||||||
|
*/
|
||||||
|
interface KanbanPreferencesStore {
|
||||||
|
fun getSelectedBoard(endpoint: String): String?
|
||||||
|
|
||||||
|
fun setSelectedBoard(
|
||||||
|
endpoint: String,
|
||||||
|
slug: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getViewMode(): String
|
||||||
|
|
||||||
|
fun setViewMode(mode: String)
|
||||||
|
|
||||||
|
fun getIncludeArchived(): Boolean
|
||||||
|
|
||||||
|
fun setIncludeArchived(include: Boolean)
|
||||||
|
|
||||||
|
fun getGroupRunning(): Boolean
|
||||||
|
|
||||||
|
fun setGroupRunning(group: Boolean)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MODE_LIST = "list"
|
||||||
|
const val MODE_BOARD = "board"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InMemoryKanbanPreferencesStore : KanbanPreferencesStore {
|
||||||
|
private val selectedBoards = mutableMapOf<String, String>()
|
||||||
|
private var viewMode: String = KanbanPreferencesStore.MODE_LIST
|
||||||
|
private var includeArchived: Boolean = false
|
||||||
|
private var groupRunning: Boolean = false
|
||||||
|
|
||||||
|
override fun getSelectedBoard(endpoint: String): String? = selectedBoards[endpoint]
|
||||||
|
|
||||||
|
override fun setSelectedBoard(
|
||||||
|
endpoint: String,
|
||||||
|
slug: String,
|
||||||
|
) {
|
||||||
|
selectedBoards[endpoint] = slug
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getViewMode(): String = viewMode
|
||||||
|
|
||||||
|
override fun setViewMode(mode: String) {
|
||||||
|
viewMode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getIncludeArchived(): Boolean = includeArchived
|
||||||
|
|
||||||
|
override fun setIncludeArchived(include: Boolean) {
|
||||||
|
includeArchived = include
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getGroupRunning(): Boolean = groupRunning
|
||||||
|
|
||||||
|
override fun setGroupRunning(group: Boolean) {
|
||||||
|
groupRunning = group
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class KanbanBoard(
|
|
||||||
@SerialName("slug") val id: String,
|
|
||||||
val name: String,
|
|
||||||
val description: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class KanbanTask(
|
|
||||||
val id: String,
|
|
||||||
val title: String,
|
|
||||||
@SerialName("body") val description: String? = null,
|
|
||||||
val status: String,
|
|
||||||
@SerialName("assignee") val assignedTo: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class CreateTaskBody(
|
|
||||||
val title: String,
|
|
||||||
val body: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class KanbanColumn(
|
|
||||||
val name: String,
|
|
||||||
val tasks: List<KanbanTask>,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class KanbanBoardResponse(
|
|
||||||
val columns: List<KanbanColumn>,
|
|
||||||
val assignees: List<String>? = null,
|
|
||||||
val tenants: List<String>? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class KanbanBoardsResponse(
|
|
||||||
val boards: List<KanbanBoard>,
|
|
||||||
val current: String? = null,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanComment(
|
||||||
|
val id: Long,
|
||||||
|
val author: String,
|
||||||
|
val body: String,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanDetailEvent(
|
||||||
|
val id: Long,
|
||||||
|
val kind: String,
|
||||||
|
val payload: JsonElement? = null,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanRun(
|
||||||
|
val id: Long,
|
||||||
|
val profile: String? = null,
|
||||||
|
val status: String,
|
||||||
|
val outcome: String? = null,
|
||||||
|
val summary: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val metadata: JsonElement? = null,
|
||||||
|
val workerPid: Int? = null,
|
||||||
|
val startedAt: Long? = null,
|
||||||
|
val endedAt: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WorkerLog(
|
||||||
|
val taskId: String,
|
||||||
|
val path: String? = null,
|
||||||
|
val exists: Boolean = false,
|
||||||
|
val sizeBytes: Long = 0L,
|
||||||
|
val content: String = "",
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanAttachment(
|
||||||
|
val id: Long,
|
||||||
|
val taskId: String? = null,
|
||||||
|
val filename: String,
|
||||||
|
val contentType: String? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
val uploadedBy: String? = null,
|
||||||
|
val storedPath: String? = null,
|
||||||
|
val createdAt: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AttachmentUploadResponse(
|
||||||
|
val attachment: KanbanAttachment? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanBoard(
|
||||||
|
@SerialName("slug") val id: String,
|
||||||
|
val name: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val isCurrent: Boolean = false,
|
||||||
|
val total: Int? = null,
|
||||||
|
val counts: Map<String, Int> = emptyMap(),
|
||||||
|
val defaultWorkdir: String? = null,
|
||||||
|
val defaultWorkspaceKind: String? = null,
|
||||||
|
val projectId: String? = null,
|
||||||
|
val projectName: String? = null,
|
||||||
|
val createdAt: Long? = null,
|
||||||
|
val archived: Boolean = false,
|
||||||
|
) {
|
||||||
|
val slug: String get() = id
|
||||||
|
val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanColumn(
|
||||||
|
val name: String,
|
||||||
|
val tasks: List<KanbanTask> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanBoardResponse(
|
||||||
|
val columns: List<KanbanColumn> = emptyList(),
|
||||||
|
val assignees: List<String>? = null,
|
||||||
|
val tenants: List<String>? = null,
|
||||||
|
val latestEventId: Long = 0L,
|
||||||
|
val now: Long = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanBoardsResponse(
|
||||||
|
val boards: List<KanbanBoard> = emptyList(),
|
||||||
|
val current: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanProject(
|
||||||
|
val id: String,
|
||||||
|
val slug: String,
|
||||||
|
val name: String,
|
||||||
|
val primaryPath: String? = null,
|
||||||
|
val icon: String? = null,
|
||||||
|
val color: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanProjectsResponse(
|
||||||
|
val projects: List<KanbanProject> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CreateBoardBody(
|
||||||
|
val slug: String,
|
||||||
|
val name: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val projectId: String? = null,
|
||||||
|
val switch: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CreateBoardResponse(
|
||||||
|
val board: KanbanBoard? = null,
|
||||||
|
val current: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RenameBoardBody(
|
||||||
|
val name: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val defaultWorkdir: String? = null,
|
||||||
|
val projectId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RenameBoardResponse(
|
||||||
|
val board: KanbanBoard? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeleteBoardResponse(
|
||||||
|
val result: JsonElement? = null,
|
||||||
|
val current: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanProfile(
|
||||||
|
val name: String,
|
||||||
|
val isDefault: Boolean = false,
|
||||||
|
val description: String = "",
|
||||||
|
val descriptionAuto: Boolean = false,
|
||||||
|
val model: String? = null,
|
||||||
|
val provider: String? = null,
|
||||||
|
val skillCount: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanProfilesResponse(
|
||||||
|
val profiles: List<KanbanProfile> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OrchestrationSettings(
|
||||||
|
val orchestratorProfile: String = "",
|
||||||
|
val defaultAssignee: String = "",
|
||||||
|
val autoDecompose: Boolean = true,
|
||||||
|
val autoPromoteChildren: Boolean = true,
|
||||||
|
val resolvedOrchestratorProfile: String = "",
|
||||||
|
val resolvedDefaultAssignee: String = "",
|
||||||
|
val activeProfile: String = "default",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OrchestrationSettingsUpdate(
|
||||||
|
val orchestratorProfile: String? = null,
|
||||||
|
val defaultAssignee: String? = null,
|
||||||
|
val autoDecompose: Boolean? = null,
|
||||||
|
val autoPromoteChildren: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AutoDescribeResponse(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
val profile: String? = null,
|
||||||
|
val reason: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BoardExportResult(
|
||||||
|
val board: String,
|
||||||
|
val archive: String,
|
||||||
|
val size: Long = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BoardImportResult(
|
||||||
|
val board: String,
|
||||||
|
val name: String = "",
|
||||||
|
val renamed: Boolean = false,
|
||||||
|
val requestedBoard: String = "",
|
||||||
|
val counts: Map<String, Int> = emptyMap(),
|
||||||
|
val warnings: List<String> = emptyList(),
|
||||||
|
val current: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DispatchResult(
|
||||||
|
val spawned: List<JsonElement> = emptyList(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TaskLinkCounts(
|
||||||
|
val parents: Int = 0,
|
||||||
|
val children: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TaskProgress(
|
||||||
|
val done: Int = 0,
|
||||||
|
val total: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TaskWarnings(
|
||||||
|
val count: Int = 0,
|
||||||
|
val highestSeverity: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TaskDiagnosticAction(
|
||||||
|
val kind: String,
|
||||||
|
val label: String,
|
||||||
|
val payload: Map<String, JsonElement> = emptyMap(),
|
||||||
|
val suggested: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TaskDiagnostic(
|
||||||
|
val kind: String,
|
||||||
|
val severity: String,
|
||||||
|
val title: String,
|
||||||
|
val detail: String,
|
||||||
|
val actions: List<TaskDiagnosticAction> = emptyList(),
|
||||||
|
val count: Int = 1,
|
||||||
|
val lastSeenAt: Long = 0L,
|
||||||
|
val data: Map<String, JsonElement> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TaskLinks(
|
||||||
|
val parents: List<String> = emptyList(),
|
||||||
|
val children: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChildTaskResult(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val status: String,
|
||||||
|
val latestSummary: String? = null,
|
||||||
|
val result: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanTask(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val body: String? = null,
|
||||||
|
val status: String,
|
||||||
|
val assignee: String? = null,
|
||||||
|
val priority: Int? = null,
|
||||||
|
val tenant: String? = null,
|
||||||
|
val createdAt: Long? = null,
|
||||||
|
val latestSummary: String? = null,
|
||||||
|
val commentCount: Int = 0,
|
||||||
|
val linkCounts: TaskLinkCounts? = null,
|
||||||
|
val progress: TaskProgress? = null,
|
||||||
|
val warnings: TaskWarnings? = null,
|
||||||
|
val startedAt: Long? = null,
|
||||||
|
val workerPid: Int? = null,
|
||||||
|
val lastHeartbeatAt: Long? = null,
|
||||||
|
) {
|
||||||
|
val description: String? get() = body
|
||||||
|
val assignedTo: String? get() = assignee
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
description: String? = null,
|
||||||
|
status: String,
|
||||||
|
assignedTo: String? = null,
|
||||||
|
) : this(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
body = description,
|
||||||
|
status = status,
|
||||||
|
assignee = assignedTo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanTaskFull(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val body: String? = null,
|
||||||
|
val status: String,
|
||||||
|
val assignee: String? = null,
|
||||||
|
val priority: Int? = null,
|
||||||
|
val tenant: String? = null,
|
||||||
|
val createdAt: Long? = null,
|
||||||
|
val latestSummary: String? = null,
|
||||||
|
val commentCount: Int = 0,
|
||||||
|
val linkCounts: TaskLinkCounts? = null,
|
||||||
|
val progress: TaskProgress? = null,
|
||||||
|
val warnings: TaskWarnings? = null,
|
||||||
|
val startedAt: Long? = null,
|
||||||
|
val workerPid: Int? = null,
|
||||||
|
val lastHeartbeatAt: Long? = null,
|
||||||
|
val result: String? = null,
|
||||||
|
val createdBy: String? = null,
|
||||||
|
val modelOverride: String? = null,
|
||||||
|
val providerOverride: String? = null,
|
||||||
|
val reasoningEffort: String? = null,
|
||||||
|
val completedAt: Long? = null,
|
||||||
|
val lastFailureError: String? = null,
|
||||||
|
val workspaceKind: String? = null,
|
||||||
|
val workspacePath: String? = null,
|
||||||
|
val branchName: String? = null,
|
||||||
|
val consecutiveFailures: Int = 0,
|
||||||
|
val diagnostics: List<TaskDiagnostic> = emptyList(),
|
||||||
|
) {
|
||||||
|
val description: String? get() = body
|
||||||
|
val assignedTo: String? get() = assignee
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KanbanTaskDetailResponse(
|
||||||
|
val task: KanbanTaskFull,
|
||||||
|
val comments: List<KanbanComment> = emptyList(),
|
||||||
|
val events: List<KanbanDetailEvent> = emptyList(),
|
||||||
|
val attachments: List<KanbanAttachment>? = null,
|
||||||
|
val links: TaskLinks = TaskLinks(),
|
||||||
|
val childResults: List<ChildTaskResult> = emptyList(),
|
||||||
|
val runs: List<KanbanRun> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CreateTaskBody(
|
||||||
|
val title: String,
|
||||||
|
val body: String? = null,
|
||||||
|
val assignee: String? = null,
|
||||||
|
val tenant: String? = null,
|
||||||
|
val priority: Int = 0,
|
||||||
|
val workspaceKind: String? = null,
|
||||||
|
val workspacePath: String? = null,
|
||||||
|
val parents: List<String> = emptyList(),
|
||||||
|
val triage: Boolean = false,
|
||||||
|
val maxRuntimeSeconds: Int? = null,
|
||||||
|
val skills: List<String>? = null,
|
||||||
|
val goalMode: Boolean = false,
|
||||||
|
val goalMaxTurns: Int? = null,
|
||||||
|
val modelOverride: String? = null,
|
||||||
|
val providerOverride: String? = null,
|
||||||
|
val reasoningEffort: String? = null,
|
||||||
|
val projectId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CreateTaskResponse(
|
||||||
|
val task: KanbanTask? = null,
|
||||||
|
val warning: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UpdateTaskBody(
|
||||||
|
val status: String? = null,
|
||||||
|
val assignee: String? = null,
|
||||||
|
val priority: Int? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val body: String? = null,
|
||||||
|
val result: String? = null,
|
||||||
|
val blockReason: String? = null,
|
||||||
|
val summary: String? = null,
|
||||||
|
val metadata: Map<String, JsonElement>? = null,
|
||||||
|
val modelOverride: String? = null,
|
||||||
|
val providerOverride: String? = null,
|
||||||
|
val clearModelOverride: Boolean = false,
|
||||||
|
val reasoningEffort: String? = null,
|
||||||
|
val clearReasoningEffort: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UpdateTaskResponse(
|
||||||
|
val task: KanbanTask? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BulkTasksBody(
|
||||||
|
val ids: List<String>,
|
||||||
|
val status: String? = null,
|
||||||
|
val assignee: String? = null,
|
||||||
|
val priority: Int? = null,
|
||||||
|
val archive: Boolean = false,
|
||||||
|
val result: String? = null,
|
||||||
|
val summary: String? = null,
|
||||||
|
val metadata: Map<String, JsonElement>? = null,
|
||||||
|
val reclaimFirst: Boolean = false,
|
||||||
|
val modelOverride: String? = null,
|
||||||
|
val providerOverride: String? = null,
|
||||||
|
val clearModelOverride: Boolean = false,
|
||||||
|
val reasoningEffort: String? = null,
|
||||||
|
val clearReasoningEffort: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BulkTaskResult(
|
||||||
|
val id: String,
|
||||||
|
val ok: Boolean,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BulkTasksResponse(
|
||||||
|
val results: List<BulkTaskResult> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReassignTaskBody(
|
||||||
|
val profile: String? = null,
|
||||||
|
val reclaimFirst: Boolean = true,
|
||||||
|
val reason: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReassignTaskResponse(
|
||||||
|
val ok: Boolean = true,
|
||||||
|
val taskId: String,
|
||||||
|
val assignee: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReclaimTaskBody(
|
||||||
|
val reason: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReclaimTaskResponse(
|
||||||
|
val ok: Boolean = true,
|
||||||
|
val taskId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TaskEstimate(
|
||||||
|
val ok: Boolean,
|
||||||
|
val reason: String? = null,
|
||||||
|
val estTokens: Int? = null,
|
||||||
|
val complexity: String? = null,
|
||||||
|
val rationale: String? = null,
|
||||||
|
val model: String? = null,
|
||||||
|
)
|
||||||
@@ -30,6 +30,10 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The current [KanbanApiService] instance. Reuses [hermesApi]. */
|
||||||
|
val kanbanApi: KanbanApiService
|
||||||
|
get() = hermesApi
|
||||||
|
|
||||||
/** Force-rebuild the Retrofit client (e.g. after settings change). */
|
/** Force-rebuild the Retrofit client (e.g. after settings change). */
|
||||||
fun rebuild() {
|
fun rebuild() {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
@@ -79,7 +83,15 @@ object ApiClient {
|
|||||||
|
|
||||||
// ── Internal ─────────────────────────────────────────────────────────
|
// ── Internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun buildService(): HermesApiService {
|
private fun buildService(): HermesApiService =
|
||||||
|
buildRetrofit().create(HermesApiService::class.java).also {
|
||||||
|
service = it
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildRetrofit(): Retrofit {
|
||||||
|
val cached = retrofit
|
||||||
|
if (cached != null) return cached
|
||||||
|
|
||||||
val logging =
|
val logging =
|
||||||
HttpLoggingInterceptor().apply {
|
HttpLoggingInterceptor().apply {
|
||||||
level =
|
level =
|
||||||
@@ -129,15 +141,12 @@ object ApiClient {
|
|||||||
.authenticator(TokenRefreshAuthenticator)
|
.authenticator(TokenRefreshAuthenticator)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val rf =
|
return Retrofit
|
||||||
Retrofit
|
|
||||||
.Builder()
|
.Builder()
|
||||||
.baseUrl(AuthManager.endpointForBuild().baseUrl)
|
.baseUrl(AuthManager.endpointForBuild().baseUrl)
|
||||||
.client(okHttp)
|
.client(okHttp)
|
||||||
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
|
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
|
||||||
.build()
|
.build()
|
||||||
.also { retrofit = it }
|
.also { retrofit = it }
|
||||||
|
|
||||||
return rf.create(HermesApiService::class.java)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ import retrofit2.http.Path
|
|||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import retrofit2.http.Streaming
|
import retrofit2.http.Streaming
|
||||||
|
|
||||||
interface HermesApiService {
|
interface HermesApiService : KanbanApiService {
|
||||||
@GET("api/skills/content")
|
@GET("api/skills/content")
|
||||||
suspend fun getSkillContent(
|
suspend fun getSkillContent(
|
||||||
@Query("name") name: String,
|
@Query("name") name: String,
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package com.m57.hermescontrol.data.remote
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
|
||||||
|
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
||||||
|
import com.m57.hermescontrol.data.model.BulkTasksBody
|
||||||
|
import com.m57.hermescontrol.data.model.BulkTasksResponse
|
||||||
|
import com.m57.hermescontrol.data.model.CreateBoardBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.DeleteBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.DispatchResult
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanProjectsResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
|
||||||
|
import com.m57.hermescontrol.data.model.OrchestrationSettings
|
||||||
|
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
|
||||||
|
import com.m57.hermescontrol.data.model.ReassignTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.ReassignTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ReclaimTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.RenameBoardBody
|
||||||
|
import com.m57.hermescontrol.data.model.RenameBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.TaskEstimate
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.WorkerLog
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Multipart
|
||||||
|
import retrofit2.http.PATCH
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.PUT
|
||||||
|
import retrofit2.http.Part
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
import retrofit2.http.Streaming
|
||||||
|
|
||||||
|
interface KanbanApiService {
|
||||||
|
@GET("api/plugins/kanban/boards")
|
||||||
|
suspend fun getBoards(
|
||||||
|
@Query("include_archived") includeArchived: Boolean = false,
|
||||||
|
): Response<KanbanBoardsResponse>
|
||||||
|
|
||||||
|
@GET("api/plugins/kanban/board")
|
||||||
|
suspend fun getBoard(
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
@Query("include_archived") includeArchived: Boolean = false,
|
||||||
|
@Query("tenant") tenant: String? = null,
|
||||||
|
): Response<KanbanBoardResponse>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/boards")
|
||||||
|
suspend fun createBoard(
|
||||||
|
@Body body: CreateBoardBody,
|
||||||
|
): Response<CreateBoardResponse>
|
||||||
|
|
||||||
|
@PATCH("api/plugins/kanban/boards/{slug}")
|
||||||
|
suspend fun updateBoard(
|
||||||
|
@Path("slug") slug: String,
|
||||||
|
@Body body: RenameBoardBody,
|
||||||
|
): Response<RenameBoardResponse>
|
||||||
|
|
||||||
|
@DELETE("api/plugins/kanban/boards/{slug}")
|
||||||
|
suspend fun deleteBoard(
|
||||||
|
@Path("slug") slug: String,
|
||||||
|
@Query("delete") delete: Boolean = false,
|
||||||
|
): Response<DeleteBoardResponse>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/boards/{slug}/switch")
|
||||||
|
suspend fun switchBoard(
|
||||||
|
@Path("slug") slug: String,
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/plugins/kanban/tasks/{id}")
|
||||||
|
suspend fun getTask(
|
||||||
|
@Path("id") taskId: String,
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
): Response<KanbanTaskDetailResponse>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/tasks")
|
||||||
|
suspend fun createTask(
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
@Body body: CreateTaskBody,
|
||||||
|
): Response<CreateTaskResponse>
|
||||||
|
|
||||||
|
@PATCH("api/plugins/kanban/tasks/{id}")
|
||||||
|
suspend fun updateTask(
|
||||||
|
@Path("id") taskId: String,
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
@Body body: UpdateTaskBody,
|
||||||
|
): Response<UpdateTaskResponse>
|
||||||
|
|
||||||
|
@DELETE("api/plugins/kanban/tasks/{id}")
|
||||||
|
suspend fun deleteTask(
|
||||||
|
@Path("id") taskId: String,
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/tasks/bulk")
|
||||||
|
suspend fun bulkTasks(
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
@Body body: BulkTasksBody,
|
||||||
|
): Response<BulkTasksResponse>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/tasks/{id}/comments")
|
||||||
|
suspend fun addComment(
|
||||||
|
@Path("id") taskId: String,
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
@Body body: Map<String, String>,
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/tasks/{id}/reassign")
|
||||||
|
suspend fun reassignTask(
|
||||||
|
@Path("id") taskId: String,
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
@Body body: ReassignTaskBody,
|
||||||
|
): Response<ReassignTaskResponse>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/tasks/{id}/reclaim")
|
||||||
|
suspend fun reclaimTask(
|
||||||
|
@Path("id") taskId: String,
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
@Body body: ReclaimTaskBody = ReclaimTaskBody(),
|
||||||
|
): Response<ReclaimTaskResponse>
|
||||||
|
|
||||||
|
@GET("api/plugins/kanban/tasks/{id}/log")
|
||||||
|
suspend fun getTaskLog(
|
||||||
|
@Path("id") taskId: String,
|
||||||
|
@Query("tail") tail: Int = 16384,
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
): Response<WorkerLog>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/tasks/{id}/estimate")
|
||||||
|
suspend fun estimateTask(
|
||||||
|
@Path("id") taskId: String,
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
): Response<TaskEstimate>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/estimate")
|
||||||
|
suspend fun estimateNew(
|
||||||
|
@Body body: Map<String, String>,
|
||||||
|
): Response<TaskEstimate>
|
||||||
|
|
||||||
|
@Multipart
|
||||||
|
@POST("api/plugins/kanban/tasks/{id}/attachments")
|
||||||
|
suspend fun uploadAttachment(
|
||||||
|
@Path("id") taskId: String,
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
@Part file: MultipartBody.Part,
|
||||||
|
): Response<AttachmentUploadResponse>
|
||||||
|
|
||||||
|
@Streaming
|
||||||
|
@GET("api/plugins/kanban/attachments/{id}")
|
||||||
|
suspend fun downloadAttachment(
|
||||||
|
@Path("id") attachmentId: Long,
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
): Response<ResponseBody>
|
||||||
|
|
||||||
|
@GET("api/plugins/kanban/profiles")
|
||||||
|
suspend fun getKanbanProfiles(): Response<KanbanProfilesResponse>
|
||||||
|
|
||||||
|
@PATCH("api/plugins/kanban/profiles/{name}")
|
||||||
|
suspend fun updateProfileDescription(
|
||||||
|
@Path("name") name: String,
|
||||||
|
@Body body: Map<String, String>,
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/profiles/{name}/describe-auto")
|
||||||
|
suspend fun autoDescribeProfile(
|
||||||
|
@Path("name") name: String,
|
||||||
|
@Body body: Map<String, Boolean> = mapOf("overwrite" to true),
|
||||||
|
): Response<AutoDescribeResponse>
|
||||||
|
|
||||||
|
@GET("api/plugins/kanban/orchestration")
|
||||||
|
suspend fun getOrchestration(): Response<OrchestrationSettings>
|
||||||
|
|
||||||
|
@PUT("api/plugins/kanban/orchestration")
|
||||||
|
suspend fun updateOrchestration(
|
||||||
|
@Body body: OrchestrationSettingsUpdate,
|
||||||
|
): Response<OrchestrationSettings>
|
||||||
|
|
||||||
|
@GET("api/plugins/kanban/projects")
|
||||||
|
suspend fun getProjects(): Response<KanbanProjectsResponse>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/dispatch")
|
||||||
|
suspend fun nudgeDispatcher(
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
): Response<DispatchResult>
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package com.m57.hermescontrol.data.repository
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
|
||||||
|
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
||||||
|
import com.m57.hermescontrol.data.model.BulkTasksBody
|
||||||
|
import com.m57.hermescontrol.data.model.BulkTasksResponse
|
||||||
|
import com.m57.hermescontrol.data.model.CreateBoardBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.DeleteBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.DispatchResult
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanProjectsResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
|
||||||
|
import com.m57.hermescontrol.data.model.OrchestrationSettings
|
||||||
|
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
|
||||||
|
import com.m57.hermescontrol.data.model.ReassignTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.ReassignTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ReclaimTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.RenameBoardBody
|
||||||
|
import com.m57.hermescontrol.data.model.RenameBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.TaskEstimate
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.WorkerLog
|
||||||
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
|
import com.m57.hermescontrol.data.remote.KanbanApiService
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
|
||||||
|
interface KanbanRepository {
|
||||||
|
suspend fun getBoards(includeArchived: Boolean = false): NetworkResult<KanbanBoardsResponse>
|
||||||
|
|
||||||
|
suspend fun getBoard(
|
||||||
|
board: String,
|
||||||
|
includeArchived: Boolean = false,
|
||||||
|
tenant: String? = null,
|
||||||
|
): NetworkResult<KanbanBoardResponse>
|
||||||
|
|
||||||
|
suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse>
|
||||||
|
|
||||||
|
suspend fun updateBoard(
|
||||||
|
slug: String,
|
||||||
|
body: RenameBoardBody,
|
||||||
|
): NetworkResult<RenameBoardResponse>
|
||||||
|
|
||||||
|
suspend fun deleteBoard(
|
||||||
|
slug: String,
|
||||||
|
delete: Boolean = false,
|
||||||
|
): NetworkResult<DeleteBoardResponse>
|
||||||
|
|
||||||
|
suspend fun getTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<KanbanTaskDetailResponse>
|
||||||
|
|
||||||
|
suspend fun createTask(
|
||||||
|
board: String?,
|
||||||
|
body: CreateTaskBody,
|
||||||
|
): NetworkResult<CreateTaskResponse>
|
||||||
|
|
||||||
|
suspend fun updateTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
body: UpdateTaskBody,
|
||||||
|
): NetworkResult<UpdateTaskResponse>
|
||||||
|
|
||||||
|
suspend fun deleteTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<Unit>
|
||||||
|
|
||||||
|
suspend fun bulkTasks(
|
||||||
|
board: String?,
|
||||||
|
body: BulkTasksBody,
|
||||||
|
): NetworkResult<BulkTasksResponse>
|
||||||
|
|
||||||
|
suspend fun addComment(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
body: String,
|
||||||
|
): NetworkResult<Unit>
|
||||||
|
|
||||||
|
suspend fun reassignTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
profile: String?,
|
||||||
|
reclaimFirst: Boolean = true,
|
||||||
|
): NetworkResult<ReassignTaskResponse>
|
||||||
|
|
||||||
|
suspend fun reclaimTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
reason: String? = null,
|
||||||
|
): NetworkResult<ReclaimTaskResponse>
|
||||||
|
|
||||||
|
suspend fun getTaskLog(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
tail: Int = 16384,
|
||||||
|
): NetworkResult<WorkerLog>
|
||||||
|
|
||||||
|
suspend fun estimateTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<TaskEstimate>
|
||||||
|
|
||||||
|
suspend fun estimateNew(
|
||||||
|
title: String,
|
||||||
|
body: String?,
|
||||||
|
): NetworkResult<TaskEstimate>
|
||||||
|
|
||||||
|
suspend fun uploadAttachment(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
file: MultipartBody.Part,
|
||||||
|
): NetworkResult<AttachmentUploadResponse>
|
||||||
|
|
||||||
|
suspend fun downloadAttachment(
|
||||||
|
attachmentId: Long,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<ResponseBody>
|
||||||
|
|
||||||
|
suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse>
|
||||||
|
|
||||||
|
suspend fun updateProfileDescription(
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
): NetworkResult<Unit>
|
||||||
|
|
||||||
|
suspend fun autoDescribeProfile(name: String): NetworkResult<AutoDescribeResponse>
|
||||||
|
|
||||||
|
suspend fun getOrchestration(): NetworkResult<OrchestrationSettings>
|
||||||
|
|
||||||
|
suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult<OrchestrationSettings>
|
||||||
|
|
||||||
|
suspend fun getProjects(): NetworkResult<KanbanProjectsResponse>
|
||||||
|
|
||||||
|
suspend fun nudgeDispatcher(board: String?): NetworkResult<DispatchResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
class KanbanRepositoryImpl(
|
||||||
|
private val apiProvider: () -> KanbanApiService = { ApiClient.kanbanApi },
|
||||||
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
) : KanbanRepository {
|
||||||
|
override suspend fun getBoards(includeArchived: Boolean): NetworkResult<KanbanBoardsResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getBoards(includeArchived) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getBoard(
|
||||||
|
board: String,
|
||||||
|
includeArchived: Boolean,
|
||||||
|
tenant: String?,
|
||||||
|
): NetworkResult<KanbanBoardResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().createBoard(body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateBoard(
|
||||||
|
slug: String,
|
||||||
|
body: RenameBoardBody,
|
||||||
|
): NetworkResult<RenameBoardResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().updateBoard(slug, body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteBoard(
|
||||||
|
slug: String,
|
||||||
|
delete: Boolean,
|
||||||
|
): NetworkResult<DeleteBoardResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().deleteBoard(slug, delete) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<KanbanTaskDetailResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getTask(taskId = taskId, board = board) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createTask(
|
||||||
|
board: String?,
|
||||||
|
body: CreateTaskBody,
|
||||||
|
): NetworkResult<CreateTaskResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().createTask(board = board, body = body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
body: UpdateTaskBody,
|
||||||
|
): NetworkResult<UpdateTaskResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<Unit> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun bulkTasks(
|
||||||
|
board: String?,
|
||||||
|
body: BulkTasksBody,
|
||||||
|
): NetworkResult<BulkTasksResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().bulkTasks(board = board, body = body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addComment(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
body: String,
|
||||||
|
): NetworkResult<Unit> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall {
|
||||||
|
apiProvider().addComment(
|
||||||
|
taskId = taskId,
|
||||||
|
board = board,
|
||||||
|
body = mapOf("body" to body, "author" to "mobile"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reassignTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
profile: String?,
|
||||||
|
reclaimFirst: Boolean,
|
||||||
|
): NetworkResult<ReassignTaskResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall {
|
||||||
|
apiProvider().reassignTask(
|
||||||
|
taskId = taskId,
|
||||||
|
board = board,
|
||||||
|
body = ReassignTaskBody(profile = profile, reclaimFirst = reclaimFirst),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reclaimTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
reason: String?,
|
||||||
|
): NetworkResult<ReclaimTaskResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall {
|
||||||
|
apiProvider().reclaimTask(
|
||||||
|
taskId = taskId,
|
||||||
|
board = board,
|
||||||
|
body = ReclaimTaskBody(reason = reason),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTaskLog(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
tail: Int,
|
||||||
|
): NetworkResult<WorkerLog> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getTaskLog(taskId = taskId, tail = tail, board = board) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun estimateTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<TaskEstimate> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().estimateTask(taskId = taskId, board = board) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun estimateNew(
|
||||||
|
title: String,
|
||||||
|
body: String?,
|
||||||
|
): NetworkResult<TaskEstimate> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
val payload =
|
||||||
|
buildMap {
|
||||||
|
put("title", title)
|
||||||
|
if (!body.isNullOrBlank()) put("body", body)
|
||||||
|
}
|
||||||
|
safeApiCall { apiProvider().estimateNew(payload) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun uploadAttachment(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
file: MultipartBody.Part,
|
||||||
|
): NetworkResult<AttachmentUploadResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun downloadAttachment(
|
||||||
|
attachmentId: Long,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<ResponseBody> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getKanbanProfiles() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateProfileDescription(
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
): NetworkResult<Unit> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall {
|
||||||
|
apiProvider().updateProfileDescription(
|
||||||
|
name = name,
|
||||||
|
body = mapOf("description" to description),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun autoDescribeProfile(name: String): NetworkResult<AutoDescribeResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().autoDescribeProfile(name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getOrchestration(): NetworkResult<OrchestrationSettings> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getOrchestration() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult<OrchestrationSettings> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().updateOrchestration(body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getProjects(): NetworkResult<KanbanProjectsResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getProjects() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nudgeDispatcher(board: String?): NetworkResult<DispatchResult> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().nudgeDispatcher(board) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.m57.hermescontrol.data.repository
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||||
|
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
sealed interface KanbanSyncStatus {
|
||||||
|
data object Live : KanbanSyncStatus
|
||||||
|
|
||||||
|
data class Polling(
|
||||||
|
val lastSyncEpoch: Long = System.currentTimeMillis(),
|
||||||
|
) : KanbanSyncStatus
|
||||||
|
|
||||||
|
data object AuthFailed : KanbanSyncStatus
|
||||||
|
|
||||||
|
data object Disconnected : KanbanSyncStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
class KanbanSyncCoordinator(
|
||||||
|
private val repository: KanbanRepository,
|
||||||
|
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
|
||||||
|
private val reloadDebounceMs: Long = 250L,
|
||||||
|
private val pollIntervalMs: Long = 60_000L,
|
||||||
|
private val nudgeDebounceMs: Long = 400L,
|
||||||
|
) {
|
||||||
|
private var eventsClient: KanbanEventsClient? = null
|
||||||
|
private var activeBoard: String? = null
|
||||||
|
private var reloadJob: Job? = null
|
||||||
|
private var pollJob: Job? = null
|
||||||
|
private var nudgeJob: Job? = null
|
||||||
|
|
||||||
|
private val _syncStatus = MutableStateFlow<KanbanSyncStatus>(KanbanSyncStatus.Disconnected)
|
||||||
|
val syncStatus: StateFlow<KanbanSyncStatus> = _syncStatus.asStateFlow()
|
||||||
|
|
||||||
|
fun connect(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
board: String,
|
||||||
|
onReload: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (activeBoard == board && _syncStatus.value == KanbanSyncStatus.Live) return
|
||||||
|
activeBoard = board
|
||||||
|
|
||||||
|
val client = eventsClient ?: eventsClientProvider().also { eventsClient = it }
|
||||||
|
client.connect(
|
||||||
|
scope = scope,
|
||||||
|
board = board,
|
||||||
|
onEvents = {
|
||||||
|
reloadJob?.cancel()
|
||||||
|
reloadJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(reloadDebounceMs)
|
||||||
|
onReload()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStatus = { status ->
|
||||||
|
when (status) {
|
||||||
|
KanbanLiveStatus.CONNECTED -> {
|
||||||
|
_syncStatus.value = KanbanSyncStatus.Live
|
||||||
|
}
|
||||||
|
|
||||||
|
KanbanLiveStatus.DISCONNECTED -> {
|
||||||
|
_syncStatus.value = KanbanSyncStatus.Polling()
|
||||||
|
}
|
||||||
|
|
||||||
|
KanbanLiveStatus.AUTH_FAILED -> {
|
||||||
|
_syncStatus.value = KanbanSyncStatus.AuthFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startVisiblePolling(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
board: String,
|
||||||
|
onPoll: () -> Unit,
|
||||||
|
) {
|
||||||
|
pollJob?.cancel()
|
||||||
|
pollJob =
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(pollIntervalMs)
|
||||||
|
if (_syncStatus.value !is KanbanSyncStatus.Live) {
|
||||||
|
onPoll()
|
||||||
|
_syncStatus.value = KanbanSyncStatus.Polling(System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopVisiblePolling() {
|
||||||
|
pollJob?.cancel()
|
||||||
|
pollJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheduleNudge(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
board: String,
|
||||||
|
) {
|
||||||
|
nudgeJob?.cancel()
|
||||||
|
nudgeJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(nudgeDebounceMs)
|
||||||
|
repository.nudgeDispatcher(board)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
activeBoard = null
|
||||||
|
reloadJob?.cancel()
|
||||||
|
reloadJob = null
|
||||||
|
pollJob?.cancel()
|
||||||
|
pollJob = null
|
||||||
|
nudgeJob?.cancel()
|
||||||
|
nudgeJob = null
|
||||||
|
eventsClient?.disconnect()
|
||||||
|
_syncStatus.value = KanbanSyncStatus.Disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,19 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.SerializationException
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlinx.serialization.json.JsonDecoder
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
@@ -31,11 +39,37 @@ enum class KanbanLiveStatus {
|
|||||||
AUTH_FAILED,
|
AUTH_FAILED,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
|
||||||
|
object StringOrLongSerializer : KSerializer<String?> {
|
||||||
|
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("StringOrLong", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(
|
||||||
|
encoder: Encoder,
|
||||||
|
value: String?,
|
||||||
|
) {
|
||||||
|
if (value == null) {
|
||||||
|
encoder.encodeNull()
|
||||||
|
} else {
|
||||||
|
encoder.encodeString(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): String? {
|
||||||
|
val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString()
|
||||||
|
val element = jsonDecoder.decodeJsonElement()
|
||||||
|
if (element is JsonPrimitive) {
|
||||||
|
return element.content
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** One row of the backend's ``task_events`` table (``/api/plugins/kanban/events``). */
|
/** One row of the backend's ``task_events`` table (``/api/plugins/kanban/events``). */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KanbanEvent(
|
data class KanbanEvent(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
@SerialName("task_id") val taskId: String? = null,
|
@SerialName("task_id") val taskId: String? = null,
|
||||||
|
@Serializable(with = StringOrLongSerializer::class)
|
||||||
@SerialName("run_id") val runId: String? = null,
|
@SerialName("run_id") val runId: String? = null,
|
||||||
val kind: String,
|
val kind: String,
|
||||||
/** Partial update (e.g. ``{"status": "done"}``) — never applied directly. */
|
/** Partial update (e.g. ``{"status": "done"}``) — never applied directly. */
|
||||||
|
|||||||
@@ -17,13 +17,21 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -32,6 +40,7 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -46,6 +55,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.KanbanTaskDetailKey
|
||||||
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.KanbanTask
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
@@ -54,6 +65,9 @@ import com.m57.hermescontrol.ui.common.NavIcon
|
|||||||
import com.m57.hermescontrol.ui.common.SearchBar
|
import com.m57.hermescontrol.ui.common.SearchBar
|
||||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
|
import com.m57.hermescontrol.ui.kanban.components.KanbanCreateTaskDialog
|
||||||
|
import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet
|
||||||
|
import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard
|
||||||
|
|
||||||
private const val DEFAULT_COLUMN = "todo"
|
private const val DEFAULT_COLUMN = "todo"
|
||||||
|
|
||||||
@@ -67,14 +81,26 @@ fun KanbanScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
var selectedAssignee by remember { mutableStateOf<String?>(null) }
|
||||||
|
var selectedTenant by remember { mutableStateOf<String?>(null) }
|
||||||
|
var includeArchived by remember { mutableStateOf(false) }
|
||||||
|
var groupRunning by remember { mutableStateOf(false) }
|
||||||
|
var showFilterSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val filteredTasks =
|
val filteredTasks =
|
||||||
remember(query, state.tasks) {
|
remember(query, state.tasks, selectedAssignee, selectedTenant) {
|
||||||
state.tasks.filter { task ->
|
state.tasks.filter { task ->
|
||||||
|
val matchesQuery =
|
||||||
|
query.isBlank() ||
|
||||||
task.title.contains(query, ignoreCase = true) ||
|
task.title.contains(query, ignoreCase = true) ||
|
||||||
task.description?.contains(query, ignoreCase = true) == true ||
|
task.description?.contains(query, ignoreCase = true) == true ||
|
||||||
task.status.contains(query, ignoreCase = true) ||
|
task.status.contains(query, ignoreCase = true) ||
|
||||||
task.assignedTo?.contains(query, ignoreCase = true) == 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +113,17 @@ fun KanbanScreen(
|
|||||||
var confirmTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
|
var confirmTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
|
||||||
var summaryTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
|
var summaryTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
|
||||||
|
|
||||||
|
// Multi-select & Bulk operations state
|
||||||
|
var isMultiSelectMode by remember { mutableStateOf(false) }
|
||||||
|
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
|
||||||
|
var showBulkMoveDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Board management dialogs
|
||||||
|
var showBoardMenu by remember { mutableStateOf(false) }
|
||||||
|
var showCreateBoardDialog by remember { mutableStateOf(false) }
|
||||||
|
var showRenameBoardDialog by remember { mutableStateOf(false) }
|
||||||
|
var showDeleteBoardDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadBoards()
|
viewModel.loadBoards()
|
||||||
}
|
}
|
||||||
@@ -96,6 +133,60 @@ fun KanbanScreen(
|
|||||||
HermesScaffold(
|
HermesScaffold(
|
||||||
title = { Text(stringResource(R.string.kanban_board_title)) },
|
title = { Text(stringResource(R.string.kanban_board_title)) },
|
||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { showFilterSheet = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FilterList,
|
||||||
|
contentDescription = "Filter",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { showBoardMenu = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = "Board options",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showBoardMenu,
|
||||||
|
onDismissRequest = { showBoardMenu = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("New Board") },
|
||||||
|
onClick = {
|
||||||
|
showBoardMenu = false
|
||||||
|
showCreateBoardDialog = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (state.selectedBoard != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Rename Board") },
|
||||||
|
onClick = {
|
||||||
|
showBoardMenu = false
|
||||||
|
showRenameBoardDialog = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (state.boards.size > 1) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Delete Board") },
|
||||||
|
onClick = {
|
||||||
|
showBoardMenu = false
|
||||||
|
showDeleteBoardDialog = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(if (isMultiSelectMode) "Exit Multi-Select" else "Select Multiple") },
|
||||||
|
onClick = {
|
||||||
|
showBoardMenu = false
|
||||||
|
isMultiSelectMode = !isMultiSelectMode
|
||||||
|
if (!isMultiSelectMode) selectedTaskIds = emptySet()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
isRefreshing = state.isLoading,
|
isRefreshing = state.isLoading,
|
||||||
onRefresh = { viewModel.loadBoards() },
|
onRefresh = { viewModel.loadBoards() },
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
@@ -142,7 +233,7 @@ fun KanbanScreen(
|
|||||||
Tab(
|
Tab(
|
||||||
selected = board == state.selectedBoard,
|
selected = board == state.selectedBoard,
|
||||||
onClick = { viewModel.selectBoard(board) },
|
onClick = { viewModel.selectBoard(board) },
|
||||||
text = { Text(board.name) },
|
text = { Text(board.displayName) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,6 +258,14 @@ fun KanbanScreen(
|
|||||||
) {
|
) {
|
||||||
LiveStatusPill(isLive = state.isLive)
|
LiveStatusPill(isLive = state.isLive)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
IconButton(onClick = { showFilterSheet = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.FilterList,
|
||||||
|
contentDescription = "Filters",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
IconButton(onClick = { showAddTaskDialog = true }) {
|
IconButton(onClick = { showAddTaskDialog = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Add,
|
imageVector = Icons.Filled.Add,
|
||||||
@@ -228,9 +327,41 @@ fun KanbanScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(colTasks, key = { it.id }) { task ->
|
items(colTasks, key = { it.id }) { task ->
|
||||||
TaskCard(
|
KanbanTaskCard(
|
||||||
task = task,
|
task = task,
|
||||||
onTaskClick = { taskForActions = it },
|
isSelected =
|
||||||
|
isMultiSelectMode && task.id in selectedTaskIds,
|
||||||
|
onTaskClick = { clickedTask ->
|
||||||
|
if (isMultiSelectMode) {
|
||||||
|
selectedTaskIds =
|
||||||
|
if (clickedTask.id in selectedTaskIds) {
|
||||||
|
selectedTaskIds - clickedTask.id
|
||||||
|
} else {
|
||||||
|
selectedTaskIds + clickedTask.id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.selectedBoard?.let { board ->
|
||||||
|
NavigationController.navigateTo(
|
||||||
|
KanbanTaskDetailKey(
|
||||||
|
boardSlug = board.id,
|
||||||
|
taskId = clickedTask.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActionClick = { clickedTask ->
|
||||||
|
if (isMultiSelectMode) {
|
||||||
|
selectedTaskIds =
|
||||||
|
if (clickedTask.id in selectedTaskIds) {
|
||||||
|
selectedTaskIds - clickedTask.id
|
||||||
|
} else {
|
||||||
|
selectedTaskIds + clickedTask.id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
taskForActions = clickedTask
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,10 +374,16 @@ fun KanbanScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showAddTaskDialog) {
|
if (showAddTaskDialog) {
|
||||||
AddTaskDialog(
|
KanbanCreateTaskDialog(
|
||||||
|
columns = state.columns,
|
||||||
|
defaultColumn = state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN,
|
||||||
|
profiles = state.profiles,
|
||||||
|
existingTasks = state.tasks,
|
||||||
|
isCreating = state.isCreatingTask,
|
||||||
|
onEstimate = viewModel::estimateNewTask,
|
||||||
onDismiss = { showAddTaskDialog = false },
|
onDismiss = { showAddTaskDialog = false },
|
||||||
onConfirm = { title, desc ->
|
onConfirm = { body, targetStatus ->
|
||||||
viewModel.createTask(title, desc, state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN)
|
viewModel.createTask(body, targetStatus)
|
||||||
showAddTaskDialog = false
|
showAddTaskDialog = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -288,48 +425,281 @@ fun KanbanScreen(
|
|||||||
|
|
||||||
summaryTarget?.let { (task, action) ->
|
summaryTarget?.let { (task, action) ->
|
||||||
CompleteTaskDialog(
|
CompleteTaskDialog(
|
||||||
onConfirm = { summary -> viewModel.moveTask(task, action, summary) },
|
onConfirm = { summary ->
|
||||||
|
summaryTarget = null
|
||||||
|
viewModel.moveTask(task, action, summary)
|
||||||
|
},
|
||||||
onDismiss = { summaryTarget = null },
|
onDismiss = { summaryTarget = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
if (showFilterSheet) {
|
||||||
fun TaskCard(
|
val assignees =
|
||||||
task: KanbanTask,
|
remember(state.tasks) {
|
||||||
onTaskClick: (KanbanTask) -> Unit,
|
state.tasks
|
||||||
) {
|
.mapNotNull { it.assignee }
|
||||||
Card(onClick = { onTaskClick(task) }, modifier = Modifier.fillMaxWidth()) {
|
.distinct()
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
.sorted()
|
||||||
Text(text = task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
val tenants =
|
||||||
task.description?.let {
|
remember(state.tasks) {
|
||||||
Text(
|
state.tasks
|
||||||
text = it,
|
.mapNotNull { it.tenant }
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
.distinct()
|
||||||
maxLines = 2,
|
.sorted()
|
||||||
overflow = TextOverflow.Ellipsis,
|
}
|
||||||
|
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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
task.assignedTo?.let { assignee ->
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
if (isMultiSelectMode) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Card(
|
||||||
Icon(
|
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||||
imageVector = Icons.Filled.Person,
|
colors =
|
||||||
contentDescription = null,
|
CardDefaults.cardColors(
|
||||||
modifier = Modifier.size(14.dp),
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
),
|
||||||
)
|
modifier =
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = assignee,
|
text = "${selectedTaskIds.size} selected",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
selectedTaskIds =
|
||||||
|
if (selectedTaskIds.size == filteredTasks.size) {
|
||||||
|
emptySet()
|
||||||
|
} else {
|
||||||
|
filteredTasks.map { it.id }.toSet()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(if (selectedTaskIds.size == filteredTasks.size) "Clear" else "All")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { showBulkMoveDialog = true },
|
||||||
|
enabled = selectedTaskIds.isNotEmpty(),
|
||||||
|
) {
|
||||||
|
Text("Move")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.bulkArchive(selectedTaskIds.toList())
|
||||||
|
isMultiSelectMode = false
|
||||||
|
selectedTaskIds = emptySet()
|
||||||
|
},
|
||||||
|
enabled = selectedTaskIds.isNotEmpty(),
|
||||||
|
) {
|
||||||
|
Text("Archive")
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
isMultiSelectMode = false
|
||||||
|
selectedTaskIds = emptySet()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Close multi-select")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBulkMoveDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showBulkMoveDialog = false },
|
||||||
|
title = { Text("Move ${selectedTaskIds.size} tasks") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
state.columns.forEach { col ->
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.bulkMove(selectedTaskIds.toList(), col.name)
|
||||||
|
showBulkMoveDialog = false
|
||||||
|
isMultiSelectMode = false
|
||||||
|
selectedTaskIds = emptySet()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(col.name.uppercase(), modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showBulkMoveDialog = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCreateBoardDialog) {
|
||||||
|
var boardSlug by remember { mutableStateOf("") }
|
||||||
|
var boardName by remember { mutableStateOf("") }
|
||||||
|
var boardDesc by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showCreateBoardDialog = false },
|
||||||
|
title = { Text("Create New Board") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = boardSlug,
|
||||||
|
onValueChange = {
|
||||||
|
boardSlug =
|
||||||
|
it.lowercase().filter { c ->
|
||||||
|
c.isLetterOrDigit() || c == '-' || c == '_'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Board ID / Slug") },
|
||||||
|
placeholder = { Text("e.g. sprint-3") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = boardName,
|
||||||
|
onValueChange = { boardName = it },
|
||||||
|
label = { Text("Display Name") },
|
||||||
|
placeholder = { Text("e.g. Sprint 3") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = boardDesc,
|
||||||
|
onValueChange = { boardDesc = it },
|
||||||
|
label = { Text("Description") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (boardSlug.isNotBlank()) {
|
||||||
|
viewModel.createBoard(
|
||||||
|
slug = boardSlug.trim(),
|
||||||
|
name = boardName.trim().ifBlank { null },
|
||||||
|
description = boardDesc.trim().ifBlank { null },
|
||||||
|
)
|
||||||
|
showCreateBoardDialog = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = boardSlug.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text("Create")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showCreateBoardDialog = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showRenameBoardDialog && state.selectedBoard != null) {
|
||||||
|
var newName by remember { mutableStateOf(state.selectedBoard?.name ?: "") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showRenameBoardDialog = false },
|
||||||
|
title = { Text("Rename Board") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newName,
|
||||||
|
onValueChange = { newName = it },
|
||||||
|
label = { Text("Board Name") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (newName.isNotBlank()) {
|
||||||
|
state.selectedBoard?.let { board ->
|
||||||
|
viewModel.renameBoard(board.id, newName.trim())
|
||||||
|
}
|
||||||
|
showRenameBoardDialog = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = newName.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showRenameBoardDialog = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDeleteBoardDialog && state.selectedBoard != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteBoardDialog = false },
|
||||||
|
title = { Text("Delete Board") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Are you sure you want to delete board '${state.selectedBoard?.displayName}'? This action cannot be undone.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
state.selectedBoard?.let { board ->
|
||||||
|
viewModel.deleteBoard(board.id)
|
||||||
|
}
|
||||||
|
showDeleteBoardDialog = false
|
||||||
|
},
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("Delete")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteBoardDialog = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,51 +730,6 @@ private fun LiveStatusPill(isLive: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun AddTaskDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConfirm: (title: String, desc: String?) -> Unit,
|
|
||||||
) {
|
|
||||||
var title by remember { mutableStateOf("") }
|
|
||||||
var desc by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
androidx.compose.material3.AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(stringResource(R.string.kanban_add_new_task)) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = title,
|
|
||||||
onValueChange = { title = it },
|
|
||||||
label = { Text(stringResource(R.string.kanban_task_title)) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = desc,
|
|
||||||
onValueChange = { desc = it },
|
|
||||||
label = { Text(stringResource(R.string.kanban_task_desc)) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = { if (title.isNotBlank()) onConfirm(title, desc.ifBlank { null }) },
|
|
||||||
enabled = title.isNotBlank(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_add))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
Button(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun KanbanTaskAction.labelRes(): Int =
|
private fun KanbanTaskAction.labelRes(): Int =
|
||||||
when (this) {
|
when (this) {
|
||||||
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
|
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
|
||||||
|
|||||||
@@ -0,0 +1,750 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
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.foundation.text.selection.SelectionContainer
|
||||||
|
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.platform.LocalContext
|
||||||
|
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.chat.MarkdownText
|
||||||
|
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", "Files")
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
4 -> {
|
||||||
|
TaskFilesTab(
|
||||||
|
attachments = detail.attachments ?: emptyList(),
|
||||||
|
isUploading = state.isUploadingAttachment,
|
||||||
|
onUpload = { filename, mimeType, bytes ->
|
||||||
|
viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
SelectionContainer {
|
||||||
|
MarkdownText(
|
||||||
|
text = task.body?.ifBlank { "No description provided." } ?: "No description provided.",
|
||||||
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
SelectionContainer {
|
||||||
|
MarkdownText(
|
||||||
|
text = summary,
|
||||||
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
SelectionContainer {
|
||||||
|
MarkdownText(
|
||||||
|
text = comment.body,
|
||||||
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TaskFilesTab(
|
||||||
|
attachments: List<com.m57.hermescontrol.data.model.KanbanAttachment>,
|
||||||
|
isUploading: Boolean,
|
||||||
|
onUpload: (filename: String, mimeType: String, bytes: ByteArray) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val launcher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetContent(),
|
||||||
|
) { uri: Uri? ->
|
||||||
|
if (uri != null) {
|
||||||
|
var filename = "attachment"
|
||||||
|
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (nameIndex != -1 && cursor.moveToFirst()) {
|
||||||
|
filename = cursor.getString(nameIndex) ?: "attachment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||||
|
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
if (bytes != null) {
|
||||||
|
onUpload(filename, mimeType, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Attached Files (${attachments.size})",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { launcher.launch("*/*") },
|
||||||
|
enabled = !isUploading,
|
||||||
|
) {
|
||||||
|
if (isUploading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
Text("Attach File")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No files attached to this task.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(attachments, key = { it.id }) { att ->
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(12.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Description,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = att.filename,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
val sizeStr =
|
||||||
|
att.size?.let { s ->
|
||||||
|
if (s > 1024 * 1024) "${s / (1024 * 1024)} MB" else "${s / 1024} KB"
|
||||||
|
} ?: "Unknown size"
|
||||||
|
Text(
|
||||||
|
text = "$sizeStr • ${att.uploadedBy ?: "unknown"}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
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.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
|
||||||
|
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,
|
||||||
|
filename: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
) {
|
||||||
|
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
|
||||||
|
val part = MultipartBody.Part.createFormData("file", filename, requestBody)
|
||||||
|
uploadAttachment(board, taskId, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,25 @@ package com.m57.hermescontrol.ui.kanban
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
|
||||||
|
import com.m57.hermescontrol.data.local.KanbanPreferencesStore
|
||||||
|
import com.m57.hermescontrol.data.model.BulkTasksBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateBoardBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoard
|
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||||
import com.m57.hermescontrol.data.model.KanbanColumn
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanProfile
|
||||||
import com.m57.hermescontrol.data.model.KanbanTask
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.model.RenameBoardBody
|
||||||
|
import com.m57.hermescontrol.data.model.TaskEstimate
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||||
|
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
|
||||||
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||||
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
|
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -20,14 +28,16 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
data class KanbanUiState(
|
data class KanbanUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
|
val isCreatingTask: Boolean = false,
|
||||||
|
val operatingTaskIds: Set<String> = emptySet(),
|
||||||
val boards: List<KanbanBoard> = emptyList(),
|
val boards: List<KanbanBoard> = emptyList(),
|
||||||
val selectedBoard: KanbanBoard? = null,
|
val selectedBoard: KanbanBoard? = null,
|
||||||
val columns: List<KanbanColumn> = emptyList(),
|
val columns: List<KanbanColumn> = emptyList(),
|
||||||
val tasks: List<KanbanTask> = emptyList(),
|
val tasks: List<KanbanTask> = emptyList(),
|
||||||
|
val profiles: List<KanbanProfile> = emptyList(),
|
||||||
val isLive: Boolean = false,
|
val isLive: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val toastMessage: String? = null,
|
val toastMessage: String? = null,
|
||||||
@@ -113,7 +123,12 @@ fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
|
|||||||
}
|
}
|
||||||
|
|
||||||
class KanbanViewModel(
|
class KanbanViewModel(
|
||||||
|
private val repository: KanbanRepository = KanbanRepositoryImpl(),
|
||||||
|
private val preferences: KanbanPreferencesStore = InMemoryKanbanPreferencesStore(),
|
||||||
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
|
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
|
||||||
|
private val endpointProvider: () -> String = {
|
||||||
|
runCatching { AuthManager.endpointForBuild().baseUrl.toString() }.getOrDefault("default")
|
||||||
|
},
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(KanbanUiState())
|
private val _uiState = MutableStateFlow(KanbanUiState())
|
||||||
@@ -122,90 +137,290 @@ class KanbanViewModel(
|
|||||||
private var eventsClient: KanbanEventsClient? = null
|
private var eventsClient: KanbanEventsClient? = null
|
||||||
private var eventsBoard: String? = null
|
private var eventsBoard: String? = null
|
||||||
private var reloadJob: Job? = null
|
private var reloadJob: Job? = null
|
||||||
|
private var currentLoadGen: Int = 0
|
||||||
|
|
||||||
fun loadBoards() {
|
fun loadBoards() {
|
||||||
safeLaunchLoad(
|
val endpoint = endpointProvider()
|
||||||
apiCall = { safeApiCall { ApiClient.hermesApi.getKanbanBoards() } },
|
val savedSlug = preferences.getSelectedBoard(endpoint)
|
||||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
|
||||||
onSuccess = { data ->
|
|
||||||
val boards = data.boards.orEmpty()
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
loadProfiles()
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.getBoards()) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val boards = result.data.boards
|
||||||
_uiState.update { it.copy(isLoading = false, boards = boards) }
|
_uiState.update { it.copy(isLoading = false, boards = boards) }
|
||||||
val currentSlug = data.current
|
val targetBoard =
|
||||||
val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull()
|
boards.find { it.id == previouslySelectedId }
|
||||||
if (currentBoard != null) {
|
?: boards.find { it.id == result.data.current }
|
||||||
selectBoard(currentBoard)
|
?: boards.firstOrNull()
|
||||||
|
if (targetBoard != null) {
|
||||||
|
selectBoard(targetBoard)
|
||||||
|
} else {
|
||||||
|
_uiState.update { it.copy(selectedBoard = null, columns = emptyList(), tasks = emptyList()) }
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
onError = { errorMsg ->
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Failed to load Kanban boards: $errorMsg",
|
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectBoard(board: KanbanBoard) {
|
fun selectBoard(board: KanbanBoard) {
|
||||||
val current = _uiState.value.selectedBoard
|
val endpoint = endpointProvider()
|
||||||
if (current?.id == board.id) {
|
preferences.setSelectedBoard(endpoint, board.id)
|
||||||
// Already-active board: reload its data and (re)connect the events
|
|
||||||
// stream WITHOUT the /switch round-trip — refresh and createTask
|
val gen = ++currentLoadGen
|
||||||
// re-select the same board and must not POST a redundant switch.
|
|
||||||
viewModelScope.launch { loadBoardIntoState(board) }
|
|
||||||
connectEvents(board)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val switchResult =
|
loadBoardIntoState(board, gen)
|
||||||
withContext(Dispatchers.IO) {
|
connectEvents(board)
|
||||||
safeApiCall { ApiClient.hermesApi.switchKanbanBoard(board.id) }
|
|
||||||
}
|
}
|
||||||
if (switchResult is NetworkResult.Failure) {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
errorMessage = "Failed to switch Kanban board: ${switchResult.error.message}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadBoardIntoState(board)
|
fun loadProfiles() {
|
||||||
connectEvents(board)
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.getProfiles()) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(profiles = result.data.profiles) }
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
// Do not block UI if profiles fetch fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createTask(
|
||||||
|
body: CreateTaskBody,
|
||||||
|
targetStatus: String = "todo",
|
||||||
|
) {
|
||||||
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
|
if (_uiState.value.isCreatingTask) return
|
||||||
|
_uiState.update { it.copy(isCreatingTask = true) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result =
|
||||||
|
repository.createTask(
|
||||||
|
board = board.id,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val createdTask = result.data.task
|
||||||
|
if (createdTask != null &&
|
||||||
|
targetStatus.isNotBlank() &&
|
||||||
|
!createdTask.status.equals(targetStatus, ignoreCase = true)
|
||||||
|
) {
|
||||||
|
repository.updateTask(
|
||||||
|
taskId = createdTask.id,
|
||||||
|
board = board.id,
|
||||||
|
body = UpdateTaskBody(status = targetStatus),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val warning = result.data.warning
|
||||||
|
val msg =
|
||||||
|
if (!warning.isNullOrBlank()) {
|
||||||
|
"Task created: $warning"
|
||||||
|
} else {
|
||||||
|
"Task created successfully"
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isCreatingTask = false, toastMessage = msg) }
|
||||||
|
reloadBoardSilently()
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isCreatingTask = false,
|
||||||
|
toastMessage = "Failed to create task: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createTask(
|
fun createTask(
|
||||||
title: String,
|
title: String,
|
||||||
description: String?,
|
description: String?,
|
||||||
status: String,
|
status: String = "todo",
|
||||||
) {
|
) {
|
||||||
val board = _uiState.value.selectedBoard ?: return
|
createTask(
|
||||||
viewModelScope.launch {
|
body =
|
||||||
val result =
|
CreateTaskBody(
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
safeApiCall {
|
|
||||||
ApiClient.hermesApi.createKanbanTask(
|
|
||||||
board = board.id,
|
|
||||||
task =
|
|
||||||
com.m57.hermescontrol.data.model.CreateTaskBody(
|
|
||||||
title = title,
|
title = title,
|
||||||
body = description,
|
body = description,
|
||||||
),
|
),
|
||||||
|
targetStatus = status,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun estimateNewTask(
|
||||||
|
title: String,
|
||||||
|
body: String?,
|
||||||
|
): TaskEstimate? =
|
||||||
|
when (val result = repository.estimateNew(title, body)) {
|
||||||
|
is NetworkResult.Success -> result.data
|
||||||
|
is NetworkResult.Failure -> null
|
||||||
}
|
}
|
||||||
when (result) {
|
|
||||||
|
fun createBoard(
|
||||||
|
slug: String,
|
||||||
|
name: String? = null,
|
||||||
|
description: String? = null,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
val body =
|
||||||
|
CreateBoardBody(
|
||||||
|
slug = slug,
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
)
|
||||||
|
when (val res = repository.createBoard(body)) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Task created successfully") }
|
val boardName = res.data.board?.name ?: slug
|
||||||
selectBoard(board)
|
_uiState.update { it.copy(toastMessage = "Board created: $boardName") }
|
||||||
|
loadBoards()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to create task: ${result.error.message}") }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
toastMessage = "Failed to create board: ${res.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renameBoard(
|
||||||
|
slug: String,
|
||||||
|
newName: String,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val res = repository.updateBoard(slug, RenameBoardBody(name = newName))) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val boardName = res.data.board?.name ?: newName
|
||||||
|
_uiState.update { it.copy(toastMessage = "Board renamed to $boardName") }
|
||||||
|
loadBoards()
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
toastMessage = "Failed to rename board: ${res.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteBoard(slug: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val res = repository.deleteBoard(slug, delete = true)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(toastMessage = "Board deleted") }
|
||||||
|
loadBoards()
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
toastMessage = "Failed to delete board: ${res.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bulkMove(
|
||||||
|
taskIds: List<String>,
|
||||||
|
targetStatus: String,
|
||||||
|
) {
|
||||||
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, status = targetStatus))) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val count = res.data.results.count { it.ok }
|
||||||
|
_uiState.update { it.copy(toastMessage = "Moved $count tasks to $targetStatus") }
|
||||||
|
reloadBoardSilently()
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
toastMessage = "Bulk move failed: ${res.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bulkArchive(taskIds: List<String>) {
|
||||||
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, archive = true))) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val count = res.data.results.count { it.ok }
|
||||||
|
_uiState.update { it.copy(toastMessage = "Archived $count tasks") }
|
||||||
|
reloadBoardSilently()
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
toastMessage = "Bulk archive failed: ${res.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bulkAssign(
|
||||||
|
taskIds: List<String>,
|
||||||
|
assignee: String?,
|
||||||
|
) {
|
||||||
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, assignee = assignee))) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val count = res.data.results.count { it.ok }
|
||||||
|
_uiState.update { it.copy(toastMessage = "Assigned $count tasks") }
|
||||||
|
reloadBoardSilently()
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
toastMessage = "Bulk assign failed: ${res.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,35 +431,78 @@ class KanbanViewModel(
|
|||||||
action: KanbanTaskAction,
|
action: KanbanTaskAction,
|
||||||
summary: String? = null,
|
summary: String? = null,
|
||||||
) {
|
) {
|
||||||
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
|
if (task.id in _uiState.value.operatingTaskIds) return
|
||||||
val originalStatus = task.status
|
val originalStatus = task.status
|
||||||
// Optimistically update, desktop-style
|
|
||||||
|
// Optimistically update both tasks and columns, desktop-style
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
val updatedTasks =
|
||||||
tasks =
|
|
||||||
state.tasks.map {
|
state.tasks.map {
|
||||||
if (it.id == task.id) it.copy(status = action.targetStatus) else it
|
if (it.id == task.id) it.copy(status = action.targetStatus) else it
|
||||||
},
|
}
|
||||||
|
val updatedColumns =
|
||||||
|
state.columns.map { col ->
|
||||||
|
val filtered = col.tasks.filter { it.id != task.id }
|
||||||
|
val newTasks =
|
||||||
|
if (col.name.equals(action.targetStatus, ignoreCase = true)) {
|
||||||
|
val moved = updatedTasks.find { it.id == task.id }
|
||||||
|
if (moved != null) filtered + moved else filtered
|
||||||
|
} else {
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
col.copy(tasks = newTasks)
|
||||||
|
}
|
||||||
|
state.copy(
|
||||||
|
tasks = updatedTasks,
|
||||||
|
columns = updatedColumns,
|
||||||
|
operatingTaskIds = state.operatingTaskIds + task.id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val body =
|
val body =
|
||||||
buildMap<String, String?> {
|
UpdateTaskBody(
|
||||||
put("status", action.targetStatus)
|
status = action.targetStatus,
|
||||||
if (action.needsSummary && !summary.isNullOrBlank()) {
|
summary = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
|
||||||
put("result", summary)
|
result = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
|
||||||
put("summary", summary)
|
)
|
||||||
|
val result = repository.updateTask(task.id, board = board.id, body = body)
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val returnedTask = result.data.task
|
||||||
|
_uiState.update { state ->
|
||||||
|
val finalStatus = returnedTask?.status ?: action.targetStatus
|
||||||
|
val updatedTasks =
|
||||||
|
state.tasks.map {
|
||||||
|
if (it.id == task.id) (returnedTask ?: it.copy(status = finalStatus)) else it
|
||||||
|
}
|
||||||
|
val updatedColumns =
|
||||||
|
state.columns.map { col ->
|
||||||
|
val filtered = col.tasks.filter { it.id != task.id }
|
||||||
|
val newTasks =
|
||||||
|
if (col.name.equals(finalStatus, ignoreCase = true)) {
|
||||||
|
val moved = updatedTasks.find { it.id == task.id }
|
||||||
|
if (moved != null) filtered + moved else filtered
|
||||||
|
} else {
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
col.copy(tasks = newTasks)
|
||||||
|
}
|
||||||
|
state.copy(
|
||||||
|
tasks = updatedTasks,
|
||||||
|
columns = updatedColumns,
|
||||||
|
operatingTaskIds = state.operatingTaskIds - task.id,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val result =
|
|
||||||
withContext(Dispatchers.IO) {
|
is NetworkResult.Failure -> {
|
||||||
safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, body) }
|
|
||||||
}
|
|
||||||
if (result is NetworkResult.Failure) {
|
|
||||||
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
|
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun revertTaskMove(
|
private fun revertTaskMove(
|
||||||
taskId: String,
|
taskId: String,
|
||||||
@@ -252,11 +510,26 @@ class KanbanViewModel(
|
|||||||
errorMsg: String,
|
errorMsg: String,
|
||||||
) {
|
) {
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
val updatedTasks =
|
||||||
tasks =
|
|
||||||
state.tasks.map {
|
state.tasks.map {
|
||||||
if (it.id == taskId) it.copy(status = originalStatus) else it
|
if (it.id == taskId) it.copy(status = originalStatus) else it
|
||||||
},
|
}
|
||||||
|
val updatedColumns =
|
||||||
|
state.columns.map { col ->
|
||||||
|
val filtered = col.tasks.filter { it.id != taskId }
|
||||||
|
val newTasks =
|
||||||
|
if (col.name.equals(originalStatus, ignoreCase = true)) {
|
||||||
|
val moved = updatedTasks.find { it.id == taskId }
|
||||||
|
if (moved != null) filtered + moved else filtered
|
||||||
|
} else {
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
col.copy(tasks = newTasks)
|
||||||
|
}
|
||||||
|
state.copy(
|
||||||
|
tasks = updatedTasks,
|
||||||
|
columns = updatedColumns,
|
||||||
|
operatingTaskIds = state.operatingTaskIds - taskId,
|
||||||
toastMessage = errorMsg,
|
toastMessage = errorMsg,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -300,33 +573,41 @@ class KanbanViewModel(
|
|||||||
|
|
||||||
/** Re-fetch the current board without touching the loading spinner. */
|
/** Re-fetch the current board without touching the loading spinner. */
|
||||||
private fun reloadBoardSilently() {
|
private fun reloadBoardSilently() {
|
||||||
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
|
val gen = currentLoadGen
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result = repository.getBoard(board = board.id)
|
||||||
withContext(Dispatchers.IO) {
|
if (gen != currentLoadGen) return@launch
|
||||||
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
|
|
||||||
}
|
|
||||||
if (result is NetworkResult.Success) {
|
if (result is NetworkResult.Success) {
|
||||||
val body = result.data
|
val body = result.data
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
|
if (it.selectedBoard?.id != board.id) {
|
||||||
|
it
|
||||||
|
} else {
|
||||||
it.copy(
|
it.copy(
|
||||||
columns = body.columns,
|
columns = body.columns,
|
||||||
tasks = body.columns.flatMap { it.tasks },
|
tasks = body.columns.flatMap { col -> col.tasks },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadBoardIntoState(board: KanbanBoard) {
|
|
||||||
val result =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun loadBoardIntoState(
|
||||||
|
board: KanbanBoard,
|
||||||
|
gen: Int = currentLoadGen,
|
||||||
|
) {
|
||||||
|
val result = repository.getBoard(board = board.id)
|
||||||
|
if (gen != currentLoadGen) return
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val body = result.data
|
val body = result.data
|
||||||
val allTasks = body.columns.flatMap { it.tasks }
|
val allTasks = body.columns.flatMap { it.tasks }
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
|
if (it.selectedBoard?.id != board.id) {
|
||||||
|
it
|
||||||
|
} else {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
columns = body.columns,
|
columns = body.columns,
|
||||||
@@ -334,9 +615,13 @@ class KanbanViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
|
if (it.selectedBoard?.id != board.id) {
|
||||||
|
it
|
||||||
|
} else {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
|
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
|
||||||
@@ -345,6 +630,7 @@ class KanbanViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun clearToast() {
|
override fun clearToast() {
|
||||||
_uiState.update { it.copy(toastMessage = null) }
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
|
|||||||
+535
@@ -0,0 +1,535 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanProfile
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import com.m57.hermescontrol.data.model.TaskEstimate
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private const val PARKED_VALUE = "__parked__"
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun KanbanCreateTaskDialog(
|
||||||
|
columns: List<KanbanColumn>,
|
||||||
|
defaultColumn: String,
|
||||||
|
profiles: List<KanbanProfile>,
|
||||||
|
existingTasks: List<KanbanTask>,
|
||||||
|
isCreating: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onEstimate: (suspend (title: String, body: String?) -> TaskEstimate?)? = null,
|
||||||
|
) {
|
||||||
|
var title by remember { mutableStateOf("") }
|
||||||
|
var desc by remember { mutableStateOf("") }
|
||||||
|
var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) }
|
||||||
|
var selectedAssignee by remember { mutableStateOf<String?>(null) }
|
||||||
|
var priority by remember { mutableIntStateOf(0) }
|
||||||
|
var goalMode by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Estimation state
|
||||||
|
var estimate by remember { mutableStateOf<TaskEstimate?>(null) }
|
||||||
|
var isEstimating by remember { mutableStateOf(false) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Advanced options
|
||||||
|
var showAdvanced by remember { mutableStateOf(false) }
|
||||||
|
var parentTaskId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var modelOverride by remember { mutableStateOf("") }
|
||||||
|
var providerOverride by remember { mutableStateOf("") }
|
||||||
|
var reasoningEffort by remember { mutableStateOf<String?>(null) }
|
||||||
|
var workspaceKind by remember { mutableStateOf<String?>(null) }
|
||||||
|
var skillsText by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
var assigneeExpanded by remember { mutableStateOf(false) }
|
||||||
|
var columnExpanded by remember { mutableStateOf(false) }
|
||||||
|
var parentExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { if (!isCreating) onDismiss() },
|
||||||
|
modifier = modifier,
|
||||||
|
title = { Text(stringResource(R.string.kanban_add_new_task)) },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = title,
|
||||||
|
onValueChange = { title = it },
|
||||||
|
label = { Text(stringResource(R.string.kanban_task_title)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = desc,
|
||||||
|
onValueChange = { desc = it },
|
||||||
|
label = { Text(stringResource(R.string.kanban_task_desc)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 3,
|
||||||
|
maxLines = 5,
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Target Column selector
|
||||||
|
if (columns.isNotEmpty()) {
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = columnExpanded,
|
||||||
|
onExpandedChange = { if (!isCreating) columnExpanded = it },
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedColumn.uppercase(),
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(R.string.kanban_target_column)) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnExpanded) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.menuAnchor(
|
||||||
|
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||||
|
).fillMaxWidth(),
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = columnExpanded,
|
||||||
|
onDismissRequest = { columnExpanded = false },
|
||||||
|
) {
|
||||||
|
columns.forEach { col ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(col.name.uppercase()) },
|
||||||
|
onClick = {
|
||||||
|
selectedColumn = col.name
|
||||||
|
columnExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignee dropdown
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = assigneeExpanded,
|
||||||
|
onExpandedChange = { if (!isCreating) assigneeExpanded = it },
|
||||||
|
) {
|
||||||
|
val displayAssignee =
|
||||||
|
when (selectedAssignee) {
|
||||||
|
null -> stringResource(R.string.kanban_assignee_unassigned)
|
||||||
|
PARKED_VALUE -> stringResource(R.string.kanban_assignee_parked)
|
||||||
|
else -> selectedAssignee ?: ""
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = displayAssignee,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(R.string.kanban_assignee)) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = assigneeExpanded) },
|
||||||
|
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = assigneeExpanded,
|
||||||
|
onDismissRequest = { assigneeExpanded = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.kanban_assignee_unassigned)) },
|
||||||
|
onClick = {
|
||||||
|
selectedAssignee = null
|
||||||
|
assigneeExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
profiles.forEach { profile ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(profile.name)
|
||||||
|
if (profile.model != null) {
|
||||||
|
Text(
|
||||||
|
profile.model,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
selectedAssignee = profile.name
|
||||||
|
assigneeExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.kanban_assignee_parked)) },
|
||||||
|
onClick = {
|
||||||
|
selectedAssignee = PARKED_VALUE
|
||||||
|
assigneeExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Priority chips
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.kanban_priority),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
listOf(
|
||||||
|
0 to stringResource(R.string.kanban_priority_normal),
|
||||||
|
1 to stringResource(R.string.kanban_priority_med),
|
||||||
|
2 to stringResource(R.string.kanban_priority_high),
|
||||||
|
3 to stringResource(R.string.kanban_priority_urgent),
|
||||||
|
).forEach { (pVal, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = priority == pVal,
|
||||||
|
onClick = { priority = pVal },
|
||||||
|
label = { Text(label) },
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Autonomous Goal Mode switch
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = !isCreating) { goalMode = !goalMode }
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.kanban_goal_mode),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.kanban_goal_mode_desc),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Switch(
|
||||||
|
checked = goalMode,
|
||||||
|
onCheckedChange = { goalMode = it },
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Advanced Options Toggle
|
||||||
|
TextButton(
|
||||||
|
onClick = { showAdvanced = !showAdvanced },
|
||||||
|
modifier = Modifier.align(Alignment.Start),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (showAdvanced) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.kanban_advanced_options))
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = showAdvanced) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// Parent Task
|
||||||
|
if (existingTasks.isNotEmpty()) {
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = parentExpanded,
|
||||||
|
onExpandedChange = { if (!isCreating) parentExpanded = it },
|
||||||
|
) {
|
||||||
|
val displayParent =
|
||||||
|
existingTasks.find { it.id == parentTaskId }?.let {
|
||||||
|
"#${it.id.removePrefix("t_").take(6)} • ${it.title}"
|
||||||
|
} ?: stringResource(R.string.kanban_parent_none)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = displayParent,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(R.string.kanban_parent_task)) },
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(
|
||||||
|
expanded = parentExpanded,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.menuAnchor(
|
||||||
|
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||||
|
).fillMaxWidth(),
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = parentExpanded,
|
||||||
|
onDismissRequest = { parentExpanded = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.kanban_parent_none)) },
|
||||||
|
onClick = {
|
||||||
|
parentTaskId = null
|
||||||
|
parentExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
existingTasks.forEach { task ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("#${task.id.removePrefix("t_").take(6)} • ${task.title}") },
|
||||||
|
onClick = {
|
||||||
|
parentTaskId = task.id
|
||||||
|
parentExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model Override
|
||||||
|
OutlinedTextField(
|
||||||
|
value = modelOverride,
|
||||||
|
onValueChange = { modelOverride = it },
|
||||||
|
label = { Text(stringResource(R.string.kanban_model_override)) },
|
||||||
|
placeholder = { Text("e.g. claude-3-5-sonnet") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Provider Override
|
||||||
|
OutlinedTextField(
|
||||||
|
value = providerOverride,
|
||||||
|
onValueChange = { providerOverride = it },
|
||||||
|
label = { Text(stringResource(R.string.kanban_provider_override)) },
|
||||||
|
placeholder = { Text("e.g. anthropic") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Reasoning Effort
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.kanban_reasoning_effort),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
listOf(
|
||||||
|
null to "Inherit",
|
||||||
|
"low" to "Low",
|
||||||
|
"medium" to "Medium",
|
||||||
|
"high" to "High",
|
||||||
|
).forEach { (effort, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = reasoningEffort == effort,
|
||||||
|
onClick = { reasoningEffort = effort },
|
||||||
|
label = { Text(label) },
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Workspace Kind
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.kanban_workspace_kind),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
listOf(
|
||||||
|
null to "Scratch (Default)",
|
||||||
|
"worktree" to "Worktree",
|
||||||
|
"dir" to "Dir",
|
||||||
|
).forEach { (kind, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = workspaceKind == kind,
|
||||||
|
onClick = { workspaceKind = kind },
|
||||||
|
label = { Text(label) },
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
OutlinedTextField(
|
||||||
|
value = skillsText,
|
||||||
|
onValueChange = { skillsText = it },
|
||||||
|
label = { Text(stringResource(R.string.kanban_skills)) },
|
||||||
|
placeholder = { Text("e.g. git, coding, tests") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onEstimate != null) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (estimate != null && estimate?.ok == true) {
|
||||||
|
Text(
|
||||||
|
text = "~${estimate?.estTokens ?: 0} tokens · ${estimate?.complexity ?: "normal"}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (title.isNotBlank() && !isEstimating) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
isEstimating = true
|
||||||
|
estimate = onEstimate(title, desc.ifBlank { null })
|
||||||
|
isEstimating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = title.isNotBlank() && !isEstimating && !isCreating,
|
||||||
|
) {
|
||||||
|
if (isEstimating) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
Text(if (estimate != null) "Re-estimate" else "Estimate tokens")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (title.isNotBlank()) {
|
||||||
|
val skillsList =
|
||||||
|
skillsText
|
||||||
|
.split(",")
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
val body =
|
||||||
|
CreateTaskBody(
|
||||||
|
title = title.trim(),
|
||||||
|
body = desc.trim().ifBlank { null },
|
||||||
|
assignee = if (selectedAssignee == PARKED_VALUE) null else selectedAssignee,
|
||||||
|
priority = priority,
|
||||||
|
goalMode = goalMode,
|
||||||
|
parents = parentTaskId?.let { listOf(it) } ?: emptyList(),
|
||||||
|
modelOverride = modelOverride.trim().ifBlank { null },
|
||||||
|
providerOverride = providerOverride.trim().ifBlank { null },
|
||||||
|
reasoningEffort = reasoningEffort,
|
||||||
|
workspaceKind = workspaceKind,
|
||||||
|
skills = skillsList,
|
||||||
|
triage = selectedColumn.equals("triage", ignoreCase = true),
|
||||||
|
)
|
||||||
|
onConfirm(body, selectedColumn)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = title.isNotBlank() && !isCreating,
|
||||||
|
) {
|
||||||
|
if (isCreating) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.action_add))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
enabled = !isCreating,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,283 @@
|
|||||||
|
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.MoreVert
|
||||||
|
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.IconButton
|
||||||
|
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,
|
||||||
|
onActionClick: ((KanbanTask) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "#${task.id.removePrefix("t_").take(6)}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (onActionClick != null) {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
IconButton(
|
||||||
|
onClick = { onActionClick(task) },
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = "Task actions",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -342,6 +342,25 @@
|
|||||||
<string name="kanban_live">Live</string>
|
<string name="kanban_live">Live</string>
|
||||||
<string name="kanban_offline">Offline</string>
|
<string name="kanban_offline">Offline</string>
|
||||||
<string name="kanban_no_tasks">No tasks</string>
|
<string name="kanban_no_tasks">No tasks</string>
|
||||||
|
<string name="kanban_priority">Priority</string>
|
||||||
|
<string name="kanban_priority_normal">0 (Normal)</string>
|
||||||
|
<string name="kanban_priority_med">1 (Med)</string>
|
||||||
|
<string name="kanban_priority_high">2 (High)</string>
|
||||||
|
<string name="kanban_priority_urgent">3 (Urgent)</string>
|
||||||
|
<string name="kanban_assignee">Assignee</string>
|
||||||
|
<string name="kanban_assignee_unassigned">Default (Unassigned)</string>
|
||||||
|
<string name="kanban_assignee_parked">Parked (No worker)</string>
|
||||||
|
<string name="kanban_goal_mode">Autonomous Goal Mode</string>
|
||||||
|
<string name="kanban_goal_mode_desc">Judge validates task completion across multi-turn iterations</string>
|
||||||
|
<string name="kanban_advanced_options">Advanced Options</string>
|
||||||
|
<string name="kanban_parent_task">Parent Task</string>
|
||||||
|
<string name="kanban_parent_none">None</string>
|
||||||
|
<string name="kanban_model_override">Model Override</string>
|
||||||
|
<string name="kanban_provider_override">Provider Override</string>
|
||||||
|
<string name="kanban_reasoning_effort">Reasoning Effort</string>
|
||||||
|
<string name="kanban_workspace_kind">Workspace Kind</string>
|
||||||
|
<string name="kanban_skills">Skills</string>
|
||||||
|
<string name="kanban_target_column">Column</string>
|
||||||
<string name="action_add">Add</string>
|
<string name="action_add">Add</string>
|
||||||
<!-- Skills Screen -->
|
<!-- Skills Screen -->
|
||||||
<string name="skills_status_all">All Statuses</string>
|
<string name="skills_status_all">All Statuses</string>
|
||||||
|
|||||||
@@ -1205,10 +1205,9 @@ class E2eIntegrationTest {
|
|||||||
runTest {
|
runTest {
|
||||||
val board = KanbanBoard("board-1", "Backlog", null)
|
val board = KanbanBoard("board-1", "Backlog", null)
|
||||||
val task = KanbanTask("task-1", "Do laundry", null, "todo", null)
|
val task = KanbanTask("task-1", "Do laundry", null, "todo", null)
|
||||||
coEvery { mockApiService.getKanbanBoards() } returns
|
coEvery { mockApiService.getBoards(any()) } returns
|
||||||
Response.success(KanbanBoardsResponse(listOf(board), "board-1"))
|
Response.success(KanbanBoardsResponse(listOf(board), "board-1"))
|
||||||
coEvery { mockApiService.switchKanbanBoard("board-1") } returns Response.success(Unit)
|
coEvery { mockApiService.getBoard("board-1", any(), any()) } returns
|
||||||
coEvery { mockApiService.getKanbanBoard() } returns
|
|
||||||
Response.success(KanbanBoardResponse(listOf(KanbanColumn("todo", listOf(task))), null, null))
|
Response.success(KanbanBoardResponse(listOf(KanbanColumn("todo", listOf(task))), null, null))
|
||||||
|
|
||||||
val viewModel = KanbanViewModel(eventsClientProvider = { mockk<KanbanEventsClient>(relaxed = true) })
|
val viewModel = KanbanViewModel(eventsClientProvider = { mockk<KanbanEventsClient>(relaxed = true) })
|
||||||
@@ -1230,11 +1229,16 @@ class E2eIntegrationTest {
|
|||||||
// Desktop-style transition: todo -> ready is a direct write the
|
// Desktop-style transition: todo -> ready is a direct write the
|
||||||
// backend accepts; the VM PATCHes the target status.
|
// backend accepts; the VM PATCHes the target status.
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApiService.updateKanbanTask(
|
mockApiService.updateTask(
|
||||||
"task-1",
|
eq("task-1"),
|
||||||
mapOf("status" to "ready"),
|
eq("board-1"),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
} returns
|
||||||
|
Response.success(
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.UpdateTaskResponse(task = task.copy(status = "ready")),
|
||||||
)
|
)
|
||||||
} returns Response.success(Unit)
|
|
||||||
viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY)
|
viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -1243,9 +1247,13 @@ class E2eIntegrationTest {
|
|||||||
.status,
|
.status,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create path: the wrapped {"task": ...} response must not
|
// Create path: returns CreateTaskResponse
|
||||||
// break deserialization — the API layer reads it as Unit.
|
coEvery { mockApiService.createTask(any(), any()) } returns
|
||||||
coEvery { mockApiService.createKanbanTask(any(), any()) } returns Response.success(Unit)
|
Response.success(
|
||||||
|
com.m57.hermescontrol.data.model.CreateTaskResponse(
|
||||||
|
task = KanbanTask("task-2", "Fresh task", status = "todo"),
|
||||||
|
),
|
||||||
|
)
|
||||||
viewModel.createTask("Fresh task", null, "todo")
|
viewModel.createTask("Fresh task", null, "todo")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals("Task created successfully", viewModel.uiState.value.toastMessage)
|
assertEquals("Task created successfully", viewModel.uiState.value.toastMessage)
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class KanbanSerializationTest {
|
||||||
|
private val json = OkHttpProvider.json
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBoardResponseSerialization() {
|
||||||
|
val raw =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "todo",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "t_101",
|
||||||
|
"title": "Build mobile Kanban",
|
||||||
|
"body": "Full desktop parity",
|
||||||
|
"status": "todo",
|
||||||
|
"assignee": "researcher",
|
||||||
|
"priority": 1,
|
||||||
|
"tenant": "mobile",
|
||||||
|
"created_at": 1718000000,
|
||||||
|
"latest_summary": "Initial draft ready",
|
||||||
|
"comment_count": 3,
|
||||||
|
"link_counts": { "parents": 1, "children": 2 },
|
||||||
|
"progress": { "done": 1, "total": 2 },
|
||||||
|
"warnings": { "count": 1, "highest_severity": "warning" },
|
||||||
|
"started_at": 1718000100,
|
||||||
|
"worker_pid": 12345,
|
||||||
|
"last_heartbeat_at": 1718000200
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assignees": ["researcher", "reviewer"],
|
||||||
|
"tenants": ["mobile"],
|
||||||
|
"latest_event_id": 42,
|
||||||
|
"now": 1718000500
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val decoded = json.decodeFromString<KanbanBoardResponse>(raw)
|
||||||
|
assertEquals(1, decoded.columns.size)
|
||||||
|
assertEquals("todo", decoded.columns[0].name)
|
||||||
|
val task = decoded.columns[0].tasks[0]
|
||||||
|
assertEquals("t_101", task.id)
|
||||||
|
assertEquals("Build mobile Kanban", task.title)
|
||||||
|
assertEquals("Full desktop parity", task.body)
|
||||||
|
assertEquals("Full desktop parity", task.description)
|
||||||
|
assertEquals("researcher", task.assignee)
|
||||||
|
assertEquals("researcher", task.assignedTo)
|
||||||
|
assertEquals(1, task.priority)
|
||||||
|
assertEquals(3, task.commentCount)
|
||||||
|
assertEquals(1, task.linkCounts?.parents)
|
||||||
|
assertEquals(2, task.linkCounts?.children)
|
||||||
|
assertEquals(1, task.progress?.done)
|
||||||
|
assertEquals(2, task.progress?.total)
|
||||||
|
assertEquals(1, task.warnings?.count)
|
||||||
|
assertEquals("warning", task.warnings?.highestSeverity)
|
||||||
|
assertEquals(12345, task.workerPid)
|
||||||
|
assertEquals(42L, decoded.latestEventId)
|
||||||
|
assertEquals(1718000500L, decoded.now)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTaskDetailResponseSerialization() {
|
||||||
|
val raw =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"task": {
|
||||||
|
"id": "t_202",
|
||||||
|
"title": "Fix memory leak",
|
||||||
|
"body": "Detailed description",
|
||||||
|
"status": "running",
|
||||||
|
"assignee": "debugger",
|
||||||
|
"result": "Resolved leak",
|
||||||
|
"created_by": "m57",
|
||||||
|
"model_override": "gpt-4o",
|
||||||
|
"provider_override": "openai",
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"completed_at": 1718000900,
|
||||||
|
"last_failure_error": null,
|
||||||
|
"workspace_kind": "worktree",
|
||||||
|
"workspace_path": "/tmp/wt-1",
|
||||||
|
"branch_name": "feat/leak-fix",
|
||||||
|
"consecutive_failures": 0,
|
||||||
|
"diagnostics": [
|
||||||
|
{
|
||||||
|
"kind": "stale_heartbeat",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": "Worker heartbeat missing",
|
||||||
|
"detail": "Last ping was 3 minutes ago",
|
||||||
|
"count": 1,
|
||||||
|
"last_seen_at": 1718000850,
|
||||||
|
"actions": [
|
||||||
|
{ "kind": "reclaim", "label": "Reclaim task", "suggested": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"author": "mobile",
|
||||||
|
"body": "Steering comment",
|
||||||
|
"created_at": 1718000800
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"kind": "status",
|
||||||
|
"payload": { "status": "running" },
|
||||||
|
"created_at": 1718000700
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"task_id": "t_202",
|
||||||
|
"filename": "leak.png",
|
||||||
|
"content_type": "image/png",
|
||||||
|
"size": 1024,
|
||||||
|
"uploaded_by": "mobile",
|
||||||
|
"stored_path": "/files/leak.png",
|
||||||
|
"created_at": 1718000750
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": {
|
||||||
|
"parents": ["t_100"],
|
||||||
|
"children": ["t_300"]
|
||||||
|
},
|
||||||
|
"child_results": [
|
||||||
|
{
|
||||||
|
"id": "t_300",
|
||||||
|
"title": "Child task",
|
||||||
|
"status": "done",
|
||||||
|
"latest_summary": "Finished child",
|
||||||
|
"result": "OK"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"profile": "debugger",
|
||||||
|
"status": "done",
|
||||||
|
"outcome": "completed",
|
||||||
|
"summary": "Completed attempt 1",
|
||||||
|
"worker_pid": 9876,
|
||||||
|
"started_at": 1718000500,
|
||||||
|
"ended_at": 1718000900
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val decoded = json.decodeFromString<KanbanTaskDetailResponse>(raw)
|
||||||
|
assertEquals("t_202", decoded.task.id)
|
||||||
|
assertEquals("gpt-4o", decoded.task.modelOverride)
|
||||||
|
assertEquals("high", decoded.task.reasoningEffort)
|
||||||
|
assertEquals(1, decoded.task.diagnostics.size)
|
||||||
|
assertEquals("stale_heartbeat", decoded.task.diagnostics[0].kind)
|
||||||
|
assertTrue(
|
||||||
|
decoded.task.diagnostics[0]
|
||||||
|
.actions[0]
|
||||||
|
.suggested,
|
||||||
|
)
|
||||||
|
assertEquals(1, decoded.comments.size)
|
||||||
|
assertEquals("Steering comment", decoded.comments[0].body)
|
||||||
|
assertEquals(1, decoded.events.size)
|
||||||
|
assertEquals("status", decoded.events[0].kind)
|
||||||
|
assertEquals(1, decoded.attachments?.size)
|
||||||
|
assertEquals("leak.png", decoded.attachments?.get(0)?.filename)
|
||||||
|
assertEquals(listOf("t_100"), decoded.links.parents)
|
||||||
|
assertEquals(listOf("t_300"), decoded.links.children)
|
||||||
|
assertEquals(1, decoded.childResults.size)
|
||||||
|
assertEquals(1, decoded.runs.size)
|
||||||
|
assertEquals(7L, decoded.runs[0].id)
|
||||||
|
assertEquals("completed", decoded.runs[0].outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateAndUpdatePayloadSerialization() {
|
||||||
|
val createBody =
|
||||||
|
CreateTaskBody(
|
||||||
|
title = "New Task",
|
||||||
|
body = "Task description",
|
||||||
|
assignee = "default",
|
||||||
|
priority = 2,
|
||||||
|
parents = listOf("t_001"),
|
||||||
|
triage = true,
|
||||||
|
modelOverride = "claude-3-5-sonnet",
|
||||||
|
reasoningEffort = "none",
|
||||||
|
)
|
||||||
|
val createJson = json.encodeToString(createBody)
|
||||||
|
assertTrue(createJson.contains("\"title\":\"New Task\""))
|
||||||
|
assertTrue(createJson.contains("\"triage\":true"))
|
||||||
|
assertTrue(createJson.contains("\"parents\":[\"t_001\"]"))
|
||||||
|
assertTrue(createJson.contains("\"reasoning_effort\":\"none\""))
|
||||||
|
|
||||||
|
val updateBody =
|
||||||
|
UpdateTaskBody(
|
||||||
|
status = "blocked",
|
||||||
|
blockReason = "Waiting for credentials",
|
||||||
|
clearModelOverride = true,
|
||||||
|
clearReasoningEffort = true,
|
||||||
|
)
|
||||||
|
val updateJson = json.encodeToString(updateBody)
|
||||||
|
assertTrue(updateJson.contains("\"status\":\"blocked\""))
|
||||||
|
assertTrue(updateJson.contains("\"block_reason\":\"Waiting for credentials\""))
|
||||||
|
assertTrue(updateJson.contains("\"clear_model_override\":true"))
|
||||||
|
assertTrue(updateJson.contains("\"clear_reasoning_effort\":true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBulkAndSettingsSerialization() {
|
||||||
|
val bulk =
|
||||||
|
BulkTasksBody(
|
||||||
|
ids = listOf("t_1", "t_2"),
|
||||||
|
status = "ready",
|
||||||
|
reclaimFirst = true,
|
||||||
|
)
|
||||||
|
val bulkJson = json.encodeToString(bulk)
|
||||||
|
assertTrue(bulkJson.contains("\"ids\":[\"t_1\",\"t_2\"]"))
|
||||||
|
assertTrue(bulkJson.contains("\"reclaim_first\":true"))
|
||||||
|
|
||||||
|
val orchestrationRaw =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"orchestrator_profile": "lead",
|
||||||
|
"default_assignee": "coder",
|
||||||
|
"auto_decompose": true,
|
||||||
|
"auto_promote_children": false,
|
||||||
|
"resolved_orchestrator_profile": "lead",
|
||||||
|
"resolved_default_assignee": "coder",
|
||||||
|
"active_profile": "default"
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
val orchestration = json.decodeFromString<OrchestrationSettings>(orchestrationRaw)
|
||||||
|
assertEquals("lead", orchestration.orchestratorProfile)
|
||||||
|
assertEquals("coder", orchestration.defaultAssignee)
|
||||||
|
assertTrue(orchestration.autoDecompose)
|
||||||
|
assertFalse(orchestration.autoPromoteChildren)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testWorkerLogAndEstimateSerialization() {
|
||||||
|
val logRaw =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"task_id": "t_999",
|
||||||
|
"path": "/logs/worker.log",
|
||||||
|
"exists": true,
|
||||||
|
"size_bytes": 4096,
|
||||||
|
"content": "Running tests...\nAll passed!",
|
||||||
|
"truncated": false
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
val log = json.decodeFromString<WorkerLog>(logRaw)
|
||||||
|
assertEquals("t_999", log.taskId)
|
||||||
|
assertTrue(log.exists)
|
||||||
|
assertEquals(4096L, log.sizeBytes)
|
||||||
|
assertEquals("Running tests...\nAll passed!", log.content)
|
||||||
|
assertFalse(log.truncated)
|
||||||
|
|
||||||
|
val estRaw =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"est_tokens": 15000,
|
||||||
|
"complexity": "M",
|
||||||
|
"rationale": "Multi-file changes needed",
|
||||||
|
"model": "auxiliary"
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
val estimate = json.decodeFromString<TaskEstimate>(estRaw)
|
||||||
|
assertTrue(estimate.ok)
|
||||||
|
assertEquals(15000, estimate.estTokens)
|
||||||
|
assertEquals("M", estimate.complexity)
|
||||||
|
assertEquals("Multi-file changes needed", estimate.rationale)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package com.m57.hermescontrol.data.remote
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.BulkTasksBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateBoardBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
|
||||||
|
import com.m57.hermescontrol.data.model.ReassignTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.ReclaimTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.RenameBoardBody
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
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
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
|
||||||
|
class KanbanApiServiceTest {
|
||||||
|
private lateinit var mockServer: MockWebServer
|
||||||
|
private lateinit var api: KanbanApiService
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
mockServer = MockWebServer()
|
||||||
|
mockServer.start()
|
||||||
|
|
||||||
|
api =
|
||||||
|
Retrofit
|
||||||
|
.Builder()
|
||||||
|
.baseUrl(mockServer.url("/"))
|
||||||
|
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
.create(KanbanApiService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
mockServer.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetBoards() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"boards": [
|
||||||
|
{ "slug": "default", "name": "Default Board", "total": 5, "is_current": true }
|
||||||
|
],
|
||||||
|
"current": "default"
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.getBoards(includeArchived = true)
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
val body = response.body()
|
||||||
|
assertNotNull(body)
|
||||||
|
assertEquals(1, body?.boards?.size)
|
||||||
|
assertEquals("default", body?.boards?.get(0)?.id)
|
||||||
|
assertEquals("Default Board", body?.boards?.get(0)?.name)
|
||||||
|
assertEquals("default", body?.current)
|
||||||
|
|
||||||
|
val request = mockServer.takeRequest()
|
||||||
|
assertEquals("GET", request.method)
|
||||||
|
assertEquals("/api/plugins/kanban/boards?include_archived=true", request.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetBoardScoped() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
{ "name": "ready", "tasks": [] }
|
||||||
|
],
|
||||||
|
"assignees": ["lead"],
|
||||||
|
"tenants": [],
|
||||||
|
"latest_event_id": 99,
|
||||||
|
"now": 1718000000
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.getBoard(board = "dev", includeArchived = false, tenant = "team-a")
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
val body = response.body()
|
||||||
|
assertNotNull(body)
|
||||||
|
assertEquals(1, body?.columns?.size)
|
||||||
|
assertEquals("ready", body?.columns?.get(0)?.name)
|
||||||
|
|
||||||
|
val request = mockServer.takeRequest()
|
||||||
|
assertEquals("GET", request.method)
|
||||||
|
assertTrue(request.path?.contains("board=dev") == true)
|
||||||
|
assertTrue(request.path?.contains("tenant=team-a") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateAndPatchTask() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"task": {
|
||||||
|
"id": "t_new",
|
||||||
|
"title": "Implement feature",
|
||||||
|
"status": "triage"
|
||||||
|
},
|
||||||
|
"warning": "Dispatcher not running"
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val createRes =
|
||||||
|
api.createTask(
|
||||||
|
board = "dev",
|
||||||
|
body =
|
||||||
|
CreateTaskBody(
|
||||||
|
title = "Implement feature",
|
||||||
|
triage = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(createRes.isSuccessful)
|
||||||
|
assertEquals("t_new", createRes.body()?.task?.id)
|
||||||
|
assertEquals("Dispatcher not running", createRes.body()?.warning)
|
||||||
|
|
||||||
|
val createReq = mockServer.takeRequest()
|
||||||
|
assertEquals("POST", createReq.method)
|
||||||
|
assertEquals("/api/plugins/kanban/tasks?board=dev", createReq.path)
|
||||||
|
assertTrue(createReq.body.readUtf8().contains("\"triage\":true"))
|
||||||
|
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"task": {
|
||||||
|
"id": "t_new",
|
||||||
|
"title": "Implement feature",
|
||||||
|
"status": "ready"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val patchRes =
|
||||||
|
api.updateTask(
|
||||||
|
taskId = "t_new",
|
||||||
|
board = "dev",
|
||||||
|
body =
|
||||||
|
UpdateTaskBody(
|
||||||
|
status = "ready",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(patchRes.isSuccessful)
|
||||||
|
assertEquals("ready", patchRes.body()?.task?.status)
|
||||||
|
|
||||||
|
val patchReq = mockServer.takeRequest()
|
||||||
|
assertEquals("PATCH", patchReq.method)
|
||||||
|
assertEquals("/api/plugins/kanban/tasks/t_new?board=dev", patchReq.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBulkTasks() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{ "id": "t_1", "ok": true },
|
||||||
|
{ "id": "t_2", "ok": false, "error": "transition refused" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val res =
|
||||||
|
api.bulkTasks(
|
||||||
|
board = "dev",
|
||||||
|
body =
|
||||||
|
BulkTasksBody(
|
||||||
|
ids = listOf("t_1", "t_2"),
|
||||||
|
status = "done",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(res.isSuccessful)
|
||||||
|
val results = res.body()?.results
|
||||||
|
assertEquals(2, results?.size)
|
||||||
|
assertTrue(results?.get(0)?.ok == true)
|
||||||
|
assertEquals("transition refused", results?.get(1)?.error)
|
||||||
|
|
||||||
|
val req = mockServer.takeRequest()
|
||||||
|
assertEquals("POST", req.method)
|
||||||
|
assertEquals("/api/plugins/kanban/tasks/bulk?board=dev", req.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testReassignAndReclaim() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody("""{ "ok": true, "task_id": "t_1", "assignee": "reviewer" }"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
val reassignRes =
|
||||||
|
api.reassignTask(
|
||||||
|
taskId = "t_1",
|
||||||
|
board = "dev",
|
||||||
|
body = ReassignTaskBody(profile = "reviewer", reclaimFirst = true),
|
||||||
|
)
|
||||||
|
assertTrue(reassignRes.isSuccessful)
|
||||||
|
assertEquals("reviewer", reassignRes.body()?.assignee)
|
||||||
|
|
||||||
|
val reassignReq = mockServer.takeRequest()
|
||||||
|
assertEquals("POST", reassignReq.method)
|
||||||
|
assertEquals("/api/plugins/kanban/tasks/t_1/reassign?board=dev", reassignReq.path)
|
||||||
|
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody("""{ "ok": true, "task_id": "t_1" }"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
val reclaimRes =
|
||||||
|
api.reclaimTask(
|
||||||
|
taskId = "t_1",
|
||||||
|
board = "dev",
|
||||||
|
body = ReclaimTaskBody(reason = "Worker unresponsive"),
|
||||||
|
)
|
||||||
|
assertTrue(reclaimRes.isSuccessful)
|
||||||
|
|
||||||
|
val reclaimReq = mockServer.takeRequest()
|
||||||
|
assertEquals("POST", reclaimReq.method)
|
||||||
|
assertEquals("/api/plugins/kanban/tasks/t_1/reclaim?board=dev", reclaimReq.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testOrchestrationAndDispatchNudge() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"orchestrator_profile": "boss",
|
||||||
|
"default_assignee": "worker",
|
||||||
|
"auto_decompose": true,
|
||||||
|
"auto_promote_children": true,
|
||||||
|
"resolved_orchestrator_profile": "boss",
|
||||||
|
"resolved_default_assignee": "worker",
|
||||||
|
"active_profile": "default"
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val updateRes =
|
||||||
|
api.updateOrchestration(
|
||||||
|
OrchestrationSettingsUpdate(
|
||||||
|
orchestratorProfile = "boss",
|
||||||
|
defaultAssignee = "worker",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(updateRes.isSuccessful)
|
||||||
|
assertEquals("boss", updateRes.body()?.orchestratorProfile)
|
||||||
|
|
||||||
|
val putReq = mockServer.takeRequest()
|
||||||
|
assertEquals("PUT", putReq.method)
|
||||||
|
assertEquals("/api/plugins/kanban/orchestration", putReq.path)
|
||||||
|
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody("""{ "spawned": [] }"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
val nudgeRes = api.nudgeDispatcher(board = "dev")
|
||||||
|
assertTrue(nudgeRes.isSuccessful)
|
||||||
|
|
||||||
|
val nudgeReq = mockServer.takeRequest()
|
||||||
|
assertEquals("POST", nudgeReq.method)
|
||||||
|
assertEquals("/api/plugins/kanban/dispatch?board=dev", nudgeReq.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.m57.hermescontrol.data.repository
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.remote.KanbanApiService
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class KanbanRepositoryTest {
|
||||||
|
private val testDispatcher = UnconfinedTestDispatcher()
|
||||||
|
private val mockApi = mockk<KanbanApiService>(relaxed = true)
|
||||||
|
private lateinit var repository: KanbanRepository
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
repository = KanbanRepositoryImpl(apiProvider = { mockApi }, ioDispatcher = testDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetBoardsPassesIncludeArchived() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockApi.getBoards(true) } returns Response.success(KanbanBoardsResponse())
|
||||||
|
val result = repository.getBoards(includeArchived = true)
|
||||||
|
assertTrue(result is NetworkResult.Success)
|
||||||
|
coVerify { mockApi.getBoards(true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetBoardScopedPassesBoardAndTenant() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery {
|
||||||
|
mockApi.getBoard(
|
||||||
|
board = "custom",
|
||||||
|
includeArchived = false,
|
||||||
|
tenant = "team-a",
|
||||||
|
)
|
||||||
|
} returns Response.success(KanbanBoardResponse())
|
||||||
|
|
||||||
|
val result = repository.getBoard(board = "custom", includeArchived = false, tenant = "team-a")
|
||||||
|
assertTrue(result is NetworkResult.Success)
|
||||||
|
coVerify { mockApi.getBoard(board = "custom", includeArchived = false, tenant = "team-a") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateTaskScopesToBoard() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val body = CreateTaskBody(title = "New Task")
|
||||||
|
coEvery { mockApi.createTask(board = "ops", body = body) } returns
|
||||||
|
Response.success(
|
||||||
|
CreateTaskResponse(task = KanbanTask(id = "t_1", title = "New Task", status = "triage")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = repository.createTask(board = "ops", body = body)
|
||||||
|
assertTrue(result is NetworkResult.Success)
|
||||||
|
assertEquals("t_1", (result as NetworkResult.Success).data.task?.id)
|
||||||
|
coVerify { mockApi.createTask(board = "ops", body = body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testUpdateTaskScopesToBoard() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val body = UpdateTaskBody(status = "ready")
|
||||||
|
coEvery { mockApi.updateTask(taskId = "t_1", board = "ops", body = body) } returns
|
||||||
|
Response.success(
|
||||||
|
UpdateTaskResponse(task = KanbanTask(id = "t_1", title = "New Task", status = "ready")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = repository.updateTask(taskId = "t_1", board = "ops", body = body)
|
||||||
|
assertTrue(result is NetworkResult.Success)
|
||||||
|
assertEquals("ready", (result as NetworkResult.Success).data.task?.status)
|
||||||
|
coVerify { mockApi.updateTask(taskId = "t_1", board = "ops", body = body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDeleteTaskScopesToBoard() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockApi.deleteTask(taskId = "t_1", board = "ops") } returns Response.success(Unit)
|
||||||
|
|
||||||
|
val result = repository.deleteTask(taskId = "t_1", board = "ops")
|
||||||
|
assertTrue(result is NetworkResult.Success)
|
||||||
|
coVerify { mockApi.deleteTask(taskId = "t_1", board = "ops") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHttpErrorWrapsFailure() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockApi.getBoard("missing", false, null) } returns
|
||||||
|
Response.error(404, "Not Found".toResponseBody())
|
||||||
|
|
||||||
|
val result = repository.getBoard("missing")
|
||||||
|
assertTrue(result is NetworkResult.Failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.m57.hermescontrol.data.repository
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.ws.KanbanEvent
|
||||||
|
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||||
|
import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope
|
||||||
|
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class KanbanSyncCoordinatorTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val testScope = TestScope(testDispatcher)
|
||||||
|
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
||||||
|
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
||||||
|
|
||||||
|
private lateinit var coordinator: KanbanSyncCoordinator
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
coordinator =
|
||||||
|
KanbanSyncCoordinator(
|
||||||
|
repository = mockRepository,
|
||||||
|
eventsClientProvider = { mockEventsClient },
|
||||||
|
reloadDebounceMs = 250L,
|
||||||
|
pollIntervalMs = 60_000L,
|
||||||
|
nudgeDebounceMs = 400L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLiveStatusTransitions() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val statusSlot = slot<(KanbanLiveStatus) -> Unit>()
|
||||||
|
verify(exactly = 0) { mockEventsClient.connect(any(), any(), any(), any(), any()) }
|
||||||
|
|
||||||
|
coordinator.connect(testScope, "dev", onReload = {})
|
||||||
|
verify { mockEventsClient.connect(any(), eq("dev"), any(), any(), capture(statusSlot)) }
|
||||||
|
|
||||||
|
statusSlot.captured(KanbanLiveStatus.CONNECTED)
|
||||||
|
assertEquals(KanbanSyncStatus.Live, coordinator.syncStatus.value)
|
||||||
|
|
||||||
|
statusSlot.captured(KanbanLiveStatus.DISCONNECTED)
|
||||||
|
assertTrue(coordinator.syncStatus.value is KanbanSyncStatus.Polling)
|
||||||
|
|
||||||
|
statusSlot.captured(KanbanLiveStatus.AUTH_FAILED)
|
||||||
|
assertEquals(KanbanSyncStatus.AuthFailed, coordinator.syncStatus.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDebouncedEventReload() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val eventsSlot = slot<(KanbanEventsEnvelope) -> Unit>()
|
||||||
|
var reloadCount = 0
|
||||||
|
|
||||||
|
coordinator.connect(testScope, "dev", onReload = { reloadCount++ })
|
||||||
|
verify { mockEventsClient.connect(any(), eq("dev"), any(), capture(eventsSlot), any()) }
|
||||||
|
|
||||||
|
eventsSlot.captured(KanbanEventsEnvelope(events = listOf(KanbanEvent(id = 1, kind = "status"))))
|
||||||
|
|
||||||
|
testDispatcher.scheduler.advanceTimeBy(100)
|
||||||
|
assertEquals(0, reloadCount)
|
||||||
|
|
||||||
|
testDispatcher.scheduler.advanceTimeBy(151)
|
||||||
|
assertEquals(1, reloadCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDebouncedNudge() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coordinator.scheduleNudge(testScope, "dev")
|
||||||
|
testDispatcher.scheduler.advanceTimeBy(200)
|
||||||
|
|
||||||
|
// Still within debounce window
|
||||||
|
coVerify(exactly = 0) { mockRepository.nudgeDispatcher(any()) }
|
||||||
|
|
||||||
|
testDispatcher.scheduler.advanceTimeBy(201)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { mockRepository.nudgeDispatcher("dev") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDisconnectCancelsEverything() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coordinator.connect(testScope, "dev", onReload = {})
|
||||||
|
coordinator.disconnect()
|
||||||
|
|
||||||
|
verify { mockEventsClient.disconnect() }
|
||||||
|
assertEquals(KanbanSyncStatus.Disconnected, coordinator.syncStatus.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,16 @@ class KanbanEventsClientTest {
|
|||||||
assertEquals(1710000000L, event.createdAt)
|
assertEquals(1710000000L, event.createdAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses event with numeric run_id from sqlite`() {
|
||||||
|
val frame =
|
||||||
|
"""{"events":[{"id":8,"task_id":"t-2","run_id":42,"kind":"status","payload":null,"created_at":1710000001}],"cursor":8}"""
|
||||||
|
val envelope = parseKanbanEventsFrame(frame)
|
||||||
|
assertNotNull(envelope)
|
||||||
|
assertEquals(1, envelope!!.events.size)
|
||||||
|
assertEquals("42", envelope.events.first().runId)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `parses empty events batch`() {
|
fun `parses empty events batch`() {
|
||||||
val envelope = parseKanbanEventsFrame("""{"events":[],"cursor":3}""")
|
val envelope = parseKanbanEventsFrame("""{"events":[],"cursor":3}""")
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
|
||||||
|
import com.m57.hermescontrol.data.model.BulkTaskResult
|
||||||
|
import com.m57.hermescontrol.data.model.BulkTasksResponse
|
||||||
|
import com.m57.hermescontrol.data.model.CreateBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.DeleteBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import com.m57.hermescontrol.data.model.RenameBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.TaskEstimate
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkError
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||||
|
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
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.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class KanbanMutationTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
||||||
|
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
||||||
|
private val preferences = InMemoryKanbanPreferencesStore()
|
||||||
|
|
||||||
|
private fun createViewModel(): KanbanViewModel =
|
||||||
|
KanbanViewModel(
|
||||||
|
repository = mockRepository,
|
||||||
|
preferences = preferences,
|
||||||
|
eventsClientProvider = { mockEventsClient },
|
||||||
|
endpointProvider = { "http://127.0.0.1:9119" },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateTaskSuccessWithWarning() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
val newTask = KanbanTask(id = "t_100", title = "New task", status = "todo")
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
coEvery { mockRepository.createTask("dev", any()) } returns
|
||||||
|
NetworkResult.Success(CreateTaskResponse(task = newTask, warning = "Dispatcher not running"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.createTask("New task", "Description", "todo")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.createTask(
|
||||||
|
"dev",
|
||||||
|
match { it.title == "New task" && it.body == "Description" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("Dispatcher not running") == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateTaskFailurePreservesStateAndShowsToast() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
coEvery { mockRepository.createTask("dev", any()) } returns
|
||||||
|
NetworkResult.Failure(NetworkError.Http(400, "Validation failed"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.createTask("Bad task", null, "todo")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("Validation failed") == true,
|
||||||
|
)
|
||||||
|
assertFalse(vm.uiState.value.isCreatingTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMoveTaskUpdatesBothTasksAndColumnsOptimistically() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
val task = KanbanTask(id = "t_1", title = "Task 1", status = "todo")
|
||||||
|
val colTodo = KanbanColumn("todo", listOf(task))
|
||||||
|
val colReady = KanbanColumn("ready", emptyList())
|
||||||
|
|
||||||
|
val deferred = CompletableDeferred<NetworkResult<UpdateTaskResponse>>()
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(colTodo, colReady)))
|
||||||
|
coEvery { mockRepository.updateTask("t_1", "dev", any()) } coAnswers { deferred.await() }
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// Initiate move
|
||||||
|
vm.moveTask(task, KanbanTaskAction.READY)
|
||||||
|
|
||||||
|
// Optimistic check: both tasks AND columns must reflect "ready"!
|
||||||
|
assertEquals(
|
||||||
|
"ready",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first { it.id == "t_1" }
|
||||||
|
.status,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.columns
|
||||||
|
.first { it.name == "todo" }
|
||||||
|
.tasks
|
||||||
|
.none { it.id == "t_1" },
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.columns
|
||||||
|
.first { it.name == "ready" }
|
||||||
|
.tasks
|
||||||
|
.any { it.id == "t_1" },
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.operatingTaskIds
|
||||||
|
.contains("t_1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Complete network call
|
||||||
|
deferred.complete(NetworkResult.Success(UpdateTaskResponse(task = task.copy(status = "ready"))))
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
vm.uiState.value.operatingTaskIds
|
||||||
|
.contains("t_1"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"ready",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first { it.id == "t_1" }
|
||||||
|
.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMoveTaskFailureRevertsTasksAndColumns() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
val task = KanbanTask(id = "t_1", title = "Task 1", status = "todo")
|
||||||
|
val colTodo = KanbanColumn("todo", listOf(task))
|
||||||
|
val colReady = KanbanColumn("ready", emptyList())
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(colTodo, colReady)))
|
||||||
|
coEvery { mockRepository.updateTask("t_1", "dev", any()) } returns
|
||||||
|
NetworkResult.Failure(NetworkError.Http(409, "Parent blocked"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.moveTask(task, KanbanTaskAction.READY)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// Reverted back to "todo" in both tasks and columns!
|
||||||
|
assertEquals(
|
||||||
|
"todo",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first { it.id == "t_1" }
|
||||||
|
.status,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.columns
|
||||||
|
.first { it.name == "todo" }
|
||||||
|
.tasks
|
||||||
|
.any { it.id == "t_1" },
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.columns
|
||||||
|
.first { it.name == "ready" }
|
||||||
|
.tasks
|
||||||
|
.none { it.id == "t_1" },
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("Parent blocked") == true,
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
vm.uiState.value.operatingTaskIds
|
||||||
|
.contains("t_1"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDuplicateConcurrentMoveIgnored() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
val task = KanbanTask(id = "t_1", title = "Task 1", status = "todo")
|
||||||
|
val colTodo = KanbanColumn("todo", listOf(task))
|
||||||
|
|
||||||
|
val deferred = CompletableDeferred<NetworkResult<UpdateTaskResponse>>()
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(colTodo)))
|
||||||
|
coEvery { mockRepository.updateTask("t_1", "dev", any()) } coAnswers { deferred.await() }
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// First tap
|
||||||
|
vm.moveTask(task, KanbanTaskAction.READY)
|
||||||
|
// Second duplicate tap while in-flight
|
||||||
|
vm.moveTask(task, KanbanTaskAction.ARCHIVE)
|
||||||
|
|
||||||
|
testDispatcher.scheduler.runCurrent()
|
||||||
|
|
||||||
|
// Only 1 updateTask call initiated
|
||||||
|
coVerify(exactly = 1) { mockRepository.updateTask("t_1", "dev", any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateTaskWithFullOptionsAndStatusAlignment() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
val returnedTask = KanbanTask(id = "t_101", title = "Complex Task", status = "ready")
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
coEvery { mockRepository.createTask("dev", any()) } returns
|
||||||
|
NetworkResult.Success(CreateTaskResponse(task = returnedTask))
|
||||||
|
coEvery { mockRepository.updateTask("t_101", "dev", any()) } returns
|
||||||
|
NetworkResult.Success(UpdateTaskResponse(task = returnedTask.copy(status = "todo")))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val body =
|
||||||
|
CreateTaskBody(
|
||||||
|
title = "Complex Task",
|
||||||
|
body = "Rich spec",
|
||||||
|
assignee = "reviewer",
|
||||||
|
priority = 2,
|
||||||
|
goalMode = true,
|
||||||
|
modelOverride = "claude-3-5-sonnet",
|
||||||
|
providerOverride = "anthropic",
|
||||||
|
reasoningEffort = "high",
|
||||||
|
skills = listOf("kotlin", "compose"),
|
||||||
|
parents = listOf("t_parent"),
|
||||||
|
workspaceKind = "worktree",
|
||||||
|
)
|
||||||
|
|
||||||
|
vm.createTask(body = body, targetStatus = "todo")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.createTask(
|
||||||
|
"dev",
|
||||||
|
match {
|
||||||
|
it.title == "Complex Task" &&
|
||||||
|
it.body == "Rich spec" &&
|
||||||
|
it.assignee == "reviewer" &&
|
||||||
|
it.priority == 2 &&
|
||||||
|
it.goalMode &&
|
||||||
|
it.modelOverride == "claude-3-5-sonnet" &&
|
||||||
|
it.providerOverride == "anthropic" &&
|
||||||
|
it.reasoningEffort == "high" &&
|
||||||
|
it.skills == listOf("kotlin", "compose") &&
|
||||||
|
it.parents == listOf("t_parent") &&
|
||||||
|
it.workspaceKind == "worktree"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since backend returned "ready" but user wanted "todo", status alignment update is sent
|
||||||
|
coVerify {
|
||||||
|
mockRepository.updateTask(
|
||||||
|
"t_101",
|
||||||
|
"dev",
|
||||||
|
match { it.status == "todo" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLoadProfilesPopulatesState() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val profiles =
|
||||||
|
listOf(
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.KanbanProfile(name = "researcher", model = "gpt-4o"),
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.KanbanProfile(name = "reviewer", model = "claude-3-5-sonnet"),
|
||||||
|
)
|
||||||
|
coEvery { mockRepository.getProfiles() } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.KanbanProfilesResponse(profiles),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadProfiles()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(2, vm.uiState.value.profiles.size)
|
||||||
|
assertEquals(
|
||||||
|
"researcher",
|
||||||
|
vm.uiState.value.profiles[0]
|
||||||
|
.name,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"reviewer",
|
||||||
|
vm.uiState.value.profiles[1]
|
||||||
|
.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateBoardSuccess() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "sprint-2", name = "Sprint 2")
|
||||||
|
coEvery { mockRepository.createBoard(any()) } returns
|
||||||
|
NetworkResult.Success(CreateBoardResponse(board = board))
|
||||||
|
coEvery { mockRepository.getBoards() } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.KanbanBoardsResponse(listOf(board), "sprint-2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.createBoard(slug = "sprint-2", name = "Sprint 2", description = "Next cycle")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.createBoard(
|
||||||
|
match {
|
||||||
|
it.slug == "sprint-2" && it.name == "Sprint 2" && it.description == "Next cycle"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals("Board created: Sprint 2", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRenameBoardSuccess() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev Renamed")
|
||||||
|
coEvery { mockRepository.updateBoard("dev", any()) } returns
|
||||||
|
NetworkResult.Success(RenameBoardResponse(board = board))
|
||||||
|
coEvery { mockRepository.getBoards() } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.KanbanBoardsResponse(listOf(board), "dev"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.renameBoard("dev", "Dev Renamed")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.updateBoard("dev", match { it.name == "Dev Renamed" })
|
||||||
|
}
|
||||||
|
assertEquals("Board renamed to Dev Renamed", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDeleteBoardSuccess() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockRepository.deleteBoard("old-board", delete = true) } returns
|
||||||
|
NetworkResult.Success(DeleteBoardResponse(current = null))
|
||||||
|
coEvery { mockRepository.getBoards() } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.KanbanBoardsResponse(emptyList(), null),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.deleteBoard("old-board")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify { mockRepository.deleteBoard("old-board", delete = true) }
|
||||||
|
assertEquals("Board deleted", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBulkMoveTasksSuccess() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("done", emptyList()))))
|
||||||
|
coEvery { mockRepository.bulkTasks("dev", any()) } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
BulkTasksResponse(
|
||||||
|
listOf(
|
||||||
|
BulkTaskResult("t_1", true),
|
||||||
|
BulkTaskResult("t_2", true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.bulkMove(listOf("t_1", "t_2"), "done")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.bulkTasks(
|
||||||
|
"dev",
|
||||||
|
match { it.ids == listOf("t_1", "t_2") && it.status == "done" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals("Moved 2 tasks to done", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBulkArchiveTasksSuccess() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
coEvery { mockRepository.bulkTasks("dev", any()) } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
BulkTasksResponse(
|
||||||
|
listOf(
|
||||||
|
BulkTaskResult("t_1", true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.bulkArchive(listOf("t_1"))
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.bulkTasks(
|
||||||
|
"dev",
|
||||||
|
match { it.ids == listOf("t_1") && it.archive },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals("Archived 1 tasks", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testEstimateNewTaskSuccess() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockRepository.estimateNew("Title", "Body") } returns
|
||||||
|
NetworkResult.Success(TaskEstimate(ok = true, estTokens = 1200, complexity = "low"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
val estimate = vm.estimateNewTask("Title", "Body")
|
||||||
|
|
||||||
|
assertEquals(1200, estimate?.estTokens)
|
||||||
|
assertEquals("low", estimate?.complexity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||||
|
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
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.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class KanbanScopeTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
||||||
|
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
||||||
|
private val preferences = InMemoryKanbanPreferencesStore()
|
||||||
|
|
||||||
|
private fun createViewModel(): KanbanViewModel =
|
||||||
|
KanbanViewModel(
|
||||||
|
repository = mockRepository,
|
||||||
|
preferences = preferences,
|
||||||
|
eventsClientProvider = { mockEventsClient },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBoardSwitchScopesCorrectlyForSameTaskId() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val boardA = KanbanBoard(id = "board_a", name = "Board A")
|
||||||
|
val boardB = KanbanBoard(id = "board_b", name = "Board B")
|
||||||
|
|
||||||
|
val taskA = KanbanTask(id = "t_1", title = "Task in Board A", status = "todo")
|
||||||
|
val taskB = KanbanTask(id = "t_1", title = "Task in Board B", status = "ready")
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoards() } returns
|
||||||
|
NetworkResult.Success(KanbanBoardsResponse(boards = listOf(boardA, boardB), current = "board_a"))
|
||||||
|
coEvery { mockRepository.getBoard("board_a") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", listOf(taskA)))))
|
||||||
|
coEvery { mockRepository.getBoard("board_b") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("ready", listOf(taskB)))))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadBoards()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"board_a",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Task in Board A",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first()
|
||||||
|
.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Switch to board B
|
||||||
|
vm.selectBoard(boardB)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"board_b",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Task in Board B",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first()
|
||||||
|
.title,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"ready",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first()
|
||||||
|
.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDelayedLateResponseDoesNotOverwriteNewerSelection() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val boardA = KanbanBoard(id = "board_a", name = "Board A")
|
||||||
|
val boardB = KanbanBoard(id = "board_b", name = "Board B")
|
||||||
|
|
||||||
|
val taskA = KanbanTask(id = "t_a", title = "Task A", status = "todo")
|
||||||
|
val taskB = KanbanTask(id = "t_b", title = "Task B", status = "todo")
|
||||||
|
|
||||||
|
val deferredB = CompletableDeferred<NetworkResult<KanbanBoardResponse>>()
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoard("board_a") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", listOf(taskA)))))
|
||||||
|
coEvery { mockRepository.getBoard("board_b") } coAnswers { deferredB.await() }
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
|
||||||
|
// User selects board B (deferred response)
|
||||||
|
vm.selectBoard(boardB)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
assertEquals(
|
||||||
|
"board_b",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
// User quickly selects board A before B responds
|
||||||
|
vm.selectBoard(boardA)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
assertEquals(
|
||||||
|
"board_a",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Task A",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first()
|
||||||
|
.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Now B finally resolves late
|
||||||
|
deferredB.complete(
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", listOf(taskB))))),
|
||||||
|
)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// State MUST remain Board A and Task A!
|
||||||
|
assertEquals(
|
||||||
|
"board_a",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Task A",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first()
|
||||||
|
.title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBoardListRefreshPreservesUserSelection() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val boardA = KanbanBoard(id = "board_a", name = "Board A")
|
||||||
|
val boardB = KanbanBoard(id = "board_b", name = "Board B")
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoards() } returns
|
||||||
|
NetworkResult.Success(KanbanBoardsResponse(boards = listOf(boardA, boardB), current = "board_a"))
|
||||||
|
coEvery { mockRepository.getBoard("board_b") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(boardB)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"board_b",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Refresh boards — server says current is "board_a"
|
||||||
|
vm.loadBoards()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// Local user selection "board_b" must be preserved!
|
||||||
|
assertEquals(
|
||||||
|
"board_b",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testEmptyBoardListHandledGracefully() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockRepository.getBoards() } returns
|
||||||
|
NetworkResult.Success(KanbanBoardsResponse(boards = emptyList(), current = null))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadBoards()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(vm.uiState.value.selectedBoard)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.columns
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testZeroSwitchCallsDuringNavigation() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val boardA = KanbanBoard(id = "board_a", name = "Board A")
|
||||||
|
val boardB = KanbanBoard(id = "board_b", name = "Board B")
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoards() } returns
|
||||||
|
NetworkResult.Success(KanbanBoardsResponse(boards = listOf(boardA, boardB), current = "board_a"))
|
||||||
|
coEvery { mockRepository.getBoard(any()) } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse())
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadBoards()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.selectBoard(boardB)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// Zero calls to switchBoard anywhere!
|
||||||
|
coVerify(exactly = 0) { mockRepository.updateBoard(any(), any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTaskFull
|
||||||
|
import com.m57.hermescontrol.data.model.ReassignTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.WorkerLog
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class KanbanTaskViewModelTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
||||||
|
|
||||||
|
private fun createViewModel(): KanbanTaskViewModel =
|
||||||
|
KanbanTaskViewModel(
|
||||||
|
repository = mockRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLoadTaskSuccess() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNotNull(vm.uiState.value.detail)
|
||||||
|
assertEquals(
|
||||||
|
"Task 1",
|
||||||
|
vm.uiState.value.detail
|
||||||
|
?.task
|
||||||
|
?.title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLoadRunningTaskLoadsLog() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Running task", status = "running")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
coEvery { mockRepository.getTaskLog("t_1", "dev", any()) } returns
|
||||||
|
NetworkResult.Success(WorkerLog(taskId = "t_1", content = "Log line 1", exists = true))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"Log line 1",
|
||||||
|
vm.uiState.value.workerLog
|
||||||
|
?.content,
|
||||||
|
)
|
||||||
|
coVerify { mockRepository.getTaskLog("t_1", "dev", any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testUpdateDescription() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Task 1", body = "Old body", status = "todo")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
coEvery { mockRepository.updateTask("t_1", "dev", any()) } returns
|
||||||
|
NetworkResult.Success(UpdateTaskResponse())
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.updateDescription("dev", "t_1", "New description")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"New description",
|
||||||
|
vm.uiState.value.detail
|
||||||
|
?.task
|
||||||
|
?.body,
|
||||||
|
)
|
||||||
|
assertEquals("Description saved", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAddComment() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
coEvery { mockRepository.addComment("t_1", "dev", "Steering note") } returns
|
||||||
|
NetworkResult.Success(Unit)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.addComment("dev", "t_1", "Steering note")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify { mockRepository.addComment("t_1", "dev", "Steering note") }
|
||||||
|
assertEquals("Comment added", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testPostNoteAndRequeue() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Running task", status = "running")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
coEvery { mockRepository.addComment("t_1", "dev", "Stop now") } returns
|
||||||
|
NetworkResult.Success(Unit)
|
||||||
|
coEvery { mockRepository.reclaimTask("t_1", "dev", any()) } returns
|
||||||
|
NetworkResult.Success(ReclaimTaskResponse(taskId = "t_1"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.postNoteAndRequeue("dev", "t_1", "Stop now")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify { mockRepository.addComment("t_1", "dev", "Stop now") }
|
||||||
|
coVerify { mockRepository.reclaimTask("t_1", "dev", match { it.contains("Requeued") }) }
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("requeued") == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testReassign() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockRepository.reassignTask("t_1", "dev", "coder", true) } returns
|
||||||
|
NetworkResult.Success(ReassignTaskResponse(taskId = "t_1", assignee = "coder"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.reassign("dev", "t_1", "coder")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify { mockRepository.reassignTask("t_1", "dev", "coder", true) }
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("coder") == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testUploadAttachmentSuccess() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Task with file", status = "todo")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
coEvery { mockRepository.uploadAttachment("t_1", "dev", any()) } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
com.m57.hermescontrol.data.model.AttachmentUploadResponse(
|
||||||
|
attachment =
|
||||||
|
com.m57.hermescontrol.data.model.KanbanAttachment(
|
||||||
|
id = 101L,
|
||||||
|
filename = "test.png",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.uploadAttachment("dev", "t_1", "test.png", "image/png", byteArrayOf(1, 2, 3))
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) }
|
||||||
|
assertEquals("Attachment uploaded", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
|||||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanColumn
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||||
import com.m57.hermescontrol.data.model.KanbanTask
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
import com.m57.hermescontrol.data.remote.KanbanApiService
|
||||||
import com.m57.hermescontrol.data.ws.KanbanEvent
|
import com.m57.hermescontrol.data.ws.KanbanEvent
|
||||||
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||||
import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope
|
import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope
|
||||||
@@ -23,7 +24,6 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceTimeBy
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
@@ -38,7 +38,7 @@ import retrofit2.Response
|
|||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class KanbanViewModelTest {
|
class KanbanViewModelTest {
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private val mockApi = mockk<HermesApiService>(relaxed = true)
|
private val mockApi = mockk<KanbanApiService>(relaxed = true)
|
||||||
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
||||||
|
|
||||||
private fun createViewModel(): KanbanViewModel {
|
private fun createViewModel(): KanbanViewModel {
|
||||||
@@ -60,23 +60,24 @@ class KanbanViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun stubBoard(tasks: List<KanbanTask> = listOf(KanbanTask(id = "t1", title = "Task 1", status = "todo"))) {
|
private fun stubBoard(tasks: List<KanbanTask> = listOf(KanbanTask(id = "t1", title = "Task 1", status = "todo"))) {
|
||||||
coEvery { mockApi.getKanbanBoards() } returns
|
coEvery { mockApi.getBoards(any()) } returns
|
||||||
Response.success(
|
Response.success(
|
||||||
KanbanBoardsResponse(
|
KanbanBoardsResponse(
|
||||||
boards = listOf(KanbanBoard(id = "work", name = "Work")),
|
boards = listOf(KanbanBoard(id = "work", name = "Work")),
|
||||||
current = "work",
|
current = "work",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
coEvery { mockApi.switchKanbanBoard("work") } returns Response.success(Unit)
|
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
|
||||||
coEvery { mockApi.getKanbanBoard() } returns
|
|
||||||
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = tasks))))
|
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = tasks))))
|
||||||
|
coEvery { mockApi.getBoard(board = "ops", any(), any()) } returns
|
||||||
|
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = emptyList()))))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
mockkObject(ApiClient)
|
mockkObject(ApiClient)
|
||||||
every { ApiClient.hermesApi } returns mockApi
|
every { ApiClient.kanbanApi } returns mockApi
|
||||||
stubBoard()
|
stubBoard()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ class KanbanViewModelTest {
|
|||||||
val eventsSlot = slot<(KanbanEventsEnvelope) -> Unit>()
|
val eventsSlot = slot<(KanbanEventsEnvelope) -> Unit>()
|
||||||
verify { mockEventsClient.connect(any(), any(), any(), capture(eventsSlot), any()) }
|
verify { mockEventsClient.connect(any(), any(), any(), capture(eventsSlot), any()) }
|
||||||
|
|
||||||
coEvery { mockApi.getKanbanBoard() } returns
|
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
|
||||||
Response.success(
|
Response.success(
|
||||||
KanbanBoardResponse(
|
KanbanBoardResponse(
|
||||||
columns =
|
columns =
|
||||||
@@ -138,7 +139,7 @@ class KanbanViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceTimeBy(1)
|
testDispatcher.scheduler.advanceTimeBy(1)
|
||||||
settle()
|
settle()
|
||||||
assertEquals(2, vm.uiState.value.tasks.size)
|
assertEquals(2, vm.uiState.value.tasks.size)
|
||||||
coVerify(exactly = 2) { mockApi.getKanbanBoard() }
|
coVerify(atLeast = 2) { mockApi.getBoard(board = "work", any(), any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -146,9 +147,6 @@ class KanbanViewModelTest {
|
|||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
vm.loadBoards()
|
vm.loadBoards()
|
||||||
settle()
|
settle()
|
||||||
coEvery { mockApi.switchKanbanBoard("ops") } returns Response.success(Unit)
|
|
||||||
coEvery { mockApi.getKanbanBoard() } returns
|
|
||||||
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = emptyList()))))
|
|
||||||
vm.selectBoard(KanbanBoard(id = "ops", name = "Ops"))
|
vm.selectBoard(KanbanBoard(id = "ops", name = "Ops"))
|
||||||
settle()
|
settle()
|
||||||
verify { mockEventsClient.connect(any(), eq("ops"), any(), any(), any()) }
|
verify { mockEventsClient.connect(any(), eq("ops"), any(), any(), any()) }
|
||||||
@@ -219,7 +217,7 @@ class KanbanViewModelTest {
|
|||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
vm.loadBoards()
|
vm.loadBoards()
|
||||||
settle()
|
settle()
|
||||||
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit)
|
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
|
||||||
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
|
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
|
||||||
settle()
|
settle()
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -228,7 +226,7 @@ class KanbanViewModelTest {
|
|||||||
.first { it.id == "t1" }
|
.first { it.id == "t1" }
|
||||||
.status,
|
.status,
|
||||||
)
|
)
|
||||||
coVerify { mockApi.updateKanbanTask("t1", mapOf("status" to "ready")) }
|
coVerify { mockApi.updateTask("t1", "work", match { it.status == "ready" }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -236,7 +234,7 @@ class KanbanViewModelTest {
|
|||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
vm.loadBoards()
|
vm.loadBoards()
|
||||||
settle()
|
settle()
|
||||||
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.error(409, "".toResponseBody())
|
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.error(409, "".toResponseBody())
|
||||||
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
|
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
|
||||||
settle()
|
settle()
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -256,7 +254,7 @@ class KanbanViewModelTest {
|
|||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
vm.loadBoards()
|
vm.loadBoards()
|
||||||
settle()
|
settle()
|
||||||
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit)
|
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
|
||||||
vm.moveTask(
|
vm.moveTask(
|
||||||
KanbanTask(id = "t1", title = "Task 1", status = "ready"),
|
KanbanTask(id = "t1", title = "Task 1", status = "ready"),
|
||||||
KanbanTaskAction.COMPLETE,
|
KanbanTaskAction.COMPLETE,
|
||||||
@@ -264,9 +262,10 @@ class KanbanViewModelTest {
|
|||||||
)
|
)
|
||||||
settle()
|
settle()
|
||||||
coVerify {
|
coVerify {
|
||||||
mockApi.updateKanbanTask(
|
mockApi.updateTask(
|
||||||
"t1",
|
"t1",
|
||||||
mapOf("status" to "done", "result" to "Shipped it", "summary" to "Shipped it"),
|
"work",
|
||||||
|
match { it.status == "done" && it.summary == "Shipped it" && it.result == "Shipped it" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user