feat(kanban): live board updates over plugin events WebSocket + kanban UI pass (#789)
* feat(kanban): live board updates over plugin events WebSocket (#775) Tail /api/plugins/kanban/events per board with cursor tracking and backoff reconnect; debounced REST reload on event batches (desktop's proven pattern — event payloads are partial updates). Board switch re-subscribes with a fresh stream. Reuses HermesWsClient's ticket refresh (now internal). Adds ServerEndpoint.webSocketUrl(path, ...) overload for non-chat WS endpoints. Kanban UI pass: add-task FAB (the AddTaskDialog was unreachable), live/offline status pill, assignee chip on task cards, 2-line description clamp, empty-column hint. Tests: KanbanEventsClient frame parsing, KanbanViewModel WS wiring (connect on load, debounced reload, board-switch reconnect, no-reconnect guard, onCleared), ServerEndpoint overload. * fix(kanban): create task response is wrapped {"task": ...} — read it as Unit Backend POST /api/plugins/kanban/tasks returns {"task": {...}} (wrapped), but the app declared Response<KanbanTask> — with ignoreUnknownKeys=true the wrapper key is skipped and the required id/title/status fields are missing, so deserialization threw and the create showed a failure toast while the task WAS created server-side (ghost tasks). The body is never consumed by the ViewModel, so Response<Unit> matches updateKanbanTask. FAB from the live-events PR (#775) made this latent bug reachable. Adds an E2e regression assertion for the create path. * feat(kanban): remove move left/right arrows — backend rejects the transitions The dashboard PATCH route's status whitelist omits 'review' (a first-class kanban_db status), so any move touching that column 400s, and reopen transitions (done->blocked) 409. Every task on the live board sits in 'done', making the arrows 100% failure. Removing them keeps the UI honest; tasks are managed from the desktop/CLI. Drops moveTask/revertTaskMove from the VM, the arrows from TaskCard, the now-dead updateKanbanTask API method and move strings; E2e test keeps load/create coverage. * feat(kanban): desktop-style task transitions — gated action sheet Mirrors the desktop kanban's move model: tap a card to open an action sheet with only the transitions the backend PATCH route accepts: - triage / ready from any status (direct write; unblock from blocked) - block only from running/ready, complete only from running/ready/blocked - archive from any non-archived status - never running (dispatcher-only, backend 400s) and never review (not in the dashboard whitelist) — the moves that made the old arrows lie Complete prompts for a mandatory completion summary (stored as result), matching the desktop; block/complete/archive ask for confirmation. Optimistic update with revert + toast on failure. Tests: transition-gating map, move PATCH body, failure revert, completion summary body; E2e kanban test covers a desktop-style todo->ready move. * ui(kanban): replace oversized FAB with compact top-bar add button The 56dp FAB floated over the board's bottom-right corner and covered a chunk of the last column. The add action now lives in the header row next to the live pill (input-top style), and the board's bottom contentPadding drops back to 16dp — no more reserved dead zone. * fix(kanban): mint an independent WS ticket — don't touch the chat's shared slot The dashboard WS ticket is single-use and consumed at handshake. Both the chat client and the kanban events client were refreshing into the SAME shared AuthManager token slot on their own schedules (the kanban client on every backoff reconnect, forever). Whichever client built its URL second grabbed the other's just-consumed ticket, got 1008, reconnected, and stole the other's next ticket — mutual starvation that left the app stuck at 'Reconnecting...' (repro on main: none, because main has one consumer). HermesWsClient now exposes mintWsTicket(): a ticket minted WITHOUT touching the shared slot (cookie-auth'd, same endpoint). The kanban events client uses its own ticket per connection; the chat WS keeps the shared slot. Refresh's HTTP-error -> status mapping is preserved via a small result holder. * fix(kanban): drop orphaned ko translations for removed move buttons The move arrows were removed earlier but values-ko still shipped content_desc_move_left/right — Android lint ExtraTranslation failed CI. * fix(ws): keep RECONNECTING on transient ticket-mint exceptions My refactor flattened the ticket-mint failure mapping: a network exception during POST /api/auth/ws-ticket was reported as a null HTTP code and mapped to DISCONNECTED, breaking the #740 contract that transient failures stay in RECONNECTING (and with it testGatedMode_transientTicketFailure- KeepsReconnectFlow). TicketRequestResult now carries an exception flag and refreshWsTicketIfNeeded maps it to RECONNECTING. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
37900a9af3
commit
44767fef4a
12 files changed
+1110
-127
No files matched your search
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.kanban
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -15,9 +16,11 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -39,6 +42,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -79,6 +83,9 @@ fun KanbanScreen(
|
||||
filteredTasks.groupBy { it.status.lowercase() }
|
||||
}
|
||||
var showAddTaskDialog by remember { mutableStateOf(false) }
|
||||
var taskForActions by remember { mutableStateOf<KanbanTask?>(null) }
|
||||
var confirmTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
|
||||
var summaryTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadBoards()
|
||||
@@ -150,9 +157,33 @@ fun KanbanScreen(
|
||||
Text(stringResource(R.string.kanban_no_columns))
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
LiveStatusPill(isLive = state.isLive)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(onClick = { showAddTaskDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = stringResource(R.string.kanban_add_task),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
start = 16.dp,
|
||||
top = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 16.dp,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(state.columns.size, key = { index ->
|
||||
@@ -161,8 +192,6 @@ fun KanbanScreen(
|
||||
val column = state.columns[columnIndex]
|
||||
val colName = column.name
|
||||
val colTasks = tasksByColumn[colName.lowercase()] ?: emptyList()
|
||||
val prevColumn = state.columns.getOrNull(columnIndex - 1)
|
||||
val nextColumn = state.columns.getOrNull(columnIndex + 1)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
@@ -179,23 +208,32 @@ fun KanbanScreen(
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(colTasks, key = { it.id }) { task ->
|
||||
TaskCard(
|
||||
task = task,
|
||||
onMoveLeft =
|
||||
prevColumn?.let {
|
||||
{ viewModel.moveTask(task, it.name) }
|
||||
},
|
||||
onMoveRight =
|
||||
nextColumn?.let {
|
||||
{ viewModel.moveTask(task, it.name) }
|
||||
},
|
||||
if (colTasks.isEmpty()) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.kanban_no_tasks),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(colTasks, key = { it.id }) { task ->
|
||||
TaskCard(
|
||||
task = task,
|
||||
onTaskClick = { taskForActions = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,6 +251,47 @@ fun KanbanScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
taskForActions?.let { task ->
|
||||
val actions = kanbanActionsForStatus(task.status)
|
||||
if (actions.isNotEmpty()) {
|
||||
TaskActionSheet(
|
||||
task = task,
|
||||
actions = actions,
|
||||
onAction = { action ->
|
||||
taskForActions = null
|
||||
when {
|
||||
action.needsSummary -> summaryTarget = task to action
|
||||
action.needsConfirm -> confirmTarget = task to action
|
||||
else -> viewModel.moveTask(task, action)
|
||||
}
|
||||
},
|
||||
onDismiss = { taskForActions = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
confirmTarget?.let { (task, action) ->
|
||||
ConfirmActionDialog(
|
||||
message = stringResource(action.confirmRes()),
|
||||
onConfirm = {
|
||||
confirmTarget = null
|
||||
if (action.needsSummary) {
|
||||
summaryTarget = task to action
|
||||
} else {
|
||||
viewModel.moveTask(task, action)
|
||||
}
|
||||
},
|
||||
onDismiss = { confirmTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
summaryTarget?.let { (task, action) ->
|
||||
CompleteTaskDialog(
|
||||
onConfirm = { summary -> viewModel.moveTask(task, action, summary) },
|
||||
onDismiss = { summaryTarget = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,48 +301,65 @@ fun KanbanScreen(
|
||||
@Composable
|
||||
fun TaskCard(
|
||||
task: KanbanTask,
|
||||
onMoveLeft: (() -> Unit)?,
|
||||
onMoveRight: (() -> Unit)?,
|
||||
onTaskClick: (KanbanTask) -> Unit,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Card(onClick = { onTaskClick(task) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(text = task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
task.description?.let {
|
||||
Text(text = it, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (onMoveLeft != null) {
|
||||
IconButton(onClick = onMoveLeft) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.content_desc_move_left),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
if (onMoveRight != null) {
|
||||
IconButton(onClick = onMoveRight) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = stringResource(R.string.content_desc_move_right),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(48.dp))
|
||||
task.assignedTo?.let { assignee ->
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = assignee,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LiveStatusPill(isLive: Boolean) {
|
||||
val color =
|
||||
if (isLive) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.background(color = color, shape = CircleShape),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(if (isLive) R.string.kanban_live else R.string.kanban_offline),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddTaskDialog(
|
||||
@@ -308,3 +404,111 @@ fun AddTaskDialog(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun KanbanTaskAction.labelRes(): Int =
|
||||
when (this) {
|
||||
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
|
||||
KanbanTaskAction.READY -> R.string.kanban_action_ready
|
||||
KanbanTaskAction.UNBLOCK -> R.string.kanban_action_unblock
|
||||
KanbanTaskAction.BLOCK -> R.string.kanban_action_block
|
||||
KanbanTaskAction.COMPLETE -> R.string.kanban_action_complete
|
||||
KanbanTaskAction.ARCHIVE -> R.string.kanban_action_archive
|
||||
}
|
||||
|
||||
private fun KanbanTaskAction.confirmRes(): Int =
|
||||
when (this) {
|
||||
KanbanTaskAction.BLOCK -> R.string.kanban_confirm_blocked
|
||||
KanbanTaskAction.COMPLETE -> R.string.kanban_confirm_done
|
||||
KanbanTaskAction.ARCHIVE -> R.string.kanban_confirm_archive
|
||||
else -> error("Action $this has no confirm message")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskActionSheet(
|
||||
task: KanbanTask,
|
||||
actions: List<KanbanTaskAction>,
|
||||
onAction: (KanbanTaskAction) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(task.title, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||
text = {
|
||||
Column {
|
||||
actions.forEach { action ->
|
||||
Button(
|
||||
onClick = { onAction(action) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Text(stringResource(action.labelRes()))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmActionDialog(
|
||||
message: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
Button(onClick = onConfirm) {
|
||||
Text(stringResource(R.string.action_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompleteTaskDialog(
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var summary by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.kanban_complete_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = summary,
|
||||
onValueChange = { summary = it },
|
||||
label = { Text(stringResource(R.string.kanban_complete_summary_label)) },
|
||||
supportingText = { Text(stringResource(R.string.kanban_complete_summary_hint)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onConfirm(summary.trim()) },
|
||||
enabled = summary.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(R.string.kanban_action_complete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,13 @@ import com.m57.hermescontrol.data.model.KanbanTask
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
|
||||
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.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -24,16 +28,74 @@ data class KanbanUiState(
|
||||
val selectedBoard: KanbanBoard? = null,
|
||||
val columns: List<KanbanColumn> = emptyList(),
|
||||
val tasks: List<KanbanTask> = emptyList(),
|
||||
val isLive: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
|
||||
class KanbanViewModel :
|
||||
ViewModel(),
|
||||
ToastHost {
|
||||
/**
|
||||
* Task actions mirroring the desktop kanban's transition-gated buttons.
|
||||
* Only moves the backend PATCH route accepts are offered: never `running`
|
||||
* (dispatcher-only, backend rejects with 400) and never `review` (not in
|
||||
* the dashboard whitelist).
|
||||
*/
|
||||
enum class KanbanTaskAction(
|
||||
val targetStatus: String,
|
||||
val needsConfirm: Boolean = false,
|
||||
val needsSummary: Boolean = false,
|
||||
) {
|
||||
TRIAGE(targetStatus = "triage"),
|
||||
READY(targetStatus = "ready"),
|
||||
UNBLOCK(targetStatus = "ready"),
|
||||
BLOCK(targetStatus = "blocked", needsConfirm = true),
|
||||
COMPLETE(targetStatus = "done", needsConfirm = true, needsSummary = true),
|
||||
ARCHIVE(targetStatus = "archived", needsConfirm = true),
|
||||
}
|
||||
|
||||
/** Desktop parity: which actions are valid from a given status. */
|
||||
fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
|
||||
when (status) {
|
||||
"triage" -> listOf(KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
||||
"todo" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
||||
"scheduled" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
||||
"ready" ->
|
||||
listOf(
|
||||
KanbanTaskAction.TRIAGE,
|
||||
KanbanTaskAction.BLOCK,
|
||||
KanbanTaskAction.COMPLETE,
|
||||
KanbanTaskAction.ARCHIVE,
|
||||
)
|
||||
"running" ->
|
||||
listOf(
|
||||
KanbanTaskAction.TRIAGE,
|
||||
KanbanTaskAction.READY,
|
||||
KanbanTaskAction.BLOCK,
|
||||
KanbanTaskAction.COMPLETE,
|
||||
KanbanTaskAction.ARCHIVE,
|
||||
)
|
||||
"blocked" ->
|
||||
listOf(
|
||||
KanbanTaskAction.TRIAGE,
|
||||
KanbanTaskAction.UNBLOCK,
|
||||
KanbanTaskAction.COMPLETE,
|
||||
KanbanTaskAction.ARCHIVE,
|
||||
)
|
||||
"review" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
||||
"done" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
||||
"archived" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
class KanbanViewModel(
|
||||
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
|
||||
) : ViewModel(), ToastHost {
|
||||
private val _uiState = MutableStateFlow(KanbanUiState())
|
||||
val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var eventsClient: KanbanEventsClient? = null
|
||||
private var eventsBoard: String? = null
|
||||
private var reloadJob: Job? = null
|
||||
|
||||
fun loadBoards() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getKanbanBoards() } },
|
||||
@@ -75,32 +137,8 @@ class KanbanViewModel :
|
||||
return@launch
|
||||
}
|
||||
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val body = result.data
|
||||
val allTasks = body.columns.flatMap { it.tasks }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
columns = body.columns,
|
||||
tasks = allTasks,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
loadBoardIntoState(board)
|
||||
connectEvents(board)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,28 +177,35 @@ class KanbanViewModel :
|
||||
|
||||
fun moveTask(
|
||||
task: KanbanTask,
|
||||
newStatus: String,
|
||||
action: KanbanTaskAction,
|
||||
summary: String? = null,
|
||||
) {
|
||||
val originalStatus = task.status
|
||||
val board = _uiState.value.selectedBoard ?: return
|
||||
|
||||
// Optimistically update
|
||||
// Optimistically update, desktop-style
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
tasks =
|
||||
state.tasks.map {
|
||||
if (it.id == task.id) it.copy(status = newStatus) else it
|
||||
if (it.id == task.id) it.copy(status = action.targetStatus) else it
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val body =
|
||||
buildMap<String, String?> {
|
||||
put("status", action.targetStatus)
|
||||
if (action.needsSummary && !summary.isNullOrBlank()) {
|
||||
put("result", summary)
|
||||
put("summary", summary)
|
||||
}
|
||||
}
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, mapOf("status" to newStatus)) }
|
||||
safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, body) }
|
||||
}
|
||||
if (result is NetworkResult.Failure) {
|
||||
revertTaskMove(task.id, originalStatus, "Failed to move task: ${result.error.message}")
|
||||
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +226,95 @@ class KanbanViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
eventsClient?.disconnect()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
// ── Live events (issue #775) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tail the kanban events WebSocket for [board]. The backend pins the board
|
||||
* at the WS handshake, so a board switch opens a fresh stream; re-selecting
|
||||
* the already-live board is a no-op (the REST load already refreshed it).
|
||||
*/
|
||||
private fun connectEvents(board: KanbanBoard) {
|
||||
if (eventsBoard == board.id && _uiState.value.isLive) return
|
||||
eventsBoard = board.id
|
||||
val client = eventsClient ?: eventsClientProvider().also { eventsClient = it }
|
||||
client.connect(
|
||||
scope = viewModelScope,
|
||||
board = board.id,
|
||||
onEvents = { scheduleBoardReload() },
|
||||
onStatus = { status ->
|
||||
_uiState.update { it.copy(isLive = status == KanbanLiveStatus.CONNECTED) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Debounced REST refresh after an events batch — mirrors the desktop pattern. */
|
||||
private fun scheduleBoardReload() {
|
||||
reloadJob?.cancel()
|
||||
reloadJob =
|
||||
viewModelScope.launch {
|
||||
delay(RELOAD_DEBOUNCE_MS)
|
||||
reloadBoardSilently()
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-fetch the current board without touching the loading spinner. */
|
||||
private fun reloadBoardSilently() {
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
|
||||
}
|
||||
if (result is NetworkResult.Success) {
|
||||
val body = result.data
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
columns = body.columns,
|
||||
tasks = body.columns.flatMap { it.tasks },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBoardIntoState(board: KanbanBoard) {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val body = result.data
|
||||
val allTasks = body.columns.flatMap { it.tasks }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
columns = body.columns,
|
||||
tasks = allTasks,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val RELOAD_DEBOUNCE_MS = 250L
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user