23 files changed
+1777
-51
No files matched your search
@@ -22,8 +22,14 @@ data class KanbanDetailEvent(
|
||||
@Serializable
|
||||
data class KanbanRun(
|
||||
val id: Long,
|
||||
val taskId: String? = null,
|
||||
val profile: String? = null,
|
||||
val stepKey: String? = null,
|
||||
val status: String,
|
||||
val claimLock: String? = null,
|
||||
val claimExpires: Long? = null,
|
||||
val maxRuntimeSeconds: Int? = null,
|
||||
val lastHeartbeatAt: Long? = null,
|
||||
val outcome: String? = null,
|
||||
val summary: String? = null,
|
||||
val error: String? = null,
|
||||
@@ -59,3 +65,71 @@ data class KanbanAttachment(
|
||||
data class AttachmentUploadResponse(
|
||||
val attachment: KanbanAttachment? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AttachmentListResponse(
|
||||
val attachments: List<KanbanAttachment> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeleteAttachmentResponse(
|
||||
val ok: Boolean,
|
||||
val id: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KanbanRunResponse(
|
||||
val run: KanbanRun,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KanbanRunInspection(
|
||||
val runId: Long,
|
||||
val alive: Boolean,
|
||||
val pid: Int? = null,
|
||||
val reason: String? = null,
|
||||
val cpuPercent: Double? = null,
|
||||
val memoryRssBytes: Long? = null,
|
||||
val memoryVmsBytes: Long? = null,
|
||||
val numThreads: Int? = null,
|
||||
val numFds: Int? = null,
|
||||
val status: String? = null,
|
||||
val createTime: Double? = null,
|
||||
val cmdline: List<String>? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TerminateRunBody(
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TerminateRunResponse(
|
||||
val ok: Boolean,
|
||||
val runId: Long,
|
||||
val taskId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KanbanActiveWorker(
|
||||
val runId: Long,
|
||||
val taskId: String,
|
||||
val taskTitle: String,
|
||||
val taskStatus: String,
|
||||
val taskAssignee: String? = null,
|
||||
val profile: String? = null,
|
||||
val workerPid: Int,
|
||||
val startedAt: Long? = null,
|
||||
val claimLock: String? = null,
|
||||
val claimExpires: Long? = null,
|
||||
val lastHeartbeatAt: Long? = null,
|
||||
val maxRuntimeSeconds: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ActiveWorkersResponse(
|
||||
val workers: List<KanbanActiveWorker> = emptyList(),
|
||||
val count: Int = workers.size,
|
||||
val checkedAt: Long,
|
||||
)
|
||||
@@ -48,6 +48,18 @@ data class TaskLinks(
|
||||
val children: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskLinkBody(
|
||||
val parentId: String,
|
||||
val childId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskLinkResponse(
|
||||
val ok: Boolean,
|
||||
val gated: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChildTaskResult(
|
||||
val id: String,
|
||||
@@ -75,6 +87,9 @@ data class KanbanTask(
|
||||
val startedAt: Long? = null,
|
||||
val workerPid: Int? = null,
|
||||
val lastHeartbeatAt: Long? = null,
|
||||
val currentRunId: Long? = null,
|
||||
val workflowTemplateId: String? = null,
|
||||
val currentStepKey: String? = null,
|
||||
) {
|
||||
val description: String? get() = body
|
||||
val assignedTo: String? get() = assignee
|
||||
@@ -112,6 +127,10 @@ data class KanbanTaskFull(
|
||||
val startedAt: Long? = null,
|
||||
val workerPid: Int? = null,
|
||||
val lastHeartbeatAt: Long? = null,
|
||||
val currentRunId: Long? = null,
|
||||
val workflowTemplateId: String? = null,
|
||||
val currentStepKey: String? = null,
|
||||
val maxRuntimeSeconds: Int? = null,
|
||||
val result: String? = null,
|
||||
val createdBy: String? = null,
|
||||
val modelOverride: String? = null,
|
||||
@@ -245,6 +264,19 @@ data class ReclaimTaskResponse(
|
||||
val taskId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SpecifyTaskBody(
|
||||
val author: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SpecifyTaskResponse(
|
||||
val ok: Boolean,
|
||||
val taskId: String,
|
||||
val reason: String? = null,
|
||||
val newTitle: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskEstimate(
|
||||
val ok: Boolean,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.model.ActiveWorkersResponse
|
||||
import com.m57.hermescontrol.data.model.AttachmentListResponse
|
||||
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
|
||||
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
||||
import com.m57.hermescontrol.data.model.BoardExportResult
|
||||
@@ -10,6 +12,7 @@ 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.DeleteAttachmentResponse
|
||||
import com.m57.hermescontrol.data.model.DeleteBoardResponse
|
||||
import com.m57.hermescontrol.data.model.DispatchResult
|
||||
import com.m57.hermescontrol.data.model.ExportBoardBody
|
||||
@@ -18,6 +21,8 @@ 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.KanbanRunInspection
|
||||
import com.m57.hermescontrol.data.model.KanbanRunResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
|
||||
import com.m57.hermescontrol.data.model.OrchestrationSettings
|
||||
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
|
||||
@@ -27,7 +32,13 @@ 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.SpecifyTaskBody
|
||||
import com.m57.hermescontrol.data.model.SpecifyTaskResponse
|
||||
import com.m57.hermescontrol.data.model.TaskEstimate
|
||||
import com.m57.hermescontrol.data.model.TaskLinkBody
|
||||
import com.m57.hermescontrol.data.model.TaskLinkResponse
|
||||
import com.m57.hermescontrol.data.model.TerminateRunBody
|
||||
import com.m57.hermescontrol.data.model.TerminateRunResponse
|
||||
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||
import com.m57.hermescontrol.data.model.WorkerLog
|
||||
@@ -57,6 +68,8 @@ interface KanbanApiService {
|
||||
@Query("board") board: String? = null,
|
||||
@Query("include_archived") includeArchived: Boolean = false,
|
||||
@Query("tenant") tenant: String? = null,
|
||||
@Query("workflow_template_id") workflowTemplateId: String? = null,
|
||||
@Query("current_step_key") currentStepKey: String? = null,
|
||||
): Response<KanbanBoardResponse>
|
||||
|
||||
@POST("api/plugins/kanban/boards")
|
||||
@@ -96,6 +109,8 @@ interface KanbanApiService {
|
||||
suspend fun getTask(
|
||||
@Path("id") taskId: String,
|
||||
@Query("board") board: String? = null,
|
||||
@Query("run_state_type") runStateType: String? = null,
|
||||
@Query("run_state_name") runStateName: String? = null,
|
||||
): Response<KanbanTaskDetailResponse>
|
||||
|
||||
@POST("api/plugins/kanban/tasks")
|
||||
@@ -170,6 +185,12 @@ interface KanbanApiService {
|
||||
@Part file: MultipartBody.Part,
|
||||
): Response<AttachmentUploadResponse>
|
||||
|
||||
@GET("api/plugins/kanban/tasks/{id}/attachments")
|
||||
suspend fun listAttachments(
|
||||
@Path("id") taskId: String,
|
||||
@Query("board") board: String? = null,
|
||||
): Response<AttachmentListResponse>
|
||||
|
||||
@Streaming
|
||||
@GET("api/plugins/kanban/attachments/{id}")
|
||||
suspend fun downloadAttachment(
|
||||
@@ -177,6 +198,49 @@ interface KanbanApiService {
|
||||
@Query("board") board: String? = null,
|
||||
): Response<ResponseBody>
|
||||
|
||||
@DELETE("api/plugins/kanban/attachments/{id}")
|
||||
suspend fun deleteAttachment(
|
||||
@Path("id") attachmentId: Long,
|
||||
@Query("board") board: String? = null,
|
||||
): Response<DeleteAttachmentResponse>
|
||||
|
||||
@GET("api/plugins/kanban/workers/active")
|
||||
suspend fun getActiveWorkers(
|
||||
@Query("board") board: String? = null,
|
||||
): Response<ActiveWorkersResponse>
|
||||
|
||||
@GET("api/plugins/kanban/runs/{id}")
|
||||
suspend fun getRun(
|
||||
@Path("id") runId: Long,
|
||||
@Query("board") board: String? = null,
|
||||
): Response<KanbanRunResponse>
|
||||
|
||||
@GET("api/plugins/kanban/runs/{id}/inspect")
|
||||
suspend fun inspectRun(
|
||||
@Path("id") runId: Long,
|
||||
@Query("board") board: String? = null,
|
||||
): Response<KanbanRunInspection>
|
||||
|
||||
@POST("api/plugins/kanban/runs/{id}/terminate")
|
||||
suspend fun terminateRun(
|
||||
@Path("id") runId: Long,
|
||||
@Query("board") board: String? = null,
|
||||
@Body body: TerminateRunBody = TerminateRunBody(),
|
||||
): Response<TerminateRunResponse>
|
||||
|
||||
@POST("api/plugins/kanban/tasks/{id}/specify")
|
||||
suspend fun specifyTask(
|
||||
@Path("id") taskId: String,
|
||||
@Query("board") board: String? = null,
|
||||
@Body body: SpecifyTaskBody = SpecifyTaskBody(),
|
||||
): Response<SpecifyTaskResponse>
|
||||
|
||||
@POST("api/plugins/kanban/links")
|
||||
suspend fun createTaskLink(
|
||||
@Query("board") board: String? = null,
|
||||
@Body body: TaskLinkBody,
|
||||
): Response<TaskLinkResponse>
|
||||
|
||||
@GET("api/plugins/kanban/profiles")
|
||||
suspend fun getKanbanProfiles(): Response<KanbanProfilesResponse>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.m57.hermescontrol.data.repository
|
||||
|
||||
import com.m57.hermescontrol.data.model.ActiveWorkersResponse
|
||||
import com.m57.hermescontrol.data.model.AttachmentListResponse
|
||||
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
|
||||
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
||||
import com.m57.hermescontrol.data.model.BoardExportResult
|
||||
@@ -10,6 +12,7 @@ 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.DeleteAttachmentResponse
|
||||
import com.m57.hermescontrol.data.model.DeleteBoardResponse
|
||||
import com.m57.hermescontrol.data.model.DispatchResult
|
||||
import com.m57.hermescontrol.data.model.ExportBoardBody
|
||||
@@ -18,6 +21,8 @@ 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.KanbanRunInspection
|
||||
import com.m57.hermescontrol.data.model.KanbanRunResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
|
||||
import com.m57.hermescontrol.data.model.OrchestrationSettings
|
||||
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
|
||||
@@ -27,7 +32,13 @@ 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.SpecifyTaskBody
|
||||
import com.m57.hermescontrol.data.model.SpecifyTaskResponse
|
||||
import com.m57.hermescontrol.data.model.TaskEstimate
|
||||
import com.m57.hermescontrol.data.model.TaskLinkBody
|
||||
import com.m57.hermescontrol.data.model.TaskLinkResponse
|
||||
import com.m57.hermescontrol.data.model.TerminateRunBody
|
||||
import com.m57.hermescontrol.data.model.TerminateRunResponse
|
||||
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||
import com.m57.hermescontrol.data.model.WorkerLog
|
||||
@@ -48,6 +59,8 @@ interface KanbanRepository {
|
||||
board: String,
|
||||
includeArchived: Boolean = false,
|
||||
tenant: String? = null,
|
||||
workflowTemplateId: String? = null,
|
||||
currentStepKey: String? = null,
|
||||
): NetworkResult<KanbanBoardResponse>
|
||||
|
||||
suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse>
|
||||
@@ -72,6 +85,8 @@ interface KanbanRepository {
|
||||
suspend fun getTask(
|
||||
taskId: String,
|
||||
board: String?,
|
||||
runStateType: String? = null,
|
||||
runStateName: String? = null,
|
||||
): NetworkResult<KanbanTaskDetailResponse>
|
||||
|
||||
suspend fun createTask(
|
||||
@@ -136,11 +151,49 @@ interface KanbanRepository {
|
||||
file: MultipartBody.Part,
|
||||
): NetworkResult<AttachmentUploadResponse>
|
||||
|
||||
suspend fun listAttachments(
|
||||
taskId: String,
|
||||
board: String,
|
||||
): NetworkResult<AttachmentListResponse>
|
||||
|
||||
suspend fun deleteAttachment(
|
||||
attachmentId: Long,
|
||||
board: String,
|
||||
): NetworkResult<DeleteAttachmentResponse>
|
||||
|
||||
suspend fun downloadAttachment(
|
||||
attachmentId: Long,
|
||||
board: String?,
|
||||
): NetworkResult<ResponseBody>
|
||||
|
||||
suspend fun getActiveWorkers(board: String): NetworkResult<ActiveWorkersResponse>
|
||||
|
||||
suspend fun getRun(
|
||||
runId: Long,
|
||||
board: String,
|
||||
): NetworkResult<KanbanRunResponse>
|
||||
|
||||
suspend fun inspectRun(
|
||||
runId: Long,
|
||||
board: String,
|
||||
): NetworkResult<KanbanRunInspection>
|
||||
|
||||
suspend fun terminateRun(
|
||||
runId: Long,
|
||||
board: String,
|
||||
): NetworkResult<TerminateRunResponse>
|
||||
|
||||
suspend fun specifyTask(
|
||||
taskId: String,
|
||||
board: String,
|
||||
): NetworkResult<SpecifyTaskResponse>
|
||||
|
||||
suspend fun createTaskLink(
|
||||
board: String,
|
||||
parentId: String,
|
||||
childId: String,
|
||||
): NetworkResult<TaskLinkResponse>
|
||||
|
||||
suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse>
|
||||
|
||||
suspend fun updateProfileDescription(
|
||||
@@ -173,9 +226,11 @@ class KanbanRepositoryImpl(
|
||||
board: String,
|
||||
includeArchived: Boolean,
|
||||
tenant: String?,
|
||||
workflowTemplateId: String?,
|
||||
currentStepKey: String?,
|
||||
): NetworkResult<KanbanBoardResponse> =
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) }
|
||||
safeApiCall { apiProvider().getBoard(board, includeArchived, tenant, workflowTemplateId, currentStepKey) }
|
||||
}
|
||||
|
||||
override suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse> =
|
||||
@@ -215,9 +270,11 @@ class KanbanRepositoryImpl(
|
||||
override suspend fun getTask(
|
||||
taskId: String,
|
||||
board: String?,
|
||||
runStateType: String?,
|
||||
runStateName: String?,
|
||||
): NetworkResult<KanbanTaskDetailResponse> =
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { apiProvider().getTask(taskId = taskId, board = board) }
|
||||
safeApiCall { apiProvider().getTask(taskId, board, runStateType, runStateName) }
|
||||
}
|
||||
|
||||
override suspend fun createTask(
|
||||
@@ -364,6 +421,18 @@ class KanbanRepositoryImpl(
|
||||
safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) }
|
||||
}
|
||||
|
||||
override suspend fun listAttachments(
|
||||
taskId: String,
|
||||
board: String,
|
||||
): NetworkResult<AttachmentListResponse> =
|
||||
withContext(ioDispatcher) { safeApiCall { apiProvider().listAttachments(taskId, board) } }
|
||||
|
||||
override suspend fun deleteAttachment(
|
||||
attachmentId: Long,
|
||||
board: String,
|
||||
): NetworkResult<DeleteAttachmentResponse> =
|
||||
withContext(ioDispatcher) { safeApiCall { apiProvider().deleteAttachment(attachmentId, board) } }
|
||||
|
||||
override suspend fun downloadAttachment(
|
||||
attachmentId: Long,
|
||||
board: String?,
|
||||
@@ -372,6 +441,48 @@ class KanbanRepositoryImpl(
|
||||
safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) }
|
||||
}
|
||||
|
||||
override suspend fun getActiveWorkers(board: String): NetworkResult<ActiveWorkersResponse> =
|
||||
withContext(ioDispatcher) { safeApiCall { apiProvider().getActiveWorkers(board) } }
|
||||
|
||||
override suspend fun getRun(
|
||||
runId: Long,
|
||||
board: String,
|
||||
): NetworkResult<KanbanRunResponse> =
|
||||
withContext(ioDispatcher) { safeApiCall { apiProvider().getRun(runId, board) } }
|
||||
|
||||
override suspend fun inspectRun(
|
||||
runId: Long,
|
||||
board: String,
|
||||
): NetworkResult<KanbanRunInspection> =
|
||||
withContext(ioDispatcher) { safeApiCall { apiProvider().inspectRun(runId, board) } }
|
||||
|
||||
override suspend fun terminateRun(
|
||||
runId: Long,
|
||||
board: String,
|
||||
): NetworkResult<TerminateRunResponse> =
|
||||
withContext(ioDispatcher) {
|
||||
val result = safeApiCall { apiProvider().terminateRun(runId, board, TerminateRunBody(reason = "mobile")) }
|
||||
if (result is NetworkResult.Success && result.data.ok) nudger.scheduleNudge(board)
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun specifyTask(
|
||||
taskId: String,
|
||||
board: String,
|
||||
): NetworkResult<SpecifyTaskResponse> =
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { apiProvider().specifyTask(taskId, board, SpecifyTaskBody(author = "mobile")) }
|
||||
}
|
||||
|
||||
override suspend fun createTaskLink(
|
||||
board: String,
|
||||
parentId: String,
|
||||
childId: String,
|
||||
): NetworkResult<TaskLinkResponse> =
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { apiProvider().createTaskLink(board, TaskLinkBody(parentId, childId)) }
|
||||
}
|
||||
|
||||
override suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse> =
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { apiProvider().getKanbanProfiles() }
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -119,10 +120,22 @@ fun KanbanScreen(
|
||||
var selectedAssignee by remember { mutableStateOf<String?>(null) }
|
||||
var selectedTenant by remember { mutableStateOf<String?>(null) }
|
||||
var showFilterSheet by remember { mutableStateOf(false) }
|
||||
var showWorkersDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val filteredTasks =
|
||||
remember(query, state.tasks, selectedAssignee, selectedTenant) {
|
||||
state.tasks.filter { task ->
|
||||
remember(
|
||||
query,
|
||||
state.tasks,
|
||||
selectedAssignee,
|
||||
selectedTenant,
|
||||
state.selectedWorkflowTemplateId,
|
||||
state.selectedCurrentStepKey,
|
||||
) {
|
||||
filterKanbanTasks(
|
||||
state.tasks,
|
||||
state.selectedWorkflowTemplateId,
|
||||
state.selectedCurrentStepKey,
|
||||
).filter { task ->
|
||||
val matchesQuery =
|
||||
query.isBlank() ||
|
||||
task.title.contains(query, ignoreCase = true) ||
|
||||
@@ -331,6 +344,14 @@ fun KanbanScreen(
|
||||
},
|
||||
)
|
||||
if (state.selectedBoard != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.kanban_active_workers)) },
|
||||
onClick = {
|
||||
showBoardMenu = false
|
||||
showWorkersDialog = true
|
||||
viewModel.loadActiveWorkers()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.kanban_board_settings)) },
|
||||
onClick = {
|
||||
@@ -917,10 +938,23 @@ fun KanbanScreen(
|
||||
tenants = tenants,
|
||||
selectedAssignee = selectedAssignee,
|
||||
selectedTenant = selectedTenant,
|
||||
workflowTemplateIds =
|
||||
state.tasks
|
||||
.mapNotNull { it.workflowTemplateId }
|
||||
.distinct()
|
||||
.sorted(),
|
||||
currentStepKeys =
|
||||
state.tasks
|
||||
.mapNotNull { it.currentStepKey }
|
||||
.distinct()
|
||||
.sorted(),
|
||||
selectedWorkflowTemplateId = state.selectedWorkflowTemplateId,
|
||||
selectedCurrentStepKey = state.selectedCurrentStepKey,
|
||||
includeArchived = state.includeArchived,
|
||||
groupRunning = state.groupRunning,
|
||||
onSelectAssignee = { selectedAssignee = it },
|
||||
onSelectTenant = { selectedTenant = it },
|
||||
onSelectWorkflowFilters = viewModel::setWorkflowFilters,
|
||||
onToggleIncludeArchived = viewModel::setIncludeArchived,
|
||||
onToggleGroupRunning = viewModel::setGroupRunning,
|
||||
onClearFilters = {
|
||||
@@ -928,6 +962,7 @@ fun KanbanScreen(
|
||||
selectedTenant = null
|
||||
viewModel.setIncludeArchived(false)
|
||||
viewModel.setGroupRunning(false)
|
||||
viewModel.setWorkflowFilters(null, null)
|
||||
},
|
||||
onDismiss = { showFilterSheet = false },
|
||||
)
|
||||
@@ -1291,6 +1326,46 @@ fun KanbanScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showWorkersDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showWorkersDialog = false },
|
||||
title = { Text(stringResource(R.string.kanban_active_workers)) },
|
||||
text = {
|
||||
if (state.isLoadingWorkers) {
|
||||
CircularProgressIndicator()
|
||||
} else if (state.activeWorkers.isEmpty()) {
|
||||
Text(stringResource(R.string.kanban_no_active_workers))
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 320.dp)) {
|
||||
items(state.activeWorkers, key = { it.runId }) { worker ->
|
||||
Text(
|
||||
"${worker.taskTitle} · ${worker.profile ?: worker.taskAssignee.orEmpty()}",
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val board = state.selectedBoard ?: return@clickable
|
||||
showWorkersDialog = false
|
||||
NavigationController.navigateTo(
|
||||
KanbanTaskDetailKey(
|
||||
boardSlug = board.id,
|
||||
taskId = worker.taskId,
|
||||
),
|
||||
)
|
||||
}.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showWorkersDialog = false }) {
|
||||
Text(stringResource(R.string.action_close))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteBoardDialog && state.selectedBoard != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteBoardDialog = false },
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
@@ -62,9 +63,11 @@ 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.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -75,12 +78,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.KanbanTaskDetailKey
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.KanbanAttachment
|
||||
import com.m57.hermescontrol.data.model.KanbanProfile
|
||||
import com.m57.hermescontrol.data.model.KanbanTaskFull
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.model.TaskEstimate
|
||||
import com.m57.hermescontrol.data.model.TaskLinks
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
@@ -89,6 +94,13 @@ import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import com.m57.hermescontrol.ui.kanban.components.KanbanModelOverrideEditor
|
||||
import com.m57.hermescontrol.util.StreamingUriRequestBody
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -110,6 +122,7 @@ fun KanbanTaskScreen(
|
||||
stringResource(R.string.kanban_tab_files),
|
||||
)
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var runToTerminate by remember { mutableStateOf<Long?>(null) }
|
||||
|
||||
LaunchedEffect(boardSlug, taskId) {
|
||||
viewModel.loadTask(boardSlug, taskId)
|
||||
@@ -208,6 +221,10 @@ fun KanbanTaskScreen(
|
||||
onReclaim = {
|
||||
viewModel.reclaim(boardSlug, taskId)
|
||||
},
|
||||
isSpecifying = state.isSpecifying,
|
||||
isLinking = state.isLinking,
|
||||
onSpecify = { viewModel.specifyTask(boardSlug, taskId) },
|
||||
onAddParent = { viewModel.createParentLink(boardSlug, taskId, it) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -229,6 +246,10 @@ fun KanbanTaskScreen(
|
||||
TaskRunsTab(
|
||||
runs = detail.runs,
|
||||
workerLog = state.workerLog,
|
||||
runStateType = state.runStateType,
|
||||
runStateName = state.runStateName,
|
||||
onFilter = { type, name -> viewModel.setRunFilter(boardSlug, taskId, type, name) },
|
||||
onInspect = { viewModel.inspectRun(boardSlug, taskId, it) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -242,9 +263,10 @@ fun KanbanTaskScreen(
|
||||
TaskFilesTab(
|
||||
attachments = detail.attachments ?: emptyList(),
|
||||
isUploading = state.isUploadingAttachment,
|
||||
onUpload = { filename, mimeType, bytes ->
|
||||
viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes)
|
||||
},
|
||||
isDeleting = state.isDeletingAttachment,
|
||||
board = boardSlug,
|
||||
taskId = taskId,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -276,6 +298,68 @@ fun KanbanTaskScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val selectedRun = state.selectedRun
|
||||
if (selectedRun != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::clearRunInspection,
|
||||
title = { Text(stringResource(R.string.kanban_run_details)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
MetaRow(stringResource(R.string.kanban_run_number, selectedRun.id), selectedRun.status)
|
||||
selectedRun.profile?.let { MetaRow(stringResource(R.string.kanban_assignee), it) }
|
||||
selectedRun.stepKey?.let { MetaRow("Step", it) }
|
||||
selectedRun.workerPid?.let { MetaRow("PID", it.toString()) }
|
||||
selectedRun.summary?.let { Text(it) }
|
||||
selectedRun.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
state.runInspection?.let { inspection ->
|
||||
Text(
|
||||
if (inspection.alive) {
|
||||
inspection.status ?: selectedRun.status
|
||||
} else {
|
||||
inspection.reason ?: stringResource(R.string.kanban_run_inspection_unavailable)
|
||||
},
|
||||
)
|
||||
inspection.cpuPercent?.let { MetaRow("CPU", "$it%") }
|
||||
inspection.memoryRssBytes?.let { MetaRow("Memory", "${it / 1024} KiB") }
|
||||
inspection.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (selectedRun.endedAt == null && selectedRun.status == "running" &&
|
||||
state.detail?.task?.status == "running"
|
||||
) {
|
||||
TextButton(onClick = { runToTerminate = selectedRun.id }, enabled = !state.isTerminatingRun) {
|
||||
Text(stringResource(R.string.kanban_terminate_run))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::clearRunInspection) { Text(stringResource(R.string.action_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (runToTerminate != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { runToTerminate = null },
|
||||
title = { Text(stringResource(R.string.kanban_terminate_run)) },
|
||||
text = { Text(stringResource(R.string.kanban_terminate_run_confirm)) },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
runToTerminate?.let { viewModel.terminateRun(boardSlug, taskId, it) }
|
||||
runToTerminate = null
|
||||
},
|
||||
enabled = !state.isTerminatingRun,
|
||||
) { Text(stringResource(R.string.kanban_terminate_run)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { runToTerminate = null }) { Text(stringResource(R.string.action_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -293,11 +377,16 @@ private fun TaskOverviewTab(
|
||||
onNavigateToTask: (String) -> Unit,
|
||||
onSaveDescription: (String) -> Unit,
|
||||
onReclaim: () -> Unit,
|
||||
isSpecifying: Boolean,
|
||||
isLinking: Boolean,
|
||||
onSpecify: () -> Unit,
|
||||
onAddParent: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isEditingDescription by remember { mutableStateOf(false) }
|
||||
var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") }
|
||||
var assigneeMenuExpanded by remember { mutableStateOf(false) }
|
||||
var parentDraft by remember { mutableStateOf("") }
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
val currentModelOverride =
|
||||
@@ -430,6 +519,8 @@ private fun TaskOverviewTab(
|
||||
task.tenant?.let { MetaRow(label = "Tenant", value = it) }
|
||||
task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") }
|
||||
task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) }
|
||||
task.workflowTemplateId?.let { MetaRow(label = "Workflow", value = it) }
|
||||
task.currentStepKey?.let { MetaRow(label = "Step", value = it) }
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
@@ -446,7 +537,7 @@ private fun TaskOverviewTab(
|
||||
}
|
||||
|
||||
// Dependencies Section
|
||||
if (links.parents.isNotEmpty() || links.children.isNotEmpty()) {
|
||||
run {
|
||||
Card(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier =
|
||||
@@ -461,6 +552,22 @@ private fun TaskOverviewTab(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = parentDraft,
|
||||
onValueChange = { parentDraft = it },
|
||||
label = { Text(stringResource(R.string.kanban_parent_id)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
onAddParent(parentDraft)
|
||||
parentDraft = ""
|
||||
},
|
||||
enabled = parentDraft.isNotBlank() && !isLinking,
|
||||
) { Text(stringResource(R.string.kanban_add_parent)) }
|
||||
}
|
||||
if (links.parents.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
@@ -506,6 +613,13 @@ private fun TaskOverviewTab(
|
||||
}
|
||||
|
||||
// Effort Estimation Section
|
||||
if (task.status == "triage") {
|
||||
OutlinedButton(
|
||||
onClick = onSpecify,
|
||||
enabled = !isSpecifying,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) { Text(stringResource(R.string.kanban_specify_task)) }
|
||||
}
|
||||
Card(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier =
|
||||
@@ -778,8 +892,15 @@ private fun formatRunDuration(durationSeconds: Long): String =
|
||||
private fun TaskRunsTab(
|
||||
runs: List<com.m57.hermescontrol.data.model.KanbanRun>,
|
||||
workerLog: com.m57.hermescontrol.data.model.WorkerLog?,
|
||||
runStateType: String?,
|
||||
runStateName: String?,
|
||||
onFilter: (String?, String?) -> Unit,
|
||||
onInspect: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var filterType by remember(runStateType) { mutableStateOf(runStateType ?: "status") }
|
||||
var filterName by remember(runStateName) { mutableStateOf(runStateName ?: "") }
|
||||
var filterMenuExpanded by remember { mutableStateOf(false) }
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -790,6 +911,34 @@ private fun TaskRunsTab(
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box {
|
||||
TextButton(onClick = { filterMenuExpanded = true }) {
|
||||
Text(if (filterType == "status") "Status" else "Outcome")
|
||||
}
|
||||
DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
|
||||
listOf("status", "outcome").forEach { type ->
|
||||
DropdownMenuItem(text = { Text(type) }, onClick = {
|
||||
filterType = type
|
||||
filterMenuExpanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = filterName,
|
||||
onValueChange = { filterName = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text(stringResource(R.string.kanban_run_state_filter)) },
|
||||
singleLine = true,
|
||||
)
|
||||
TextButton(onClick = { onFilter(filterType, filterName.trim()) }, enabled = filterName.isNotBlank()) {
|
||||
Text(stringResource(R.string.kanban_filter))
|
||||
}
|
||||
}
|
||||
if (runStateName != null) {
|
||||
TextButton(onClick = { onFilter(null, null) }) { Text(stringResource(R.string.kanban_all_runs)) }
|
||||
}
|
||||
}
|
||||
|
||||
if (runs.isEmpty()) {
|
||||
@@ -858,6 +1007,9 @@ private fun TaskRunsTab(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = { onInspect(run.id) }) {
|
||||
Text(stringResource(R.string.kanban_inspect_run))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1058,31 +1210,107 @@ private fun MetaRow(
|
||||
private fun TaskFilesTab(
|
||||
attachments: List<com.m57.hermescontrol.data.model.KanbanAttachment>,
|
||||
isUploading: Boolean,
|
||||
onUpload: (filename: String, mimeType: String, bytes: ByteArray) -> Unit,
|
||||
isDeleting: Boolean,
|
||||
board: String,
|
||||
taskId: String,
|
||||
viewModel: KanbanTaskViewModel,
|
||||
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 configuration = LocalConfiguration.current
|
||||
val resources = remember(context, configuration) { context.createConfigurationContext(configuration).resources }
|
||||
val scope = rememberCoroutineScope()
|
||||
var attachmentToDelete by remember { mutableStateOf<KanbanAttachment?>(null) }
|
||||
var attachmentToDownload by remember { mutableStateOf<KanbanAttachment?>(null) }
|
||||
val downloadLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
|
||||
val selected = attachmentToDownload
|
||||
attachmentToDownload = null
|
||||
if (uri != null && selected != null) {
|
||||
scope.launch {
|
||||
when (val result = viewModel.downloadAttachment(board, selected.id)) {
|
||||
is NetworkResult.Success -> {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
result.data.use { body ->
|
||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
body.byteStream().use { input -> input.copyTo(output) }
|
||||
} ?: error("Could not open the selected destination")
|
||||
}
|
||||
}
|
||||
viewModel.showToast(resources.getString(R.string.kanban_attachment_saved))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
viewModel.showToast(
|
||||
resources.getString(R.string.kanban_attachment_error, e.message ?: "Unknown error"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
viewModel.showToast(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
val bytes =
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: byteArrayOf()
|
||||
if (bytes.isNotEmpty()) {
|
||||
onUpload(filename, mimeType, bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
scope.launch {
|
||||
try {
|
||||
val part =
|
||||
withContext(Dispatchers.IO) {
|
||||
var filename = "attachment"
|
||||
var contentLength = -1L
|
||||
context.contentResolver
|
||||
.query(
|
||||
uri,
|
||||
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)?.use { c ->
|
||||
if (c.moveToFirst()) {
|
||||
c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let {
|
||||
filename = c.getString(it) ?: filename
|
||||
}
|
||||
c
|
||||
.getColumnIndex(
|
||||
OpenableColumns.SIZE,
|
||||
).takeIf { it >= 0 && !c.isNull(it) }
|
||||
?.let {
|
||||
contentLength = c.getLong(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
val body =
|
||||
StreamingUriRequestBody(
|
||||
context.contentResolver,
|
||||
uri,
|
||||
mime.toMediaTypeOrNull(),
|
||||
contentLength,
|
||||
)
|
||||
MultipartBody.Part.createFormData("file", filename, body)
|
||||
}
|
||||
viewModel.uploadAttachment(board, taskId, part)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
viewModel.showToast(
|
||||
resources.getString(
|
||||
R.string.kanban_attachment_error,
|
||||
e.message ?: "Unknown error",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(board, taskId) { viewModel.refreshAttachments(board, taskId) }
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(
|
||||
@@ -1096,7 +1324,7 @@ private fun TaskFilesTab(
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Button(
|
||||
onClick = { launcher.launch("*/*") },
|
||||
onClick = { launcher.launch(arrayOf("*/*")) },
|
||||
enabled = !isUploading,
|
||||
) {
|
||||
if (isUploading) {
|
||||
@@ -1136,22 +1364,55 @@ private fun TaskFilesTab(
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = att.filename.ifBlank { "File #${att.id}" },
|
||||
att.filename.ifBlank {
|
||||
"File #${att.id}"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
att.size?.let {
|
||||
Text(
|
||||
text = "${it / 1024} KB",
|
||||
"${it / 1024} KB",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = {
|
||||
attachmentToDownload = att
|
||||
downloadLauncher.launch(att.filename)
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Filled.Download,
|
||||
contentDescription = stringResource(R.string.kanban_download_attachment),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { attachmentToDelete = att }, enabled = !isDeleting) {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = stringResource(R.string.kanban_delete_attachment),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
attachmentToDelete?.let { attachment ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { attachmentToDelete = null },
|
||||
title = { Text(stringResource(R.string.kanban_delete_attachment)) },
|
||||
text = { Text(stringResource(R.string.kanban_delete_attachment_confirm)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
attachmentToDelete = null
|
||||
viewModel.deleteAttachment(board, taskId, attachment.id)
|
||||
}) { Text(stringResource(R.string.kanban_delete_attachment)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { attachmentToDelete = null }) { Text(stringResource(R.string.action_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.KanbanProfile
|
||||
import com.m57.hermescontrol.data.model.KanbanRun
|
||||
import com.m57.hermescontrol.data.model.KanbanRunInspection
|
||||
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
@@ -25,9 +27,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.ResponseBody
|
||||
|
||||
data class KanbanTaskUiState(
|
||||
val isLoading: Boolean = false,
|
||||
@@ -47,6 +48,15 @@ data class KanbanTaskUiState(
|
||||
val isReassigning: Boolean = false,
|
||||
val isUpdatingModel: Boolean = false,
|
||||
val isUploadingAttachment: Boolean = false,
|
||||
val isDeletingAttachment: Boolean = false,
|
||||
val isSpecifying: Boolean = false,
|
||||
val isLinking: Boolean = false,
|
||||
val isInspectingRun: Boolean = false,
|
||||
val isTerminatingRun: Boolean = false,
|
||||
val selectedRun: KanbanRun? = null,
|
||||
val runInspection: KanbanRunInspection? = null,
|
||||
val runStateType: String? = null,
|
||||
val runStateName: String? = null,
|
||||
)
|
||||
|
||||
class KanbanTaskViewModel(
|
||||
@@ -61,6 +71,7 @@ class KanbanTaskViewModel(
|
||||
private var eventsClient: KanbanEventsClient? = null
|
||||
private var eventsBoard: String? = null
|
||||
private var activeTaskId: String? = null
|
||||
private var activeBoard: String? = null
|
||||
private var currentLoadGen: Int = 0
|
||||
private var reloadJob: Job? = null
|
||||
private var logPollJob: Job? = null
|
||||
@@ -74,7 +85,18 @@ class KanbanTaskViewModel(
|
||||
reloadJob?.cancel()
|
||||
stopLogPolling()
|
||||
detailPollJob?.cancel()
|
||||
if (activeBoard != board || activeTaskId != taskId) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
runStateType = null,
|
||||
runStateName = null,
|
||||
selectedRun = null,
|
||||
runInspection = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
activeTaskId = taskId
|
||||
activeBoard = board
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
@@ -90,7 +112,15 @@ class KanbanTaskViewModel(
|
||||
startDetailPolling(board, taskId)
|
||||
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.getTask(taskId = taskId, board = board)) {
|
||||
when (
|
||||
val result =
|
||||
repository.getTask(
|
||||
taskId,
|
||||
board,
|
||||
_uiState.value.runStateType,
|
||||
_uiState.value.runStateName,
|
||||
)
|
||||
) {
|
||||
is NetworkResult.Success -> {
|
||||
if (gen != currentLoadGen) return@launch
|
||||
val taskData = result.data
|
||||
@@ -254,11 +284,13 @@ class KanbanTaskViewModel(
|
||||
taskId: String,
|
||||
) {
|
||||
val gen = currentLoadGen
|
||||
val result = repository.getTask(taskId = taskId, board = board)
|
||||
if (gen != currentLoadGen || taskId != activeTaskId) return
|
||||
val result = repository.getTask(taskId, board, _uiState.value.runStateType, _uiState.value.runStateName)
|
||||
if (gen != currentLoadGen || taskId != activeTaskId || board != activeBoard) return
|
||||
if (result is NetworkResult.Success) {
|
||||
val taskData = result.data
|
||||
_uiState.update { it.copy(detail = taskData) }
|
||||
_uiState.update { state ->
|
||||
state.copy(detail = taskData.copy(attachments = taskData.attachments ?: state.detail?.attachments))
|
||||
}
|
||||
if (taskData.task.status.equals("running", ignoreCase = true)) {
|
||||
if (logPollJob == null) startLogPolling(board, taskId)
|
||||
} else {
|
||||
@@ -521,18 +553,6 @@ class KanbanTaskViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -548,7 +568,10 @@ class KanbanTaskViewModel(
|
||||
toastMessage = "Attachment uploaded",
|
||||
)
|
||||
}
|
||||
loadTask(board, taskId)
|
||||
if (activeTaskId == taskId && activeBoard == board) {
|
||||
loadTaskSilently(board, taskId)
|
||||
refreshAttachments(board, taskId)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -563,6 +586,217 @@ class KanbanTaskViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshAttachments(
|
||||
board: String,
|
||||
taskId: String,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.listAttachments(taskId, board)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
if (activeTaskId != taskId || activeBoard != board) {
|
||||
state
|
||||
} else {
|
||||
state.copy(detail = state.detail?.copy(attachments = result.data.attachments))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = result.error.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAttachment(
|
||||
board: String,
|
||||
taskId: String,
|
||||
attachmentId: Long,
|
||||
) {
|
||||
if (_uiState.value.isDeletingAttachment) return
|
||||
_uiState.update { it.copy(isDeletingAttachment = true) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.deleteAttachment(attachmentId, board)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isDeletingAttachment = false,
|
||||
toastMessage = if (result.data.ok) "Attachment deleted" else "Attachment was not deleted",
|
||||
)
|
||||
}
|
||||
if (result.data.ok) refreshAttachments(board, taskId)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(isDeletingAttachment = false, toastMessage = result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadAttachment(
|
||||
board: String,
|
||||
attachmentId: Long,
|
||||
): NetworkResult<ResponseBody> = repository.downloadAttachment(attachmentId, board)
|
||||
|
||||
fun showToast(message: String) {
|
||||
_uiState.update { it.copy(toastMessage = message) }
|
||||
}
|
||||
|
||||
fun inspectRun(
|
||||
board: String,
|
||||
taskId: String,
|
||||
runId: Long,
|
||||
) {
|
||||
if (_uiState.value.isInspectingRun) return
|
||||
_uiState.update { it.copy(isInspectingRun = true, selectedRun = null, runInspection = null) }
|
||||
viewModelScope.launch {
|
||||
val run = repository.getRun(runId, board)
|
||||
val inspection = if (run is NetworkResult.Success) repository.inspectRun(runId, board) else null
|
||||
if (activeTaskId != null && (activeTaskId != taskId || activeBoard != board)) return@launch
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isInspectingRun = false,
|
||||
selectedRun = (run as? NetworkResult.Success)?.data?.run,
|
||||
runInspection = (inspection as? NetworkResult.Success)?.data,
|
||||
toastMessage =
|
||||
(run as? NetworkResult.Failure)?.error?.message
|
||||
?: (inspection as? NetworkResult.Failure)?.error?.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearRunInspection() {
|
||||
_uiState.update { it.copy(selectedRun = null, runInspection = null) }
|
||||
}
|
||||
|
||||
fun terminateRun(
|
||||
board: String,
|
||||
taskId: String,
|
||||
runId: Long,
|
||||
) {
|
||||
if (_uiState.value.isTerminatingRun) return
|
||||
_uiState.update { it.copy(isTerminatingRun = true) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.terminateRun(runId, board)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isTerminatingRun = false,
|
||||
toastMessage = if (result.data.ok) "Run terminated" else "Run was not terminated",
|
||||
)
|
||||
}
|
||||
if (result.data.ok && result.data.taskId == taskId &&
|
||||
activeTaskId == taskId && activeBoard == board
|
||||
) {
|
||||
val latestRun = repository.getRun(runId, board)
|
||||
_uiState.update { it.copy(selectedRun = (latestRun as? NetworkResult.Success)?.data?.run) }
|
||||
loadTaskSilently(board, taskId)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(isTerminatingRun = false, toastMessage = result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun specifyTask(
|
||||
board: String,
|
||||
taskId: String,
|
||||
) {
|
||||
if (_uiState.value.isSpecifying) return
|
||||
_uiState.update { it.copy(isSpecifying = true) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.specifyTask(taskId, board)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSpecifying = false,
|
||||
toastMessage =
|
||||
if (result.data.ok) {
|
||||
"Task specified"
|
||||
} else {
|
||||
result.data.reason
|
||||
?: "Specification failed"
|
||||
},
|
||||
)
|
||||
}
|
||||
if (result.data.ok && activeTaskId == taskId &&
|
||||
activeBoard == board
|
||||
) {
|
||||
loadTaskSilently(board, taskId)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(isSpecifying = false, toastMessage = result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createParentLink(
|
||||
board: String,
|
||||
childId: String,
|
||||
parentId: String,
|
||||
) {
|
||||
if (parentId.isBlank() || _uiState.value.isLinking) return
|
||||
_uiState.update { it.copy(isLinking = true) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.createTaskLink(board, parentId.trim(), childId)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLinking = false,
|
||||
toastMessage =
|
||||
when {
|
||||
!result.data.ok -> "Parent link was not created"
|
||||
result.data.gated -> "Linked; task gated by parent"
|
||||
else -> "Parent linked"
|
||||
},
|
||||
)
|
||||
}
|
||||
if (result.data.ok) {
|
||||
if (activeTaskId == childId && activeBoard == board) {
|
||||
loadTaskSilently(board, childId)
|
||||
} else if (activeTaskId == null) {
|
||||
loadTask(board, childId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(isLinking = false, toastMessage = result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setRunFilter(
|
||||
board: String,
|
||||
taskId: String,
|
||||
type: String?,
|
||||
name: String?,
|
||||
) {
|
||||
if ((type == null) != (name == null) || type !in setOf(null, "status", "outcome")) return
|
||||
_uiState.update { it.copy(runStateType = type, runStateName = name) }
|
||||
if (activeBoard == board && activeTaskId == taskId) {
|
||||
viewModelScope.launch { loadTaskSilently(board, taskId) }
|
||||
}
|
||||
}
|
||||
|
||||
public override fun onCleared() {
|
||||
stopLogPolling()
|
||||
detailPollJob?.cancel()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.m57.hermescontrol.ui.kanban
|
||||
|
||||
/** The dashboard currently sends dependency refusals as a 409 string detail, not JSON fields. */
|
||||
data class KanbanTransitionRefusal(
|
||||
val blockingParentIds: List<String>,
|
||||
val blockingParentStatuses: List<String>,
|
||||
)
|
||||
|
||||
fun kanbanTransitionRefusal(message: String): KanbanTransitionRefusal? {
|
||||
val (parentList, pattern) =
|
||||
when {
|
||||
message.contains("blocked by parent(s) not done") -> {
|
||||
message.substringAfter("blocked by parent(s) not done") to Regex("\\(([^(),\\s]+), status=([^()]*)\\)")
|
||||
}
|
||||
|
||||
message.contains("unsatisfied parent dependencies:") -> {
|
||||
message.substringAfter("unsatisfied parent dependencies:").substringBefore(';') to
|
||||
Regex("([^,;()\\s]+) \\(([^()]*)\\)")
|
||||
}
|
||||
|
||||
else -> {
|
||||
return null
|
||||
}
|
||||
}
|
||||
val blockers =
|
||||
pattern.findAll(parentList).map { match -> match.groupValues[1] to match.groupValues[2] }.toList()
|
||||
return blockers.takeIf { it.isNotEmpty() }?.let {
|
||||
KanbanTransitionRefusal(it.map { pair -> pair.first }, it.map { pair -> pair.second })
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody
|
||||
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||
import com.m57.hermescontrol.data.model.ExportBoardBody
|
||||
import com.m57.hermescontrol.data.model.ImportBoardBody
|
||||
import com.m57.hermescontrol.data.model.KanbanActiveWorker
|
||||
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||
@@ -26,6 +27,7 @@ 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.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||
@@ -64,8 +66,22 @@ data class KanbanUiState(
|
||||
val columnCollapseOverrides: Map<String, Boolean> = emptyMap(),
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
val selectedWorkflowTemplateId: String? = null,
|
||||
val selectedCurrentStepKey: String? = null,
|
||||
val activeWorkers: List<KanbanActiveWorker> = emptyList(),
|
||||
val isLoadingWorkers: Boolean = false,
|
||||
)
|
||||
|
||||
fun filterKanbanTasks(
|
||||
tasks: List<KanbanTask>,
|
||||
workflowTemplateId: String?,
|
||||
currentStepKey: String?,
|
||||
): List<KanbanTask> =
|
||||
tasks.filter { task ->
|
||||
(workflowTemplateId == null || task.workflowTemplateId.equals(workflowTemplateId, ignoreCase = true)) &&
|
||||
(currentStepKey == null || task.currentStepKey.equals(currentStepKey, ignoreCase = true))
|
||||
}
|
||||
|
||||
/**
|
||||
* Task actions mirroring the desktop kanban's transition-gated buttons.
|
||||
* Only moves the backend PATCH route accepts are offered: never `running`
|
||||
@@ -250,7 +266,15 @@ class KanbanViewModel(
|
||||
preferences.setSelectedBoard(endpoint, board.id)
|
||||
|
||||
val gen = ++currentLoadGen
|
||||
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
selectedBoard = board,
|
||||
isLoading = true,
|
||||
errorMessage = null,
|
||||
activeWorkers = emptyList(),
|
||||
isLoadingWorkers = false,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
loadBoardIntoState(board, gen)
|
||||
connectEvents(board)
|
||||
@@ -272,6 +296,54 @@ class KanbanViewModel(
|
||||
_uiState.update { it.copy(groupRunning = group) }
|
||||
}
|
||||
|
||||
fun setWorkflowFilters(
|
||||
workflowTemplateId: String?,
|
||||
currentStepKey: String?,
|
||||
) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
selectedWorkflowTemplateId = workflowTemplateId,
|
||||
selectedCurrentStepKey = currentStepKey,
|
||||
)
|
||||
}
|
||||
_uiState.value.selectedBoard?.let { board ->
|
||||
val gen = ++currentLoadGen
|
||||
viewModelScope.launch { loadBoardIntoState(board, gen) }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadActiveWorkers() {
|
||||
val board = _uiState.value.selectedBoard ?: return
|
||||
_uiState.update { it.copy(isLoadingWorkers = true) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.getActiveWorkers(board.id)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
if (it.selectedBoard?.id == board.id) {
|
||||
it.copy(activeWorkers = result.data.workers, isLoadingWorkers = false)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
if (it.selectedBoard?.id == board.id) {
|
||||
it.copy(
|
||||
isLoadingWorkers = false,
|
||||
activeWorkers = emptyList(),
|
||||
toastMessage = "Failed to load active workers: ${result.error.message}",
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleColumnCollapse(
|
||||
columnName: String,
|
||||
boardHasWork: Boolean,
|
||||
@@ -860,10 +932,22 @@ class KanbanViewModel(
|
||||
operatingTaskIds = state.operatingTaskIds - task.id,
|
||||
)
|
||||
}
|
||||
reloadBoardSilently()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
|
||||
val refusal =
|
||||
(result.error as? NetworkError.Http)
|
||||
?.takeIf { it.code == 409 }
|
||||
?.let { kanbanTransitionRefusal(it.message) }
|
||||
val message =
|
||||
if (refusal != null) {
|
||||
"Move blocked by parents ${refusal.blockingParentIds.joinToString()} " +
|
||||
"(${refusal.blockingParentStatuses.joinToString()}): ${result.error.message}"
|
||||
} else {
|
||||
"Move failed: ${result.error.message}"
|
||||
}
|
||||
revertTaskMove(task.id, originalStatus, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -945,6 +1029,8 @@ class KanbanViewModel(
|
||||
repository.getBoard(
|
||||
board = board.id,
|
||||
includeArchived = _uiState.value.includeArchived,
|
||||
workflowTemplateId = _uiState.value.selectedWorkflowTemplateId,
|
||||
currentStepKey = _uiState.value.selectedCurrentStepKey,
|
||||
)
|
||||
if (gen != currentLoadGen) return@launch
|
||||
if (result is NetworkResult.Success) {
|
||||
@@ -1010,6 +1096,8 @@ class KanbanViewModel(
|
||||
repository.getBoard(
|
||||
board = board.id,
|
||||
includeArchived = _uiState.value.includeArchived,
|
||||
workflowTemplateId = _uiState.value.selectedWorkflowTemplateId,
|
||||
currentStepKey = _uiState.value.selectedCurrentStepKey,
|
||||
)
|
||||
applyBoardResult(board, result, gen)
|
||||
}
|
||||
|
||||
@@ -34,10 +34,15 @@ fun KanbanFilterSheet(
|
||||
tenants: List<String>,
|
||||
selectedAssignee: String?,
|
||||
selectedTenant: String?,
|
||||
workflowTemplateIds: List<String>,
|
||||
currentStepKeys: List<String>,
|
||||
selectedWorkflowTemplateId: String?,
|
||||
selectedCurrentStepKey: String?,
|
||||
includeArchived: Boolean,
|
||||
groupRunning: Boolean,
|
||||
onSelectAssignee: (String?) -> Unit,
|
||||
onSelectTenant: (String?) -> Unit,
|
||||
onSelectWorkflowFilters: (String?, String?) -> Unit,
|
||||
onToggleIncludeArchived: (Boolean) -> Unit,
|
||||
onToggleGroupRunning: (Boolean) -> Unit,
|
||||
onClearFilters: () -> Unit,
|
||||
@@ -105,6 +110,29 @@ fun KanbanFilterSheet(
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text(
|
||||
"Workflow template",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
FilterChoiceRow("All templates", selectedWorkflowTemplateId == null) {
|
||||
onSelectWorkflowFilters(null, selectedCurrentStepKey)
|
||||
}
|
||||
workflowTemplateIds.forEach { value ->
|
||||
FilterChoiceRow(value, selectedWorkflowTemplateId == value) {
|
||||
onSelectWorkflowFilters(value, selectedCurrentStepKey)
|
||||
}
|
||||
}
|
||||
Text("Current step", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
FilterChoiceRow("All steps", selectedCurrentStepKey == null) {
|
||||
onSelectWorkflowFilters(selectedWorkflowTemplateId, null)
|
||||
}
|
||||
currentStepKeys.forEach { value ->
|
||||
FilterChoiceRow(value, selectedCurrentStepKey == value) {
|
||||
onSelectWorkflowFilters(selectedWorkflowTemplateId, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Assignee Filter
|
||||
Text(
|
||||
text = stringResource(R.string.kanban_assignee),
|
||||
@@ -192,3 +220,18 @@ fun KanbanFilterSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterChoiceRow(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp),
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user