feat(kanban): enforce board scoping and local selection in KanbanViewModel

This commit is contained in:
M57 committed 2026-09-14 22:23:01 +03:00
1 parent a486191d9d
commit c57e056d71
6 files changed
+900 -112

No files matched your search

@@ -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
}
}
@@ -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().getProfiles() }
}
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) }
}
}
@@ -2,17 +2,20 @@ package com.m57.hermescontrol.ui.kanban
import androidx.lifecycle.ViewModel
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.CreateTaskBody
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.model.UpdateTaskBody
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.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
@@ -20,7 +23,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class KanbanUiState(
val isLoading: Boolean = false,
@@ -113,7 +115,12 @@ fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
}
class KanbanViewModel(
private val repository: KanbanRepository = KanbanRepositoryImpl(),
private val preferences: KanbanPreferencesStore = InMemoryKanbanPreferencesStore(),
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
private val endpointProvider: () -> String = {
runCatching { AuthManager.endpointForBuild().baseUrl.toString() }.getOrDefault("default")
},
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanUiState())
@@ -122,58 +129,50 @@ class KanbanViewModel(
private var eventsClient: KanbanEventsClient? = null
private var eventsBoard: String? = null
private var reloadJob: Job? = null
private var currentLoadGen: Int = 0
fun loadBoards() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getKanbanBoards() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
val boards = data.boards.orEmpty()
_uiState.update { it.copy(isLoading = false, boards = boards) }
val currentSlug = data.current
val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull()
if (currentBoard != null) {
selectBoard(currentBoard)
val endpoint = endpointProvider()
val savedSlug = preferences.getSelectedBoard(endpoint)
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
when (val result = repository.getBoards()) {
is NetworkResult.Success -> {
val boards = result.data.boards
_uiState.update { it.copy(isLoading = false, boards = boards) }
val targetBoard =
boards.find { it.id == previouslySelectedId }
?: boards.find { it.id == result.data.current }
?: boards.firstOrNull()
if (targetBoard != null) {
selectBoard(targetBoard)
} else {
_uiState.update { it.copy(selectedBoard = null, columns = emptyList(), tasks = emptyList()) }
}
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: $errorMsg",
)
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
)
}
}
},
)
}
}
}
fun selectBoard(board: KanbanBoard) {
val current = _uiState.value.selectedBoard
if (current?.id == board.id) {
// Already-active board: reload its data and (re)connect the events
// stream WITHOUT the /switch round-trip — refresh and createTask
// re-select the same board and must not POST a redundant switch.
viewModelScope.launch { loadBoardIntoState(board) }
connectEvents(board)
return
}
val endpoint = endpointProvider()
preferences.setSelectedBoard(endpoint, board.id)
val gen = ++currentLoadGen
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
viewModelScope.launch {
val switchResult =
withContext(Dispatchers.IO) {
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)
loadBoardIntoState(board, gen)
connectEvents(board)
}
}
@@ -186,22 +185,18 @@ class KanbanViewModel(
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.createKanbanTask(
board = board.id,
task =
com.m57.hermescontrol.data.model.CreateTaskBody(
title = title,
body = description,
),
)
}
}
repository.createTask(
board = board.id,
body =
CreateTaskBody(
title = title,
body = description,
),
)
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Task created successfully") }
selectBoard(board)
reloadBoardSilently()
}
is NetworkResult.Failure -> {
@@ -216,6 +211,7 @@ class KanbanViewModel(
action: KanbanTaskAction,
summary: String? = null,
) {
val board = _uiState.value.selectedBoard ?: return
val originalStatus = task.status
// Optimistically update, desktop-style
_uiState.update { state ->
@@ -229,17 +225,12 @@ class KanbanViewModel(
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, body) }
}
UpdateTaskBody(
status = action.targetStatus,
summary = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
result = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
)
val result = repository.updateTask(task.id, board = board.id, body = body)
if (result is NetworkResult.Failure) {
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
}
@@ -300,47 +291,60 @@ class KanbanViewModel(
/** Re-fetch the current board without touching the loading spinner. */
private fun reloadBoardSilently() {
val board = _uiState.value.selectedBoard ?: return
val gen = currentLoadGen
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
}
val result = repository.getBoard(board = board.id)
if (gen != currentLoadGen) return@launch
if (result is NetworkResult.Success) {
val body = result.data
_uiState.update {
it.copy(
columns = body.columns,
tasks = body.columns.flatMap { it.tasks },
)
if (it.selectedBoard?.id != board.id) {
it
} else {
it.copy(
columns = body.columns,
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) {
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,
)
if (it.selectedBoard?.id != board.id) {
it
} else {
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}",
)
if (it.selectedBoard?.id != board.id) {
it
} else {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
)
}
}
}
}