fix(kanban): address PR correctness and CI regressions (#1137)
This commit is contained in:
1 parent
190a93a4cf
commit
1735de7d9d
25 files changed
+766
-116
No files matched your search
@@ -1,9 +1,12 @@
|
|||||||
package com.m57.hermescontrol.ui.kanban
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertDoesNotExist
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import com.m57.hermescontrol.data.model.KanbanColumn
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||||
@@ -45,7 +48,7 @@ class KanbanScreenTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composeTestRule.onNodeWithText("Show archived").performClick()
|
composeTestRule.onNodeWithTag("kanban_show_archived").performClick()
|
||||||
assertTrue(toggled)
|
assertTrue(toggled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,18 +69,18 @@ class KanbanScreenTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand advanced options
|
// Expand advanced options
|
||||||
composeTestRule.onNodeWithText("Advanced Options").performClick()
|
composeTestRule.onNodeWithTag("kanban_advanced_options").performClick()
|
||||||
|
|
||||||
// By default scratch is selected, workspace path is not visible
|
// By default scratch is selected, workspace path is not visible
|
||||||
composeTestRule.onNodeWithText("Workspace Path Override").assertDoesNotExist()
|
composeTestRule.onNodeWithTag("kanban_workspace_path").assertDoesNotExist()
|
||||||
|
|
||||||
// Select Worktree
|
// Select Worktree
|
||||||
composeTestRule.onNodeWithText("Worktree").performClick()
|
composeTestRule.onNodeWithTag("kanban_workspace_kind_worktree").performClick()
|
||||||
composeTestRule.onNodeWithText("Workspace Path Override").assertIsDisplayed()
|
composeTestRule.onNodeWithTag("kanban_workspace_path").performScrollTo().assertIsDisplayed()
|
||||||
|
|
||||||
// Select Scratch again
|
// Select Scratch again
|
||||||
composeTestRule.onNodeWithText("Scratch (Default)").performClick()
|
composeTestRule.onNodeWithTag("kanban_workspace_kind_scratch").performClick()
|
||||||
composeTestRule.onNodeWithText("Workspace Path Override").assertDoesNotExist()
|
composeTestRule.onNodeWithTag("kanban_workspace_path").assertDoesNotExist()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ interface KanbanPreferencesStore {
|
|||||||
slug: String,
|
slug: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun clearSelectedBoard(endpoint: String)
|
||||||
|
|
||||||
fun getViewMode(): String
|
fun getViewMode(): String
|
||||||
|
|
||||||
fun setViewMode(mode: String)
|
fun setViewMode(mode: String)
|
||||||
@@ -45,6 +47,10 @@ class InMemoryKanbanPreferencesStore : KanbanPreferencesStore {
|
|||||||
selectedBoards[endpoint] = slug
|
selectedBoards[endpoint] = slug
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun clearSelectedBoard(endpoint: String) {
|
||||||
|
selectedBoards.remove(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
override fun getViewMode(): String = viewMode
|
override fun getViewMode(): String = viewMode
|
||||||
|
|
||||||
override fun setViewMode(mode: String) {
|
override fun setViewMode(mode: String) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
@@ -146,18 +147,18 @@ data class CreateTaskBody(
|
|||||||
val assignee: String? = null,
|
val assignee: String? = null,
|
||||||
val tenant: String? = null,
|
val tenant: String? = null,
|
||||||
val priority: Int = 0,
|
val priority: Int = 0,
|
||||||
val workspaceKind: String? = null,
|
@SerialName("workspace_kind") val workspaceKind: String? = null,
|
||||||
val workspacePath: String? = null,
|
@SerialName("workspace_path") val workspacePath: String? = null,
|
||||||
val parents: List<String> = emptyList(),
|
val parents: List<String> = emptyList(),
|
||||||
val triage: Boolean = false,
|
val triage: Boolean = false,
|
||||||
val maxRuntimeSeconds: Int? = null,
|
@SerialName("max_runtime_seconds") val maxRuntimeSeconds: Int? = null,
|
||||||
val skills: List<String>? = null,
|
val skills: List<String>? = null,
|
||||||
val goalMode: Boolean = false,
|
@SerialName("goal_mode") val goalMode: Boolean = false,
|
||||||
val goalMaxTurns: Int? = null,
|
@SerialName("goal_max_turns") val goalMaxTurns: Int? = null,
|
||||||
val modelOverride: String? = null,
|
@SerialName("model_override") val modelOverride: String? = null,
|
||||||
val providerOverride: String? = null,
|
@SerialName("provider_override") val providerOverride: String? = null,
|
||||||
val reasoningEffort: String? = null,
|
@SerialName("reasoning_effort") val reasoningEffort: String? = null,
|
||||||
val projectId: String? = null,
|
@SerialName("project_id") val projectId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -174,14 +175,14 @@ data class UpdateTaskBody(
|
|||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
val body: String? = null,
|
val body: String? = null,
|
||||||
val result: String? = null,
|
val result: String? = null,
|
||||||
val blockReason: String? = null,
|
@SerialName("block_reason") val blockReason: String? = null,
|
||||||
val summary: String? = null,
|
val summary: String? = null,
|
||||||
val metadata: Map<String, JsonElement>? = null,
|
val metadata: Map<String, JsonElement>? = null,
|
||||||
val modelOverride: String? = null,
|
@SerialName("model_override") val modelOverride: String? = null,
|
||||||
val providerOverride: String? = null,
|
@SerialName("provider_override") val providerOverride: String? = null,
|
||||||
val clearModelOverride: Boolean = false,
|
@SerialName("clear_model_override") val clearModelOverride: Boolean = false,
|
||||||
val reasoningEffort: String? = null,
|
@SerialName("reasoning_effort") val reasoningEffort: String? = null,
|
||||||
val clearReasoningEffort: Boolean = false,
|
@SerialName("clear_reasoning_effort") val clearReasoningEffort: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -199,12 +200,12 @@ data class BulkTasksBody(
|
|||||||
val result: String? = null,
|
val result: String? = null,
|
||||||
val summary: String? = null,
|
val summary: String? = null,
|
||||||
val metadata: Map<String, JsonElement>? = null,
|
val metadata: Map<String, JsonElement>? = null,
|
||||||
val reclaimFirst: Boolean = false,
|
@SerialName("reclaim_first") val reclaimFirst: Boolean = false,
|
||||||
val modelOverride: String? = null,
|
@SerialName("model_override") val modelOverride: String? = null,
|
||||||
val providerOverride: String? = null,
|
@SerialName("provider_override") val providerOverride: String? = null,
|
||||||
val clearModelOverride: Boolean = false,
|
@SerialName("clear_model_override") val clearModelOverride: Boolean = false,
|
||||||
val reasoningEffort: String? = null,
|
@SerialName("reasoning_effort") val reasoningEffort: String? = null,
|
||||||
val clearReasoningEffort: Boolean = false,
|
@SerialName("clear_reasoning_effort") val clearReasoningEffort: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ package com.m57.hermescontrol.ui.kanban
|
|||||||
|
|
||||||
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
|
|
||||||
|
private val ALL_REASONING_EFFORTS = listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||||
|
|
||||||
|
fun supportedKanbanReasoningEfforts(
|
||||||
|
supportsReasoning: Boolean,
|
||||||
|
canDisableReasoning: Boolean,
|
||||||
|
): List<String> =
|
||||||
|
if (!supportsReasoning) {
|
||||||
|
listOf("")
|
||||||
|
} else if (canDisableReasoning) {
|
||||||
|
ALL_REASONING_EFFORTS
|
||||||
|
} else {
|
||||||
|
ALL_REASONING_EFFORTS.filterNot { it == "none" }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task model override matching desktop's TaskModelOverride contract:
|
* Task model override matching desktop's TaskModelOverride contract:
|
||||||
* - model: model name or ID
|
* - model: model name or ID
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.m57.hermescontrol.ui.kanban
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import com.m57.hermescontrol.data.model.KanbanTask
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
enum class KanbanArcState {
|
enum class KanbanArcState {
|
||||||
QUEUED,
|
QUEUED,
|
||||||
@@ -48,3 +51,16 @@ object KanbanRuntimeHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One coarse clock per board screen, rather than one coroutine per task card. */
|
||||||
|
@Composable
|
||||||
|
fun rememberKanbanNowSeconds(enabled: Boolean): Long =
|
||||||
|
produceState(
|
||||||
|
initialValue = System.currentTimeMillis() / 1000L,
|
||||||
|
key1 = enabled,
|
||||||
|
) {
|
||||||
|
while (enabled) {
|
||||||
|
delay(5_000L)
|
||||||
|
value = System.currentTimeMillis() / 1000L
|
||||||
|
}
|
||||||
|
}.value
|
||||||
@@ -89,6 +89,7 @@ import com.m57.hermescontrol.ui.kanban.components.KanbanCreateTaskDialog
|
|||||||
import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet
|
import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet
|
||||||
import com.m57.hermescontrol.ui.kanban.components.KanbanOrchestrationDialog
|
import com.m57.hermescontrol.ui.kanban.components.KanbanOrchestrationDialog
|
||||||
import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard
|
import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard
|
||||||
|
import com.m57.hermescontrol.util.StreamingUriRequestBody
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -107,6 +108,7 @@ fun KanbanScreen(
|
|||||||
viewModel: KanbanViewModel = viewModel { KanbanViewModel() },
|
viewModel: KanbanViewModel = viewModel { KanbanViewModel() },
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val nowSeconds = rememberKanbanNowSeconds(state.tasks.any { it.status.equals("running", ignoreCase = true) })
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -168,10 +170,12 @@ fun KanbanScreen(
|
|||||||
try {
|
try {
|
||||||
val cachedFile = File(pendingExportPath!!)
|
val cachedFile = File(pendingExportPath!!)
|
||||||
if (cachedFile.exists()) {
|
if (cachedFile.exists()) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||||
cachedFile.inputStream().use { input ->
|
cachedFile.inputStream().use { input ->
|
||||||
input.copyTo(output)
|
input.copyTo(output)
|
||||||
}
|
}
|
||||||
|
} ?: error("Unable to open selected destination")
|
||||||
}
|
}
|
||||||
viewModel.showToast("Board exported successfully")
|
viewModel.showToast("Board exported successfully")
|
||||||
}
|
}
|
||||||
@@ -199,18 +203,33 @@ fun KanbanScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)
|
val contentLength =
|
||||||
if (inputStream != null) {
|
context.contentResolver
|
||||||
val bytes = withContext(Dispatchers.IO) { inputStream.use { it.readBytes() } }
|
.query(
|
||||||
|
uri,
|
||||||
|
arrayOf(OpenableColumns.SIZE),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { cursor ->
|
||||||
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (sizeIndex != -1 && cursor.moveToFirst() && !cursor.isNull(sizeIndex)) {
|
||||||
|
cursor.getLong(sizeIndex).takeIf { it >= 0L } ?: -1L
|
||||||
|
} else {
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
} ?: -1L
|
||||||
val targetPath = "kanban-imports/$fileName"
|
val targetPath = "kanban-imports/$fileName"
|
||||||
val pathBody = targetPath.toRequestBody("text/plain".toMediaTypeOrNull())
|
val pathBody = targetPath.toRequestBody("text/plain".toMediaTypeOrNull())
|
||||||
val overwriteBody = "true".toRequestBody("text/plain".toMediaTypeOrNull())
|
val overwriteBody = "true".toRequestBody("text/plain".toMediaTypeOrNull())
|
||||||
val filePart =
|
val requestBody =
|
||||||
MultipartBody.Part.createFormData(
|
StreamingUriRequestBody(
|
||||||
"file",
|
contentResolver = context.contentResolver,
|
||||||
fileName,
|
uri = uri,
|
||||||
bytes.toRequestBody("application/gzip".toMediaTypeOrNull()),
|
contentType = "application/gzip".toMediaTypeOrNull(),
|
||||||
|
contentLength = contentLength,
|
||||||
)
|
)
|
||||||
|
val filePart = MultipartBody.Part.createFormData("file", fileName, requestBody)
|
||||||
val uploadResult =
|
val uploadResult =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
safeApiCall {
|
safeApiCall {
|
||||||
@@ -244,7 +263,6 @@ fun KanbanScreen(
|
|||||||
viewModel.showToast("Failed to upload archive: ${uploadResult.error.message}")
|
viewModel.showToast("Failed to upload archive: ${uploadResult.error.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
viewModel.showToast("Import failed: ${e.message}")
|
viewModel.showToast("Import failed: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -270,14 +288,14 @@ fun KanbanScreen(
|
|||||||
IconButton(onClick = { showFilterSheet = true }) {
|
IconButton(onClick = { showFilterSheet = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.FilterList,
|
imageVector = Icons.Default.FilterList,
|
||||||
contentDescription = "Filter",
|
contentDescription = stringResource(R.string.kanban_filter),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Box {
|
Box {
|
||||||
IconButton(onClick = { showBoardMenu = true }) {
|
IconButton(onClick = { showBoardMenu = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.MoreVert,
|
imageVector = Icons.Default.MoreVert,
|
||||||
contentDescription = "Board options",
|
contentDescription = stringResource(R.string.kanban_board_options),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
@@ -285,7 +303,7 @@ fun KanbanScreen(
|
|||||||
onDismissRequest = { showBoardMenu = false },
|
onDismissRequest = { showBoardMenu = false },
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("New Board") },
|
text = { Text(stringResource(R.string.kanban_new_board)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
showBoardMenu = false
|
showBoardMenu = false
|
||||||
showCreateBoardDialog = true
|
showCreateBoardDialog = true
|
||||||
@@ -439,7 +457,7 @@ fun KanbanScreen(
|
|||||||
IconButton(onClick = { showFilterSheet = true }) {
|
IconButton(onClick = { showFilterSheet = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.FilterList,
|
imageVector = Icons.Default.FilterList,
|
||||||
contentDescription = "Filters",
|
contentDescription = stringResource(R.string.kanban_filters),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -695,6 +713,7 @@ fun KanbanScreen(
|
|||||||
items(group.tasks, key = { it.id }) { task ->
|
items(group.tasks, key = { it.id }) { task ->
|
||||||
KanbanTaskCard(
|
KanbanTaskCard(
|
||||||
task = task,
|
task = task,
|
||||||
|
nowSeconds = nowSeconds,
|
||||||
defaultAssignee =
|
defaultAssignee =
|
||||||
state.orchestration
|
state.orchestration
|
||||||
?.resolvedDefaultAssignee,
|
?.resolvedDefaultAssignee,
|
||||||
@@ -743,6 +762,7 @@ fun KanbanScreen(
|
|||||||
items(colTasks, key = { it.id }) { task ->
|
items(colTasks, key = { it.id }) { task ->
|
||||||
KanbanTaskCard(
|
KanbanTaskCard(
|
||||||
task = task,
|
task = task,
|
||||||
|
nowSeconds = nowSeconds,
|
||||||
defaultAssignee =
|
defaultAssignee =
|
||||||
state.orchestration
|
state.orchestration
|
||||||
?.resolvedDefaultAssignee,
|
?.resolvedDefaultAssignee,
|
||||||
@@ -931,13 +951,19 @@ fun KanbanScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(if (selectedTaskIds.size == filteredTasks.size) "Clear" else "All")
|
Text(
|
||||||
|
if (selectedTaskIds.size == filteredTasks.size) {
|
||||||
|
stringResource(R.string.kanban_clear_selection)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.kanban_select_all)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = { showBulkMoveDialog = true },
|
onClick = { showBulkMoveDialog = true },
|
||||||
enabled = selectedTaskIds.isNotEmpty(),
|
enabled = selectedTaskIds.isNotEmpty(),
|
||||||
) {
|
) {
|
||||||
Text("Move")
|
Text(stringResource(R.string.kanban_move))
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = { showBulkAssignDialog = true },
|
onClick = { showBulkAssignDialog = true },
|
||||||
@@ -965,7 +991,7 @@ fun KanbanScreen(
|
|||||||
},
|
},
|
||||||
enabled = selectedTaskIds.isNotEmpty(),
|
enabled = selectedTaskIds.isNotEmpty(),
|
||||||
) {
|
) {
|
||||||
Text("Archive")
|
Text(stringResource(R.string.kanban_action_archive))
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = { showBulkDeleteDialog = true },
|
onClick = { showBulkDeleteDialog = true },
|
||||||
@@ -983,7 +1009,10 @@ fun KanbanScreen(
|
|||||||
selectedTaskIds = emptySet()
|
selectedTaskIds = emptySet()
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Close, contentDescription = "Close multi-select")
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.kanban_close_multi_select),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -997,7 +1026,7 @@ fun KanbanScreen(
|
|||||||
}
|
}
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showBulkMoveDialog = false },
|
onDismissRequest = { showBulkMoveDialog = false },
|
||||||
title = { Text("Move ${selectedTaskIds.size} tasks") },
|
title = { Text(stringResource(R.string.kanban_move_tasks, selectedTaskIds.size)) },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
writableMoveColumns.forEach { col ->
|
writableMoveColumns.forEach { col ->
|
||||||
@@ -1097,13 +1126,13 @@ fun KanbanScreen(
|
|||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showCreateBoardDialog = false },
|
onDismissRequest = { showCreateBoardDialog = false },
|
||||||
title = { Text("Create New Board") },
|
title = { Text(stringResource(R.string.kanban_create_new_board)) },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = boardSlug,
|
value = boardSlug,
|
||||||
onValueChange = { boardSlug = it },
|
onValueChange = { boardSlug = it },
|
||||||
label = { Text("Slug (id)") },
|
label = { Text(stringResource(R.string.kanban_slug)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
@@ -1111,7 +1140,7 @@ fun KanbanScreen(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = boardName,
|
value = boardName,
|
||||||
onValueChange = { boardName = it },
|
onValueChange = { boardName = it },
|
||||||
label = { Text("Display Name") },
|
label = { Text(stringResource(R.string.kanban_display_name)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
@@ -1119,7 +1148,7 @@ fun KanbanScreen(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = boardDesc,
|
value = boardDesc,
|
||||||
onValueChange = { boardDesc = it },
|
onValueChange = { boardDesc = it },
|
||||||
label = { Text("Description") },
|
label = { Text(stringResource(R.string.kanban_description)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -1184,7 +1213,7 @@ fun KanbanScreen(
|
|||||||
},
|
},
|
||||||
enabled = boardSlug.isNotBlank(),
|
enabled = boardSlug.isNotBlank(),
|
||||||
) {
|
) {
|
||||||
Text("Create")
|
Text(stringResource(R.string.action_add))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
@@ -1333,16 +1362,16 @@ private fun ConfirmActionDialog(
|
|||||||
) {
|
) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Confirm action") },
|
title = { Text(stringResource(R.string.kanban_confirm_action)) },
|
||||||
text = { Text(message) },
|
text = { Text(message) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Button(onClick = onConfirm) {
|
Button(onClick = onConfirm) {
|
||||||
Text("Confirm")
|
Text(stringResource(R.string.action_confirm))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text("Cancel")
|
Text(stringResource(R.string.action_cancel))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1376,7 +1405,7 @@ private fun CompleteTaskDialog(
|
|||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text("Cancel")
|
Text(stringResource(R.string.action_cancel))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ private fun TaskOverviewTab(
|
|||||||
onClick = onReclaim,
|
onClick = onReclaim,
|
||||||
shape = RoundedCornerShape(6.dp),
|
shape = RoundedCornerShape(6.dp),
|
||||||
) {
|
) {
|
||||||
Text("Reclaim task")
|
Text(stringResource(R.string.kanban_reclaim_task))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,7 +400,7 @@ private fun TaskOverviewTab(
|
|||||||
onDismissRequest = { assigneeMenuExpanded = false },
|
onDismissRequest = { assigneeMenuExpanded = false },
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Unassigned") },
|
text = { Text(stringResource(R.string.kanban_assignee_unassigned)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
onReassign(null)
|
onReassign(null)
|
||||||
assigneeMenuExpanded = false
|
assigneeMenuExpanded = false
|
||||||
@@ -591,7 +591,7 @@ private fun TaskOverviewTab(
|
|||||||
IconButton(onClick = { isEditingDescription = true }) {
|
IconButton(onClick = { isEditingDescription = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Edit,
|
Icons.Filled.Edit,
|
||||||
contentDescription = "Edit Description",
|
contentDescription = stringResource(R.string.kanban_edit_description),
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -608,14 +608,14 @@ private fun TaskOverviewTab(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||||
OutlinedButton(onClick = { isEditingDescription = false }) {
|
OutlinedButton(onClick = { isEditingDescription = false }) {
|
||||||
Text("Cancel")
|
Text(stringResource(R.string.action_cancel))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
onSaveDescription(descriptionDraft)
|
onSaveDescription(descriptionDraft)
|
||||||
isEditingDescription = false
|
isEditingDescription = false
|
||||||
}) {
|
}) {
|
||||||
Text("Save")
|
Text(stringResource(R.string.action_save))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -676,7 +676,7 @@ private fun TaskDiscussionTab(
|
|||||||
) {
|
) {
|
||||||
if (comments.isEmpty()) {
|
if (comments.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text("No comments yet.", style = MaterialTheme.typography.bodySmall)
|
Text(stringResource(R.string.kanban_no_comments), style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(comments, key = { it.id }) { comment ->
|
items(comments, key = { it.id }) { comment ->
|
||||||
@@ -712,7 +712,7 @@ private fun TaskDiscussionTab(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = newCommentText,
|
value = newCommentText,
|
||||||
onValueChange = { newCommentText = it },
|
onValueChange = { newCommentText = it },
|
||||||
placeholder = { Text("Write a comment...") },
|
placeholder = { Text(stringResource(R.string.kanban_write_comment)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
enabled = !isSubmitting,
|
enabled = !isSubmitting,
|
||||||
@@ -733,7 +733,7 @@ private fun TaskDiscussionTab(
|
|||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp))
|
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text("Note & Requeue")
|
Text(stringResource(R.string.kanban_note_requeue))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
@@ -754,12 +754,19 @@ private fun TaskDiscussionTab(
|
|||||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp))
|
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
Text("Comment")
|
Text(stringResource(R.string.kanban_comment))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatRunDuration(durationSeconds: Long): String =
|
||||||
|
when {
|
||||||
|
durationSeconds < 60L -> "${durationSeconds}s"
|
||||||
|
durationSeconds < 3600L -> "${durationSeconds / 60L}m"
|
||||||
|
else -> "${durationSeconds / 3600L}h"
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TaskRunsTab(
|
private fun TaskRunsTab(
|
||||||
runs: List<com.m57.hermescontrol.data.model.KanbanRun>,
|
runs: List<com.m57.hermescontrol.data.model.KanbanRun>,
|
||||||
@@ -771,12 +778,16 @@ private fun TaskRunsTab(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Text("Execution Runs", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
Text(
|
||||||
|
stringResource(R.string.kanban_execution_runs),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runs.isEmpty()) {
|
if (runs.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text("No run history available.", style = MaterialTheme.typography.bodySmall)
|
Text(stringResource(R.string.kanban_no_run_history), style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(runs, key = { it.id }) { run ->
|
items(runs, key = { it.id }) { run ->
|
||||||
@@ -791,7 +802,7 @@ private fun TaskRunsTab(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Run #${run.id}",
|
text = stringResource(R.string.kanban_run_number, run.id),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
@@ -806,15 +817,40 @@ private fun TaskRunsTab(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
run.profile?.let { Text("Profile: $it", style = MaterialTheme.typography.bodySmall) }
|
run.profile?.let {
|
||||||
run.outcome?.let { Text("Outcome: $it", style = MaterialTheme.typography.bodySmall) }
|
Text(
|
||||||
|
stringResource(R.string.kanban_run_profile, it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
run.outcome?.let {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.kanban_run_outcome, it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (run.startedAt != null && run.endedAt != null && run.endedAt >= run.startedAt) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.kanban_run_duration,
|
||||||
|
formatRunDuration(run.endedAt - run.startedAt),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
run.error?.let {
|
run.error?.let {
|
||||||
Text(
|
Text(
|
||||||
"Error: $it",
|
stringResource(R.string.kanban_run_error, it),
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
run.summary?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.kanban_run_summary, it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -823,7 +859,11 @@ private fun TaskRunsTab(
|
|||||||
if (workerLog != null && workerLog.exists && workerLog.content.isNotBlank()) {
|
if (workerLog != null && workerLog.exists && workerLog.content.isNotBlank()) {
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text("Worker Log Output", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
Text(
|
||||||
|
stringResource(R.string.kanban_worker_log),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
@@ -852,12 +892,16 @@ private fun TaskActivityTab(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Text("Event Timeline", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
Text(
|
||||||
|
stringResource(R.string.kanban_event_timeline),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (events.isEmpty()) {
|
if (events.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text("No activity events.", style = MaterialTheme.typography.bodySmall)
|
Text(stringResource(R.string.kanban_no_activity), style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(events, key = { it.id }) { event ->
|
items(events, key = { it.id }) { event ->
|
||||||
@@ -951,7 +995,11 @@ private fun TaskFilesTab(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text("Attachments", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
Text(
|
||||||
|
stringResource(R.string.kanban_attachments),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = { launcher.launch("*/*") },
|
onClick = { launcher.launch("*/*") },
|
||||||
enabled = !isUploading,
|
enabled = !isUploading,
|
||||||
@@ -960,7 +1008,7 @@ private fun TaskFilesTab(
|
|||||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
Text("Upload")
|
Text(stringResource(R.string.kanban_upload))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,7 +1020,7 @@ private fun TaskFilesTab(
|
|||||||
) {
|
) {
|
||||||
if (attachments.isEmpty()) {
|
if (attachments.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text("No attachments uploaded.", style = MaterialTheme.typography.bodySmall)
|
Text(stringResource(R.string.kanban_no_attachments), style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(attachments, key = { it.id }) { att ->
|
items(attachments, key = { it.id }) { att ->
|
||||||
@@ -993,7 +1041,7 @@ private fun TaskFilesTab(
|
|||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = att.filename?.ifBlank { "File #${att.id}" } ?: "File #${att.id}",
|
text = att.filename.ifBlank { "File #${att.id}" },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ data class KanbanTaskUiState(
|
|||||||
class KanbanTaskViewModel(
|
class KanbanTaskViewModel(
|
||||||
private val repository: KanbanRepository = KanbanRepositoryImpl(),
|
private val repository: KanbanRepository = KanbanRepositoryImpl(),
|
||||||
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
|
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
|
||||||
|
private val enableFallbackPolling: Boolean = true,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(KanbanTaskUiState())
|
private val _uiState = MutableStateFlow(KanbanTaskUiState())
|
||||||
@@ -63,17 +64,30 @@ class KanbanTaskViewModel(
|
|||||||
private var currentLoadGen: Int = 0
|
private var currentLoadGen: Int = 0
|
||||||
private var reloadJob: Job? = null
|
private var reloadJob: Job? = null
|
||||||
private var logPollJob: Job? = null
|
private var logPollJob: Job? = null
|
||||||
|
private var detailPollJob: Job? = null
|
||||||
|
|
||||||
fun loadTask(
|
fun loadTask(
|
||||||
board: String,
|
board: String,
|
||||||
taskId: String,
|
taskId: String,
|
||||||
) {
|
) {
|
||||||
val gen = ++currentLoadGen
|
val gen = ++currentLoadGen
|
||||||
|
reloadJob?.cancel()
|
||||||
|
stopLogPolling()
|
||||||
|
detailPollJob?.cancel()
|
||||||
activeTaskId = taskId
|
activeTaskId = taskId
|
||||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = true,
|
||||||
|
detail = null,
|
||||||
|
workerLog = null,
|
||||||
|
estimate = null,
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
loadProfiles()
|
loadProfiles()
|
||||||
loadModelOptions()
|
loadModelOptions()
|
||||||
connectEvents(board)
|
connectEvents(board)
|
||||||
|
startDetailPolling(board, taskId)
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val result = repository.getTask(taskId = taskId, board = board)) {
|
when (val result = repository.getTask(taskId = taskId, board = board)) {
|
||||||
@@ -191,8 +205,23 @@ class KanbanTaskViewModel(
|
|||||||
logPollJob = null
|
logPollJob = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startDetailPolling(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
) {
|
||||||
|
if (!enableFallbackPolling) return
|
||||||
|
detailPollJob =
|
||||||
|
viewModelScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
delay(DETAIL_POLL_INTERVAL_MS)
|
||||||
|
loadTaskSilently(board, taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun connectEvents(board: String) {
|
private fun connectEvents(board: String) {
|
||||||
if (eventsBoard == board) return
|
if (eventsBoard == board) return
|
||||||
|
eventsClient?.disconnect()
|
||||||
eventsBoard = board
|
eventsBoard = board
|
||||||
val client = eventsClient ?: eventsClientProvider().also { eventsClient = it }
|
val client = eventsClient ?: eventsClientProvider().also { eventsClient = it }
|
||||||
client.connect(
|
client.connect(
|
||||||
@@ -536,6 +565,8 @@ class KanbanTaskViewModel(
|
|||||||
|
|
||||||
public override fun onCleared() {
|
public override fun onCleared() {
|
||||||
stopLogPolling()
|
stopLogPolling()
|
||||||
|
detailPollJob?.cancel()
|
||||||
|
reloadJob?.cancel()
|
||||||
eventsClient?.disconnect()
|
eventsClient?.disconnect()
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
@@ -547,5 +578,6 @@ class KanbanTaskViewModel(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val RELOAD_DEBOUNCE_MS = 250L
|
const val RELOAD_DEBOUNCE_MS = 250L
|
||||||
const val POLL_INTERVAL_MS = 3000L
|
const val POLL_INTERVAL_MS = 3000L
|
||||||
|
const val DETAIL_POLL_INTERVAL_MS = 30_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,13 +174,14 @@ class KanbanViewModel(
|
|||||||
loadOrchestration()
|
loadOrchestration()
|
||||||
loadModelOptions()
|
loadModelOptions()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val result = repository.getBoards(includeArchived = _uiState.value.includeArchived)) {
|
when (val result = repository.getBoards(includeArchived = false)) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val boards = result.data.boards
|
val boards = result.data.boards
|
||||||
_uiState.update { it.copy(isLoading = false, boards = boards) }
|
_uiState.update { it.copy(isLoading = false, boards = boards) }
|
||||||
val targetBoard =
|
val targetBoard =
|
||||||
boards.find { it.id == previouslySelectedId }
|
boards.find { it.id == previouslySelectedId }
|
||||||
?: boards.find { it.id == result.data.current }
|
?: boards.find { it.id == result.data.current }
|
||||||
|
?: boards.find { it.id.equals("default", ignoreCase = true) }
|
||||||
?: boards.firstOrNull()
|
?: boards.firstOrNull()
|
||||||
if (targetBoard != null) {
|
if (targetBoard != null) {
|
||||||
selectBoard(targetBoard)
|
selectBoard(targetBoard)
|
||||||
@@ -531,7 +532,9 @@ class KanbanViewModel(
|
|||||||
_uiState.update { it.copy(isLoading = true) }
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
when (val res = repository.deleteBoard(slug, delete = false)) {
|
when (val res = repository.deleteBoard(slug, delete = false)) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Board archived") }
|
val endpoint = endpointProvider()
|
||||||
|
preferences.clearSelectedBoard(endpoint)
|
||||||
|
_uiState.update { it.copy(selectedBoard = null, toastMessage = "Board archived") }
|
||||||
loadBoards()
|
loadBoards()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,13 +655,19 @@ class KanbanViewModel(
|
|||||||
val board = _uiState.value.selectedBoard ?: return
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true) }
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, assignee = assignee))) {
|
val body =
|
||||||
|
BulkTasksBody(
|
||||||
|
ids = taskIds,
|
||||||
|
assignee = assignee ?: "",
|
||||||
|
reclaimFirst = true,
|
||||||
|
)
|
||||||
|
when (val res = repository.bulkTasks(board.id, body)) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val results = res.data.results
|
val results = res.data.results
|
||||||
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
|
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
|
||||||
val failed = results.filter { !it.ok }.map { it.id }.toSet()
|
val failed = results.filter { !it.ok }.map { it.id }.toSet()
|
||||||
val firstError = results.firstOrNull { !it.ok }?.error
|
val firstError = results.firstOrNull { !it.ok }?.error
|
||||||
val actionName = if (assignee != null) "Assigned" else "Unassigned"
|
val actionName = if (!assignee.isNullOrBlank()) "Assigned" else "Unassigned"
|
||||||
val msg =
|
val msg =
|
||||||
when {
|
when {
|
||||||
failed.isEmpty() -> "$actionName ${succeeded.size} tasks"
|
failed.isEmpty() -> "$actionName ${succeeded.size} tasks"
|
||||||
|
|||||||
+4
-4
@@ -61,7 +61,7 @@ fun KanbanBoardSettingsDialog(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
label = { Text("Board Name") },
|
label = { Text(stringResource(R.string.kanban_board_name)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
@@ -69,7 +69,7 @@ fun KanbanBoardSettingsDialog(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = description,
|
value = description,
|
||||||
onValueChange = { description = it },
|
onValueChange = { description = it },
|
||||||
label = { Text("Description") },
|
label = { Text(stringResource(R.string.kanban_description)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
minLines = 2,
|
minLines = 2,
|
||||||
)
|
)
|
||||||
@@ -121,8 +121,8 @@ fun KanbanBoardSettingsDialog(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = defaultWorkdir,
|
value = defaultWorkdir,
|
||||||
onValueChange = { defaultWorkdir = it },
|
onValueChange = { defaultWorkdir = it },
|
||||||
label = { Text("Default Workdir") },
|
label = { Text(stringResource(R.string.kanban_default_workdir)) },
|
||||||
placeholder = { Text("e.g. /home/user/project") },
|
placeholder = { Text(stringResource(R.string.kanban_workdir_example)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|||||||
+16
-7
@@ -43,6 +43,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
@@ -83,7 +84,7 @@ fun KanbanCreateTaskDialog(
|
|||||||
val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it }
|
val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it }
|
||||||
var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) }
|
var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) }
|
||||||
var selectedAssignee by remember(defaultAssignee) {
|
var selectedAssignee by remember(defaultAssignee) {
|
||||||
mutableStateOf(defaultAssignee ?: profiles.firstOrNull()?.name ?: "default")
|
mutableStateOf(defaultAssignee.orEmpty())
|
||||||
}
|
}
|
||||||
var priority by remember { mutableIntStateOf(0) }
|
var priority by remember { mutableIntStateOf(0) }
|
||||||
var goalMode by remember { mutableStateOf(false) }
|
var goalMode by remember { mutableStateOf(false) }
|
||||||
@@ -222,7 +223,13 @@ fun KanbanCreateTaskDialog(
|
|||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
Text(if (isDef) "${profile.name} (Default)" else profile.name)
|
Text(
|
||||||
|
if (isDef) {
|
||||||
|
stringResource(R.string.kanban_assignee_default_suffix, profile.name)
|
||||||
|
} else {
|
||||||
|
profile.name
|
||||||
|
},
|
||||||
|
)
|
||||||
if (profile.model != null) {
|
if (profile.model != null) {
|
||||||
Text(
|
Text(
|
||||||
profile.model,
|
profile.model,
|
||||||
@@ -311,6 +318,7 @@ fun KanbanCreateTaskDialog(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { showAdvanced = !showAdvanced }
|
.clickable { showAdvanced = !showAdvanced }
|
||||||
|
.testTag("kanban_advanced_options")
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -409,15 +417,16 @@ fun KanbanCreateTaskDialog(
|
|||||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
listOf(
|
listOf(
|
||||||
"scratch" to "Scratch (Default)",
|
"scratch" to stringResource(R.string.kanban_workspace_scratch),
|
||||||
"worktree" to "Worktree",
|
"worktree" to stringResource(R.string.kanban_workspace_worktree),
|
||||||
"dir" to "Dir",
|
"dir" to stringResource(R.string.kanban_workspace_dir),
|
||||||
).forEach { (kind, label) ->
|
).forEach { (kind, label) ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = workspaceKind == kind,
|
selected = workspaceKind == kind,
|
||||||
onClick = { workspaceKind = kind },
|
onClick = { workspaceKind = kind },
|
||||||
label = { Text(label) },
|
label = { Text(label) },
|
||||||
enabled = !isCreating,
|
enabled = !isCreating,
|
||||||
|
modifier = Modifier.testTag("kanban_workspace_kind_$kind"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,7 +444,7 @@ fun KanbanCreateTaskDialog(
|
|||||||
Text(stringResource(R.string.kanban_workspace_path_hint))
|
Text(stringResource(R.string.kanban_workspace_path_hint))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().testTag("kanban_workspace_path"),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
enabled = !isCreating,
|
enabled = !isCreating,
|
||||||
)
|
)
|
||||||
@@ -448,7 +457,7 @@ fun KanbanCreateTaskDialog(
|
|||||||
value = skillsText,
|
value = skillsText,
|
||||||
onValueChange = { skillsText = it },
|
onValueChange = { skillsText = it },
|
||||||
label = { Text(stringResource(R.string.kanban_skills)) },
|
label = { Text(stringResource(R.string.kanban_skills)) },
|
||||||
placeholder = { Text("e.g. git, coding, tests") },
|
placeholder = { Text(stringResource(R.string.kanban_skills_placeholder)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
enabled = !isCreating,
|
enabled = !isCreating,
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -60,7 +63,7 @@ fun KanbanFilterSheet(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
TextButton(onClick = onClearFilters) {
|
TextButton(onClick = onClearFilters) {
|
||||||
Text("Clear all")
|
Text(stringResource(R.string.kanban_clear_all))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,10 +78,11 @@ fun KanbanFilterSheet(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Text("Show archived tasks", style = MaterialTheme.typography.bodyMedium)
|
Text(stringResource(R.string.kanban_show_archived), style = MaterialTheme.typography.bodyMedium)
|
||||||
Switch(
|
Switch(
|
||||||
checked = includeArchived,
|
checked = includeArchived,
|
||||||
onCheckedChange = onToggleIncludeArchived,
|
onCheckedChange = onToggleIncludeArchived,
|
||||||
|
modifier = Modifier.testTag("kanban_show_archived"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +95,11 @@ fun KanbanFilterSheet(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Text("Group running lane by profile", style = MaterialTheme.typography.bodyMedium)
|
Text(stringResource(R.string.kanban_group_running), style = MaterialTheme.typography.bodyMedium)
|
||||||
Switch(
|
Switch(
|
||||||
checked = groupRunning,
|
checked = groupRunning,
|
||||||
onCheckedChange = onToggleGroupRunning,
|
onCheckedChange = onToggleGroupRunning,
|
||||||
|
modifier = Modifier.testTag("kanban_group_running"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +107,7 @@ fun KanbanFilterSheet(
|
|||||||
|
|
||||||
// Assignee Filter
|
// Assignee Filter
|
||||||
Text(
|
Text(
|
||||||
text = "Assignee",
|
text = stringResource(R.string.kanban_assignee),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
@@ -120,7 +125,7 @@ fun KanbanFilterSheet(
|
|||||||
selected = selectedAssignee == null,
|
selected = selectedAssignee == null,
|
||||||
onClick = { onSelectAssignee(null) },
|
onClick = { onSelectAssignee(null) },
|
||||||
)
|
)
|
||||||
Text("All assignees", style = MaterialTheme.typography.bodyMedium)
|
Text(stringResource(R.string.kanban_all_assignees), style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
|
|
||||||
assignees.forEach { assignee ->
|
assignees.forEach { assignee ->
|
||||||
@@ -144,7 +149,7 @@ fun KanbanFilterSheet(
|
|||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Tenant",
|
text = stringResource(R.string.kanban_tenant),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
@@ -162,7 +167,7 @@ fun KanbanFilterSheet(
|
|||||||
selected = selectedTenant == null,
|
selected = selectedTenant == null,
|
||||||
onClick = { onSelectTenant(null) },
|
onClick = { onSelectTenant(null) },
|
||||||
)
|
)
|
||||||
Text("All tenants", style = MaterialTheme.typography.bodyMedium)
|
Text(stringResource(R.string.kanban_all_tenants), style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
|
|
||||||
tenants.forEach { tenant ->
|
tenants.forEach { tenant ->
|
||||||
|
|||||||
+33
-8
@@ -33,6 +33,7 @@ import com.m57.hermescontrol.R
|
|||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
import com.m57.hermescontrol.data.model.PinnedModel
|
||||||
import com.m57.hermescontrol.ui.kanban.KanbanModelOverride
|
import com.m57.hermescontrol.ui.kanban.KanbanModelOverride
|
||||||
|
import com.m57.hermescontrol.ui.kanban.supportedKanbanReasoningEfforts
|
||||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -123,6 +124,14 @@ fun KanbanModelOverrideEditor(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
val selectedCapabilities =
|
||||||
|
modelProviders
|
||||||
|
.firstOrNull { it.slug == override.provider }
|
||||||
|
?.capabilities
|
||||||
|
?.get(override.model)
|
||||||
|
val reasoningSupported = selectedCapabilities?.reasoning != false
|
||||||
|
val canDisableReasoning = selectedCapabilities?.can_disable_reasoning != false
|
||||||
|
val supportedEfforts = supportedKanbanReasoningEfforts(reasoningSupported, canDisableReasoning)
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
modifier =
|
modifier =
|
||||||
@@ -130,13 +139,22 @@ fun KanbanModelOverrideEditor(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.horizontalScroll(rememberScrollState()),
|
.horizontalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
listOf(
|
supportedEfforts
|
||||||
"" to stringResource(R.string.kanban_effort_inherit),
|
.map { effortVal ->
|
||||||
"none" to stringResource(R.string.kanban_effort_none),
|
effortVal to
|
||||||
"low" to stringResource(R.string.kanban_effort_low),
|
when (effortVal) {
|
||||||
"medium" to stringResource(R.string.kanban_effort_medium),
|
"" -> stringResource(R.string.kanban_effort_inherit)
|
||||||
"high" to stringResource(R.string.kanban_effort_high),
|
"none" -> stringResource(R.string.kanban_effort_none)
|
||||||
).forEach { (effortVal, label) ->
|
"minimal" -> stringResource(R.string.kanban_effort_minimal)
|
||||||
|
"low" -> stringResource(R.string.kanban_effort_low)
|
||||||
|
"medium" -> stringResource(R.string.kanban_effort_medium)
|
||||||
|
"high" -> stringResource(R.string.kanban_effort_high)
|
||||||
|
"xhigh" -> stringResource(R.string.kanban_effort_xhigh)
|
||||||
|
"max" -> stringResource(R.string.kanban_effort_max)
|
||||||
|
"ultra" -> stringResource(R.string.kanban_effort_ultra)
|
||||||
|
else -> effortVal
|
||||||
|
}
|
||||||
|
}.forEach { (effortVal, label) ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = override.effort == effortVal,
|
selected = override.effort == effortVal,
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -157,7 +175,14 @@ fun KanbanModelOverrideEditor(
|
|||||||
title = stringResource(R.string.kanban_select_model),
|
title = stringResource(R.string.kanban_select_model),
|
||||||
pinnedModels = pinnedModels,
|
pinnedModels = pinnedModels,
|
||||||
onSelect = { provider, model ->
|
onSelect = { provider, model ->
|
||||||
onOverrideChange(override.copy(provider = provider, model = model))
|
val capabilities = modelProviders.firstOrNull { it.slug == provider }?.capabilities?.get(model)
|
||||||
|
onOverrideChange(
|
||||||
|
override.copy(
|
||||||
|
provider = provider,
|
||||||
|
model = model,
|
||||||
|
effort = if (capabilities?.reasoning == false) "" else override.effort,
|
||||||
|
),
|
||||||
|
)
|
||||||
showPickerDialog = false
|
showPickerDialog = false
|
||||||
},
|
},
|
||||||
onDismiss = { showPickerDialog = false },
|
onDismiss = { showPickerDialog = false },
|
||||||
|
|||||||
+1
-1
@@ -317,7 +317,7 @@ private fun ProfileDescriptionEditor(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
minLines = 2,
|
minLines = 2,
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
placeholder = { Text("Profile description for routing") },
|
placeholder = { Text(stringResource(R.string.kanban_profile_description_hint)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.KanbanTask
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.ui.kanban.KanbanArcState
|
import com.m57.hermescontrol.ui.kanban.KanbanArcState
|
||||||
@@ -47,6 +49,7 @@ fun KanbanTaskCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isSelected: Boolean = false,
|
isSelected: Boolean = false,
|
||||||
defaultAssignee: String? = null,
|
defaultAssignee: String? = null,
|
||||||
|
nowSeconds: Long = System.currentTimeMillis() / 1000L,
|
||||||
onActionClick: ((KanbanTask) -> Unit)? = null,
|
onActionClick: ((KanbanTask) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
@@ -60,9 +63,9 @@ fun KanbanTaskCard(
|
|||||||
else -> MaterialTheme.colorScheme.outline
|
else -> MaterialTheme.colorScheme.outline
|
||||||
}
|
}
|
||||||
|
|
||||||
val arcState = KanbanRuntimeHelper.arcState(task, defaultAssignee)
|
val arcState = KanbanRuntimeHelper.arcState(task, defaultAssignee, nowSeconds)
|
||||||
val isWontRun = KanbanRuntimeHelper.isWontRun(task, defaultAssignee)
|
val isWontRun = KanbanRuntimeHelper.isWontRun(task, defaultAssignee)
|
||||||
val elapsed = KanbanRuntimeHelper.formatElapsed(task.startedAt)
|
val elapsed = KanbanRuntimeHelper.formatElapsed(task.startedAt, nowSeconds)
|
||||||
|
|
||||||
val cardColors =
|
val cardColors =
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@@ -103,7 +106,11 @@ fun KanbanTaskCard(
|
|||||||
arcState == KanbanArcState.RUNNING -> {
|
arcState == KanbanArcState.RUNNING -> {
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "· ${elapsed ?: "working"}",
|
text =
|
||||||
|
stringResource(
|
||||||
|
R.string.kanban_runtime_elapsed,
|
||||||
|
elapsed ?: stringResource(R.string.kanban_runtime_working),
|
||||||
|
),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = statusColors.success,
|
color = statusColors.success,
|
||||||
@@ -113,7 +120,7 @@ fun KanbanTaskCard(
|
|||||||
arcState == KanbanArcState.STALE -> {
|
arcState == KanbanArcState.STALE -> {
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "· STALE",
|
text = stringResource(R.string.kanban_runtime_stale_badge),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = statusColors.warning,
|
color = statusColors.warning,
|
||||||
@@ -123,7 +130,7 @@ fun KanbanTaskCard(
|
|||||||
isWontRun -> {
|
isWontRun -> {
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "· WON'T RUN",
|
text = stringResource(R.string.kanban_runtime_wont_run_badge),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = statusColors.error,
|
color = statusColors.error,
|
||||||
@@ -146,7 +153,7 @@ fun KanbanTaskCard(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.MoreVert,
|
imageVector = Icons.Default.MoreVert,
|
||||||
contentDescription = "Task actions",
|
contentDescription = stringResource(R.string.kanban_task_actions),
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.m57.hermescontrol.util
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.net.Uri
|
||||||
|
import okhttp3.MediaType
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okio.BufferedSink
|
||||||
|
import okio.source
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A streaming OkHttp [RequestBody] backed by an Android SAF [Uri] and [ContentResolver].
|
||||||
|
*
|
||||||
|
* Streams bytes directly from the open stream into OkHttp's [BufferedSink] without
|
||||||
|
* buffering the entire file into heap memory (avoiding OutOfMemoryError on large archives).
|
||||||
|
*/
|
||||||
|
class StreamingUriRequestBody(
|
||||||
|
private val contentResolver: ContentResolver,
|
||||||
|
private val uri: Uri,
|
||||||
|
private val contentType: MediaType?,
|
||||||
|
private val contentLength: Long = -1L,
|
||||||
|
) : RequestBody() {
|
||||||
|
override fun contentType(): MediaType? = contentType
|
||||||
|
|
||||||
|
override fun contentLength(): Long = contentLength
|
||||||
|
|
||||||
|
override fun writeTo(sink: BufferedSink) {
|
||||||
|
val inputStream =
|
||||||
|
contentResolver.openInputStream(uri)
|
||||||
|
?: throw IOException("Unable to open input stream for URI: $uri")
|
||||||
|
inputStream.use { stream ->
|
||||||
|
stream.source().use { source ->
|
||||||
|
sink.writeAll(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -324,6 +324,7 @@
|
|||||||
<string name="kanban_priority_high">2 (مرتفع)</string>
|
<string name="kanban_priority_high">2 (مرتفع)</string>
|
||||||
<string name="kanban_priority_urgent">3 (عاجل)</string>
|
<string name="kanban_priority_urgent">3 (عاجل)</string>
|
||||||
<string name="kanban_assignee">المسؤول</string>
|
<string name="kanban_assignee">المسؤول</string>
|
||||||
|
<string name="kanban_assignee_default_suffix">%1$s (الافتراضي)</string>
|
||||||
<string name="kanban_assignee_unassigned">افتراضي (غير معيَّن)</string>
|
<string name="kanban_assignee_unassigned">افتراضي (غير معيَّن)</string>
|
||||||
<string name="kanban_assignee_parked">موقوف (بدون عامل)</string>
|
<string name="kanban_assignee_parked">موقوف (بدون عامل)</string>
|
||||||
<string name="kanban_goal_mode">وضع الهدف الذاتي</string>
|
<string name="kanban_goal_mode">وضع الهدف الذاتي</string>
|
||||||
@@ -347,11 +348,32 @@
|
|||||||
<string name="kanban_effort_low">منخفض</string>
|
<string name="kanban_effort_low">منخفض</string>
|
||||||
<string name="kanban_effort_medium">متوسط</string>
|
<string name="kanban_effort_medium">متوسط</string>
|
||||||
<string name="kanban_effort_high">مرتفع</string>
|
<string name="kanban_effort_high">مرتفع</string>
|
||||||
|
<string name="kanban_effort_minimal">حد أدنى</string>
|
||||||
|
<string name="kanban_effort_xhigh">مرتفع جدًا</string>
|
||||||
|
<string name="kanban_effort_max">أقصى</string>
|
||||||
|
<string name="kanban_effort_ultra">فائق</string>
|
||||||
|
<string name="kanban_show_archived">إظهار المهام المؤرشفة</string>
|
||||||
|
<string name="kanban_group_running">تجميع المهام قيد التشغيل حسب الملف الشخصي</string>
|
||||||
|
<string name="kanban_all_assignees">جميع المسؤولين</string>
|
||||||
|
<string name="kanban_tenant">المستأجر</string>
|
||||||
|
<string name="kanban_all_tenants">جميع المستأجرين</string>
|
||||||
|
<string name="kanban_execution_runs">عمليات التنفيذ</string>
|
||||||
|
<string name="kanban_no_run_history">لا يتوفر سجل للتنفيذ.</string>
|
||||||
|
<string name="kanban_run_number">التنفيذ #%1$d</string>
|
||||||
|
<string name="kanban_run_profile">الملف الشخصي: %1$s</string>
|
||||||
|
<string name="kanban_run_outcome">النتيجة: %1$s</string>
|
||||||
|
<string name="kanban_run_duration">المدة: %1$s</string>
|
||||||
|
<string name="kanban_run_error">الخطأ: %1$s</string>
|
||||||
|
<string name="kanban_run_summary">الملخص: %1$s</string>
|
||||||
<string name="kanban_project">نطاق المشروع</string>
|
<string name="kanban_project">نطاق المشروع</string>
|
||||||
<string name="kanban_no_project">بدون مشروع</string>
|
<string name="kanban_no_project">بدون مشروع</string>
|
||||||
<string name="kanban_archive_board">أرشفة اللوحة</string>
|
<string name="kanban_archive_board">أرشفة اللوحة</string>
|
||||||
<string name="kanban_archive_board_confirm">هل أنت متأكد من أرشفة اللوحة \'%1$s\'؟ يمكنك استعادتها لاحقًا من اللوحات المؤرشفة.</string>
|
<string name="kanban_archive_board_confirm">هل أنت متأكد من أرشفة اللوحة \'%1$s\'؟ يمكنك استعادتها لاحقًا من اللوحات المؤرشفة.</string>
|
||||||
<string name="kanban_board_settings">إعدادات اللوحة</string>
|
<string name="kanban_board_settings">إعدادات اللوحة</string>
|
||||||
|
<string name="kanban_board_name">اسم اللوحة</string>
|
||||||
|
<string name="kanban_description">الوصف</string>
|
||||||
|
<string name="kanban_default_workdir">مجلد العمل الافتراضي</string>
|
||||||
|
<string name="kanban_workdir_example">مثال: /home/user/project</string>
|
||||||
<string name="kanban_orchestration_settings">إعدادات التنسيق</string>
|
<string name="kanban_orchestration_settings">إعدادات التنسيق</string>
|
||||||
<string name="kanban_orchestrator_profile">الملف المنسق</string>
|
<string name="kanban_orchestrator_profile">الملف المنسق</string>
|
||||||
<string name="kanban_default_assignee">المسؤول الافتراضي</string>
|
<string name="kanban_default_assignee">المسؤول الافتراضي</string>
|
||||||
@@ -378,6 +400,41 @@
|
|||||||
<string name="kanban_runtime_working">قيد التشغيل</string>
|
<string name="kanban_runtime_working">قيد التشغيل</string>
|
||||||
<string name="kanban_runtime_stale">متوقف (لا توجد إشارة)</string>
|
<string name="kanban_runtime_stale">متوقف (لا توجد إشارة)</string>
|
||||||
<string name="kanban_runtime_wont_run">لن تعمل: غير معيّنة</string>
|
<string name="kanban_runtime_wont_run">لن تعمل: غير معيّنة</string>
|
||||||
|
<string name="kanban_runtime_elapsed">· %1$s</string>
|
||||||
|
<string name="kanban_runtime_stale_badge">· متوقف</string>
|
||||||
|
<string name="kanban_runtime_wont_run_badge">· لن تعمل</string>
|
||||||
|
<string name="kanban_task_actions">إجراءات المهمة</string>
|
||||||
|
<string name="kanban_reclaim_task">استعادة المهمة</string>
|
||||||
|
<string name="kanban_no_comments">لا توجد تعليقات بعد.</string>
|
||||||
|
<string name="kanban_write_comment">اكتب تعليقًا...</string>
|
||||||
|
<string name="kanban_note_requeue">ملاحظة وإعادة إدراج</string>
|
||||||
|
<string name="kanban_comment">تعليق</string>
|
||||||
|
<string name="kanban_worker_log">مخرجات سجل العامل</string>
|
||||||
|
<string name="kanban_event_timeline">الخط الزمني للأحداث</string>
|
||||||
|
<string name="kanban_no_activity">لا توجد أحداث نشاط.</string>
|
||||||
|
<string name="kanban_attachments">المرفقات</string>
|
||||||
|
<string name="kanban_upload">رفع</string>
|
||||||
|
<string name="kanban_no_attachments">لا توجد مرفقات مرفوعة.</string>
|
||||||
|
<string name="kanban_clear_all">مسح الكل</string>
|
||||||
|
<string name="kanban_skills_placeholder">مثال: git، coding، tests</string>
|
||||||
|
<string name="kanban_profile_description_hint">وصف الملف الشخصي للتوجيه</string>
|
||||||
|
<string name="kanban_filter">تصفية</string>
|
||||||
|
<string name="kanban_board_options">خيارات اللوحة</string>
|
||||||
|
<string name="kanban_new_board">لوحة جديدة</string>
|
||||||
|
<string name="kanban_clear_selection">مسح</string>
|
||||||
|
<string name="kanban_select_all">الكل</string>
|
||||||
|
<string name="kanban_move">نقل</string>
|
||||||
|
<string name="kanban_close_multi_select">إغلاق التحديد المتعدد</string>
|
||||||
|
<string name="kanban_move_tasks">نقل %1$d من المهام</string>
|
||||||
|
<string name="kanban_create_new_board">إنشاء لوحة جديدة</string>
|
||||||
|
<string name="kanban_slug">المعرّف (Slug)</string>
|
||||||
|
<string name="kanban_display_name">اسم العرض</string>
|
||||||
|
<string name="kanban_filters">عوامل التصفية</string>
|
||||||
|
<string name="kanban_confirm_action">تأكيد الإجراء</string>
|
||||||
|
<string name="kanban_edit_description">تعديل الوصف</string>
|
||||||
|
<string name="kanban_workspace_scratch">مؤقت (افتراضي)</string>
|
||||||
|
<string name="kanban_workspace_worktree">شجرة عمل</string>
|
||||||
|
<string name="kanban_workspace_dir">مجلد</string>
|
||||||
<string name="kanban_unassigned_group">غير معيّن</string>
|
<string name="kanban_unassigned_group">غير معيّن</string>
|
||||||
<string name="kanban_expand_column">توسيع %1$s</string>
|
<string name="kanban_expand_column">توسيع %1$s</string>
|
||||||
<string name="kanban_collapse_column">طي %1$s</string>
|
<string name="kanban_collapse_column">طي %1$s</string>
|
||||||
|
|||||||
@@ -322,6 +322,7 @@
|
|||||||
<string name="kanban_priority_high">2 (높음)</string>
|
<string name="kanban_priority_high">2 (높음)</string>
|
||||||
<string name="kanban_priority_urgent">3 (긴급)</string>
|
<string name="kanban_priority_urgent">3 (긴급)</string>
|
||||||
<string name="kanban_assignee">담당자</string>
|
<string name="kanban_assignee">담당자</string>
|
||||||
|
<string name="kanban_assignee_default_suffix">%1$s (기본값)</string>
|
||||||
<string name="kanban_assignee_unassigned">기본 (미지정)</string>
|
<string name="kanban_assignee_unassigned">기본 (미지정)</string>
|
||||||
<string name="kanban_assignee_parked">보류 (워커 없음)</string>
|
<string name="kanban_assignee_parked">보류 (워커 없음)</string>
|
||||||
<string name="kanban_goal_mode">자율 목표 모드</string>
|
<string name="kanban_goal_mode">자율 목표 모드</string>
|
||||||
@@ -345,11 +346,32 @@
|
|||||||
<string name="kanban_effort_low">낮음</string>
|
<string name="kanban_effort_low">낮음</string>
|
||||||
<string name="kanban_effort_medium">중간</string>
|
<string name="kanban_effort_medium">중간</string>
|
||||||
<string name="kanban_effort_high">높음</string>
|
<string name="kanban_effort_high">높음</string>
|
||||||
|
<string name="kanban_effort_minimal">최소</string>
|
||||||
|
<string name="kanban_effort_xhigh">매우 높음</string>
|
||||||
|
<string name="kanban_effort_max">최대</string>
|
||||||
|
<string name="kanban_effort_ultra">울트라</string>
|
||||||
|
<string name="kanban_show_archived">보관된 작업 표시</string>
|
||||||
|
<string name="kanban_group_running">실행 중인 작업을 프로필별로 그룹화</string>
|
||||||
|
<string name="kanban_all_assignees">모든 담당자</string>
|
||||||
|
<string name="kanban_tenant">테넌트</string>
|
||||||
|
<string name="kanban_all_tenants">모든 테넌트</string>
|
||||||
|
<string name="kanban_execution_runs">실행 기록</string>
|
||||||
|
<string name="kanban_no_run_history">실행 기록이 없습니다.</string>
|
||||||
|
<string name="kanban_run_number">실행 #%1$d</string>
|
||||||
|
<string name="kanban_run_profile">프로필: %1$s</string>
|
||||||
|
<string name="kanban_run_outcome">결과: %1$s</string>
|
||||||
|
<string name="kanban_run_duration">기간: %1$s</string>
|
||||||
|
<string name="kanban_run_error">오류: %1$s</string>
|
||||||
|
<string name="kanban_run_summary">요약: %1$s</string>
|
||||||
<string name="kanban_project">프로젝트 범위</string>
|
<string name="kanban_project">프로젝트 범위</string>
|
||||||
<string name="kanban_no_project">프로젝트 없음</string>
|
<string name="kanban_no_project">프로젝트 없음</string>
|
||||||
<string name="kanban_archive_board">보드 보관</string>
|
<string name="kanban_archive_board">보드 보관</string>
|
||||||
<string name="kanban_archive_board_confirm">\'%1$s\' 보드를 보관하시겠습니까? 나중에 보관된 보드에서 복구할 수 있습니다.</string>
|
<string name="kanban_archive_board_confirm">\'%1$s\' 보드를 보관하시겠습니까? 나중에 보관된 보드에서 복구할 수 있습니다.</string>
|
||||||
<string name="kanban_board_settings">보드 설정</string>
|
<string name="kanban_board_settings">보드 설정</string>
|
||||||
|
<string name="kanban_board_name">보드 이름</string>
|
||||||
|
<string name="kanban_description">설명</string>
|
||||||
|
<string name="kanban_default_workdir">기본 작업 디렉터리</string>
|
||||||
|
<string name="kanban_workdir_example">예: /home/user/project</string>
|
||||||
<string name="kanban_orchestration_settings">오케스트레이션 설정</string>
|
<string name="kanban_orchestration_settings">오케스트레이션 설정</string>
|
||||||
<string name="kanban_orchestrator_profile">오케스트레이터 프로필</string>
|
<string name="kanban_orchestrator_profile">오케스트레이터 프로필</string>
|
||||||
<string name="kanban_default_assignee">기본 담당자</string>
|
<string name="kanban_default_assignee">기본 담당자</string>
|
||||||
@@ -376,6 +398,41 @@
|
|||||||
<string name="kanban_runtime_working">실행 중</string>
|
<string name="kanban_runtime_working">실행 중</string>
|
||||||
<string name="kanban_runtime_stale">응답 없음 (하트비트 지연)</string>
|
<string name="kanban_runtime_stale">응답 없음 (하트비트 지연)</string>
|
||||||
<string name="kanban_runtime_wont_run">실행 안 됨: 담당자 미지정</string>
|
<string name="kanban_runtime_wont_run">실행 안 됨: 담당자 미지정</string>
|
||||||
|
<string name="kanban_runtime_elapsed">· %1$s</string>
|
||||||
|
<string name="kanban_runtime_stale_badge">· 응답 없음</string>
|
||||||
|
<string name="kanban_runtime_wont_run_badge">· 실행 안 됨</string>
|
||||||
|
<string name="kanban_task_actions">작업 작업</string>
|
||||||
|
<string name="kanban_reclaim_task">작업 회수</string>
|
||||||
|
<string name="kanban_no_comments">아직 댓글이 없습니다.</string>
|
||||||
|
<string name="kanban_write_comment">댓글을 작성하세요...</string>
|
||||||
|
<string name="kanban_note_requeue">메모 및 다시 대기열에 추가</string>
|
||||||
|
<string name="kanban_comment">댓글</string>
|
||||||
|
<string name="kanban_worker_log">워커 로그 출력</string>
|
||||||
|
<string name="kanban_event_timeline">이벤트 타임라인</string>
|
||||||
|
<string name="kanban_no_activity">활동 이벤트가 없습니다.</string>
|
||||||
|
<string name="kanban_attachments">첨부파일</string>
|
||||||
|
<string name="kanban_upload">업로드</string>
|
||||||
|
<string name="kanban_no_attachments">업로드된 첨부파일이 없습니다.</string>
|
||||||
|
<string name="kanban_clear_all">모두 지우기</string>
|
||||||
|
<string name="kanban_skills_placeholder">예: git, coding, tests</string>
|
||||||
|
<string name="kanban_profile_description_hint">라우팅에 사용할 프로필 설명</string>
|
||||||
|
<string name="kanban_filter">필터</string>
|
||||||
|
<string name="kanban_board_options">보드 옵션</string>
|
||||||
|
<string name="kanban_new_board">새 보드</string>
|
||||||
|
<string name="kanban_clear_selection">지우기</string>
|
||||||
|
<string name="kanban_select_all">전체</string>
|
||||||
|
<string name="kanban_move">이동</string>
|
||||||
|
<string name="kanban_close_multi_select">다중 선택 닫기</string>
|
||||||
|
<string name="kanban_move_tasks">작업 %1$d개 이동</string>
|
||||||
|
<string name="kanban_create_new_board">새 보드 만들기</string>
|
||||||
|
<string name="kanban_slug">슬러그 (id)</string>
|
||||||
|
<string name="kanban_display_name">표시 이름</string>
|
||||||
|
<string name="kanban_filters">필터</string>
|
||||||
|
<string name="kanban_confirm_action">작업 확인</string>
|
||||||
|
<string name="kanban_edit_description">설명 편집</string>
|
||||||
|
<string name="kanban_workspace_scratch">임시 (기본값)</string>
|
||||||
|
<string name="kanban_workspace_worktree">작업 트리</string>
|
||||||
|
<string name="kanban_workspace_dir">디렉터리</string>
|
||||||
<string name="kanban_unassigned_group">미지정</string>
|
<string name="kanban_unassigned_group">미지정</string>
|
||||||
<string name="kanban_expand_column">%1$s 펼치기</string>
|
<string name="kanban_expand_column">%1$s 펼치기</string>
|
||||||
<string name="kanban_collapse_column">%1$s 접기</string>
|
<string name="kanban_collapse_column">%1$s 접기</string>
|
||||||
|
|||||||
@@ -323,6 +323,7 @@
|
|||||||
<string name="kanban_priority_high">2 (高)</string>
|
<string name="kanban_priority_high">2 (高)</string>
|
||||||
<string name="kanban_priority_urgent">3 (紧急)</string>
|
<string name="kanban_priority_urgent">3 (紧急)</string>
|
||||||
<string name="kanban_assignee">负责人</string>
|
<string name="kanban_assignee">负责人</string>
|
||||||
|
<string name="kanban_assignee_default_suffix">%1$s(默认)</string>
|
||||||
<string name="kanban_assignee_unassigned">默认 (未指定)</string>
|
<string name="kanban_assignee_unassigned">默认 (未指定)</string>
|
||||||
<string name="kanban_assignee_parked">暂挂 (无 worker)</string>
|
<string name="kanban_assignee_parked">暂挂 (无 worker)</string>
|
||||||
<string name="kanban_goal_mode">自主目标模式</string>
|
<string name="kanban_goal_mode">自主目标模式</string>
|
||||||
@@ -346,11 +347,32 @@
|
|||||||
<string name="kanban_effort_low">低</string>
|
<string name="kanban_effort_low">低</string>
|
||||||
<string name="kanban_effort_medium">中</string>
|
<string name="kanban_effort_medium">中</string>
|
||||||
<string name="kanban_effort_high">高</string>
|
<string name="kanban_effort_high">高</string>
|
||||||
|
<string name="kanban_effort_minimal">最小</string>
|
||||||
|
<string name="kanban_effort_xhigh">超高</string>
|
||||||
|
<string name="kanban_effort_max">最大</string>
|
||||||
|
<string name="kanban_effort_ultra">极高</string>
|
||||||
|
<string name="kanban_show_archived">显示已归档任务</string>
|
||||||
|
<string name="kanban_group_running">按配置文件分组运行中的任务</string>
|
||||||
|
<string name="kanban_all_assignees">所有负责人</string>
|
||||||
|
<string name="kanban_tenant">租户</string>
|
||||||
|
<string name="kanban_all_tenants">所有租户</string>
|
||||||
|
<string name="kanban_execution_runs">执行记录</string>
|
||||||
|
<string name="kanban_no_run_history">暂无执行记录。</string>
|
||||||
|
<string name="kanban_run_number">执行 #%1$d</string>
|
||||||
|
<string name="kanban_run_profile">配置文件:%1$s</string>
|
||||||
|
<string name="kanban_run_outcome">结果:%1$s</string>
|
||||||
|
<string name="kanban_run_duration">时长:%1$s</string>
|
||||||
|
<string name="kanban_run_error">错误:%1$s</string>
|
||||||
|
<string name="kanban_run_summary">摘要:%1$s</string>
|
||||||
<string name="kanban_project">项目范围</string>
|
<string name="kanban_project">项目范围</string>
|
||||||
<string name="kanban_no_project">无项目</string>
|
<string name="kanban_no_project">无项目</string>
|
||||||
<string name="kanban_archive_board">归档看板</string>
|
<string name="kanban_archive_board">归档看板</string>
|
||||||
<string name="kanban_archive_board_confirm">确定要归档看板 \'%1$s\' 吗?稍后可从已归档看板中恢复。</string>
|
<string name="kanban_archive_board_confirm">确定要归档看板 \'%1$s\' 吗?稍后可从已归档看板中恢复。</string>
|
||||||
<string name="kanban_board_settings">看板设置</string>
|
<string name="kanban_board_settings">看板设置</string>
|
||||||
|
<string name="kanban_board_name">看板名称</string>
|
||||||
|
<string name="kanban_description">描述</string>
|
||||||
|
<string name="kanban_default_workdir">默认工作目录</string>
|
||||||
|
<string name="kanban_workdir_example">例如:/home/user/project</string>
|
||||||
<string name="kanban_orchestration_settings">编排设置</string>
|
<string name="kanban_orchestration_settings">编排设置</string>
|
||||||
<string name="kanban_orchestrator_profile">编排配置文件</string>
|
<string name="kanban_orchestrator_profile">编排配置文件</string>
|
||||||
<string name="kanban_default_assignee">默认负责人</string>
|
<string name="kanban_default_assignee">默认负责人</string>
|
||||||
@@ -377,6 +399,41 @@
|
|||||||
<string name="kanban_runtime_working">运行中</string>
|
<string name="kanban_runtime_working">运行中</string>
|
||||||
<string name="kanban_runtime_stale">无响应 (心跳超时)</string>
|
<string name="kanban_runtime_stale">无响应 (心跳超时)</string>
|
||||||
<string name="kanban_runtime_wont_run">无法运行:未指定负责人</string>
|
<string name="kanban_runtime_wont_run">无法运行:未指定负责人</string>
|
||||||
|
<string name="kanban_runtime_elapsed">· %1$s</string>
|
||||||
|
<string name="kanban_runtime_stale_badge">· 无响应</string>
|
||||||
|
<string name="kanban_runtime_wont_run_badge">· 无法运行</string>
|
||||||
|
<string name="kanban_task_actions">任务操作</string>
|
||||||
|
<string name="kanban_reclaim_task">回收任务</string>
|
||||||
|
<string name="kanban_no_comments">暂无评论。</string>
|
||||||
|
<string name="kanban_write_comment">写下评论……</string>
|
||||||
|
<string name="kanban_note_requeue">备注并重新排队</string>
|
||||||
|
<string name="kanban_comment">评论</string>
|
||||||
|
<string name="kanban_worker_log">工作器日志输出</string>
|
||||||
|
<string name="kanban_event_timeline">事件时间线</string>
|
||||||
|
<string name="kanban_no_activity">暂无活动事件。</string>
|
||||||
|
<string name="kanban_attachments">附件</string>
|
||||||
|
<string name="kanban_upload">上传</string>
|
||||||
|
<string name="kanban_no_attachments">暂无已上传附件。</string>
|
||||||
|
<string name="kanban_clear_all">全部清除</string>
|
||||||
|
<string name="kanban_skills_placeholder">例如:git、coding、tests</string>
|
||||||
|
<string name="kanban_profile_description_hint">用于路由的配置文件描述</string>
|
||||||
|
<string name="kanban_filter">筛选</string>
|
||||||
|
<string name="kanban_board_options">看板选项</string>
|
||||||
|
<string name="kanban_new_board">新建看板</string>
|
||||||
|
<string name="kanban_clear_selection">清除</string>
|
||||||
|
<string name="kanban_select_all">全部</string>
|
||||||
|
<string name="kanban_move">移动</string>
|
||||||
|
<string name="kanban_close_multi_select">关闭多选</string>
|
||||||
|
<string name="kanban_move_tasks">移动 %1$d 个任务</string>
|
||||||
|
<string name="kanban_create_new_board">创建新看板</string>
|
||||||
|
<string name="kanban_slug">Slug(ID)</string>
|
||||||
|
<string name="kanban_display_name">显示名称</string>
|
||||||
|
<string name="kanban_filters">筛选</string>
|
||||||
|
<string name="kanban_confirm_action">确认操作</string>
|
||||||
|
<string name="kanban_edit_description">编辑描述</string>
|
||||||
|
<string name="kanban_workspace_scratch">临时(默认)</string>
|
||||||
|
<string name="kanban_workspace_worktree">工作树</string>
|
||||||
|
<string name="kanban_workspace_dir">目录</string>
|
||||||
<string name="kanban_unassigned_group">未分配</string>
|
<string name="kanban_unassigned_group">未分配</string>
|
||||||
<string name="kanban_expand_column">展开 %1$s</string>
|
<string name="kanban_expand_column">展开 %1$s</string>
|
||||||
<string name="kanban_collapse_column">折叠 %1$s</string>
|
<string name="kanban_collapse_column">折叠 %1$s</string>
|
||||||
|
|||||||
@@ -359,6 +359,7 @@
|
|||||||
<string name="kanban_priority_high">2 (High)</string>
|
<string name="kanban_priority_high">2 (High)</string>
|
||||||
<string name="kanban_priority_urgent">3 (Urgent)</string>
|
<string name="kanban_priority_urgent">3 (Urgent)</string>
|
||||||
<string name="kanban_assignee">Assignee</string>
|
<string name="kanban_assignee">Assignee</string>
|
||||||
|
<string name="kanban_assignee_default_suffix">%1$s (Default)</string>
|
||||||
<string name="kanban_assignee_unassigned">Default (Unassigned)</string>
|
<string name="kanban_assignee_unassigned">Default (Unassigned)</string>
|
||||||
<string name="kanban_assignee_parked">Parked (No worker)</string>
|
<string name="kanban_assignee_parked">Parked (No worker)</string>
|
||||||
<string name="kanban_goal_mode">Autonomous Goal Mode</string>
|
<string name="kanban_goal_mode">Autonomous Goal Mode</string>
|
||||||
@@ -382,11 +383,32 @@
|
|||||||
<string name="kanban_effort_low">Low</string>
|
<string name="kanban_effort_low">Low</string>
|
||||||
<string name="kanban_effort_medium">Medium</string>
|
<string name="kanban_effort_medium">Medium</string>
|
||||||
<string name="kanban_effort_high">High</string>
|
<string name="kanban_effort_high">High</string>
|
||||||
|
<string name="kanban_effort_minimal">Minimal</string>
|
||||||
|
<string name="kanban_effort_xhigh">XHigh</string>
|
||||||
|
<string name="kanban_effort_max">Max</string>
|
||||||
|
<string name="kanban_effort_ultra">Ultra</string>
|
||||||
|
<string name="kanban_show_archived">Show archived tasks</string>
|
||||||
|
<string name="kanban_group_running">Group running lane by profile</string>
|
||||||
|
<string name="kanban_all_assignees">All assignees</string>
|
||||||
|
<string name="kanban_tenant">Tenant</string>
|
||||||
|
<string name="kanban_all_tenants">All tenants</string>
|
||||||
|
<string name="kanban_execution_runs">Execution Runs</string>
|
||||||
|
<string name="kanban_no_run_history">No run history available.</string>
|
||||||
|
<string name="kanban_run_number">Run #%1$d</string>
|
||||||
|
<string name="kanban_run_profile">Profile: %1$s</string>
|
||||||
|
<string name="kanban_run_outcome">Outcome: %1$s</string>
|
||||||
|
<string name="kanban_run_duration">Duration: %1$s</string>
|
||||||
|
<string name="kanban_run_error">Error: %1$s</string>
|
||||||
|
<string name="kanban_run_summary">Summary: %1$s</string>
|
||||||
<string name="kanban_project">Project Scope</string>
|
<string name="kanban_project">Project Scope</string>
|
||||||
<string name="kanban_no_project">No project</string>
|
<string name="kanban_no_project">No project</string>
|
||||||
<string name="kanban_archive_board">Archive Board</string>
|
<string name="kanban_archive_board">Archive Board</string>
|
||||||
<string name="kanban_archive_board_confirm">Are you sure you want to archive board \'%1$s\'? You can recover it later from archived boards.</string>
|
<string name="kanban_archive_board_confirm">Are you sure you want to archive board \'%1$s\'? You can recover it later from archived boards.</string>
|
||||||
<string name="kanban_board_settings">Board Settings</string>
|
<string name="kanban_board_settings">Board Settings</string>
|
||||||
|
<string name="kanban_board_name">Board Name</string>
|
||||||
|
<string name="kanban_description">Description</string>
|
||||||
|
<string name="kanban_default_workdir">Default Workdir</string>
|
||||||
|
<string name="kanban_workdir_example">e.g. /home/user/project</string>
|
||||||
<string name="kanban_orchestration_settings">Orchestration Settings</string>
|
<string name="kanban_orchestration_settings">Orchestration Settings</string>
|
||||||
<string name="kanban_orchestrator_profile">Orchestrator Profile</string>
|
<string name="kanban_orchestrator_profile">Orchestrator Profile</string>
|
||||||
<string name="kanban_default_assignee">Default Assignee</string>
|
<string name="kanban_default_assignee">Default Assignee</string>
|
||||||
@@ -413,6 +435,41 @@
|
|||||||
<string name="kanban_runtime_working">Working</string>
|
<string name="kanban_runtime_working">Working</string>
|
||||||
<string name="kanban_runtime_stale">Stale (no heartbeat)</string>
|
<string name="kanban_runtime_stale">Stale (no heartbeat)</string>
|
||||||
<string name="kanban_runtime_wont_run">Won\'t run: unassigned</string>
|
<string name="kanban_runtime_wont_run">Won\'t run: unassigned</string>
|
||||||
|
<string name="kanban_runtime_elapsed">· %1$s</string>
|
||||||
|
<string name="kanban_runtime_stale_badge">· STALE</string>
|
||||||
|
<string name="kanban_runtime_wont_run_badge">· WON\'T RUN</string>
|
||||||
|
<string name="kanban_task_actions">Task actions</string>
|
||||||
|
<string name="kanban_reclaim_task">Reclaim task</string>
|
||||||
|
<string name="kanban_no_comments">No comments yet.</string>
|
||||||
|
<string name="kanban_write_comment">Write a comment...</string>
|
||||||
|
<string name="kanban_note_requeue">Note & Requeue</string>
|
||||||
|
<string name="kanban_comment">Comment</string>
|
||||||
|
<string name="kanban_worker_log">Worker Log Output</string>
|
||||||
|
<string name="kanban_event_timeline">Event Timeline</string>
|
||||||
|
<string name="kanban_no_activity">No activity events.</string>
|
||||||
|
<string name="kanban_attachments">Attachments</string>
|
||||||
|
<string name="kanban_upload">Upload</string>
|
||||||
|
<string name="kanban_no_attachments">No attachments uploaded.</string>
|
||||||
|
<string name="kanban_clear_all">Clear all</string>
|
||||||
|
<string name="kanban_skills_placeholder">e.g. git, coding, tests</string>
|
||||||
|
<string name="kanban_profile_description_hint">Profile description for routing</string>
|
||||||
|
<string name="kanban_filter">Filter</string>
|
||||||
|
<string name="kanban_board_options">Board options</string>
|
||||||
|
<string name="kanban_new_board">New Board</string>
|
||||||
|
<string name="kanban_clear_selection">Clear</string>
|
||||||
|
<string name="kanban_select_all">All</string>
|
||||||
|
<string name="kanban_move">Move</string>
|
||||||
|
<string name="kanban_close_multi_select">Close multi-select</string>
|
||||||
|
<string name="kanban_move_tasks">Move %1$d tasks</string>
|
||||||
|
<string name="kanban_create_new_board">Create New Board</string>
|
||||||
|
<string name="kanban_slug">Slug (id)</string>
|
||||||
|
<string name="kanban_display_name">Display Name</string>
|
||||||
|
<string name="kanban_filters">Filters</string>
|
||||||
|
<string name="kanban_confirm_action">Confirm action</string>
|
||||||
|
<string name="kanban_edit_description">Edit Description</string>
|
||||||
|
<string name="kanban_workspace_scratch">Scratch (Default)</string>
|
||||||
|
<string name="kanban_workspace_worktree">Worktree</string>
|
||||||
|
<string name="kanban_workspace_dir">Dir</string>
|
||||||
<string name="kanban_unassigned_group">Unassigned</string>
|
<string name="kanban_unassigned_group">Unassigned</string>
|
||||||
<string name="kanban_expand_column">Expand %1$s</string>
|
<string name="kanban_expand_column">Expand %1$s</string>
|
||||||
<string name="kanban_collapse_column">Collapse %1$s</string>
|
<string name="kanban_collapse_column">Collapse %1$s</string>
|
||||||
|
|||||||
@@ -289,4 +289,29 @@ class KanbanSerializationTest {
|
|||||||
assertEquals("M", estimate.complexity)
|
assertEquals("M", estimate.complexity)
|
||||||
assertEquals("Multi-file changes needed", estimate.rationale)
|
assertEquals("Multi-file changes needed", estimate.rationale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBulkAssignmentUsesExplicitEmptyStringAndReclaim() {
|
||||||
|
val assignJson =
|
||||||
|
json.encodeToString(
|
||||||
|
BulkTasksBody(
|
||||||
|
ids = listOf("t_1"),
|
||||||
|
assignee = "researcher",
|
||||||
|
reclaimFirst = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val unassignJson =
|
||||||
|
json.encodeToString(
|
||||||
|
BulkTasksBody(
|
||||||
|
ids = listOf("t_1"),
|
||||||
|
assignee = "",
|
||||||
|
reclaimFirst = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(assignJson.contains("\"assignee\":\"researcher\""))
|
||||||
|
assertTrue(assignJson.contains("\"reclaim_first\":true"))
|
||||||
|
assertTrue(unassignJson.contains("\"assignee\":\"\""))
|
||||||
|
assertTrue(unassignJson.contains("\"reclaim_first\":true"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -63,4 +63,20 @@ class KanbanModelOverrideTest {
|
|||||||
assertEquals("low", patch.reasoningEffort)
|
assertEquals("low", patch.reasoningEffort)
|
||||||
assertFalse(patch.clearReasoningEffort)
|
assertFalse(patch.clearReasoningEffort)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFullReasoningScaleAndCapabilities() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"),
|
||||||
|
supportedKanbanReasoningEfforts(supportsReasoning = true, canDisableReasoning = true),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("minimal", "low", "medium", "high", "xhigh", "max", "ultra"),
|
||||||
|
supportedKanbanReasoningEfforts(supportsReasoning = true, canDisableReasoning = false),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(""),
|
||||||
|
supportedKanbanReasoningEfforts(supportsReasoning = false, canDisableReasoning = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -374,6 +374,40 @@ class KanbanMutationTest {
|
|||||||
assertEquals("Board created: Sprint 2", vm.uiState.value.toastMessage)
|
assertEquals("Board created: Sprint 2", vm.uiState.value.toastMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBulkAssignUsesReclaimAndRetainsOnlyFailures() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
coEvery { mockRepository.bulkTasks("dev", any()) } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
BulkTasksResponse(
|
||||||
|
results =
|
||||||
|
listOf(
|
||||||
|
BulkTaskResult("t_1", ok = true),
|
||||||
|
BulkTaskResult("t_2", ok = false, error = "locked"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
var failedIds = emptySet<String>()
|
||||||
|
vm.bulkAssign(listOf("t_1", "t_2"), assignee = null) { failedIds = it }
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.bulkTasks(
|
||||||
|
"dev",
|
||||||
|
match { it.ids == listOf("t_1", "t_2") && it.assignee == "" && it.reclaimFirst },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(setOf("t_2"), failedIds)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testRenameBoardSuccess() =
|
fun testRenameBoardSuccess() =
|
||||||
runTest(testDispatcher) {
|
runTest(testDispatcher) {
|
||||||
@@ -408,11 +442,13 @@ class KanbanMutationTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
|
preferences.setSelectedBoard("http://127.0.0.1:9119", "old-board")
|
||||||
vm.deleteBoard("old-board")
|
vm.deleteBoard("old-board")
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
coVerify { mockRepository.deleteBoard("old-board", delete = false) }
|
coVerify { mockRepository.deleteBoard("old-board", delete = false) }
|
||||||
assertEquals("Board archived", vm.uiState.value.toastMessage)
|
assertEquals("Board archived", vm.uiState.value.toastMessage)
|
||||||
|
assertEquals(null, preferences.getSelectedBoard("http://127.0.0.1:9119"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import io.mockk.mockk
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
@@ -31,10 +32,11 @@ class KanbanTaskViewModelTest {
|
|||||||
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
||||||
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
||||||
|
|
||||||
private fun createViewModel(): KanbanTaskViewModel =
|
private fun createViewModel(enableFallbackPolling: Boolean = false): KanbanTaskViewModel =
|
||||||
KanbanTaskViewModel(
|
KanbanTaskViewModel(
|
||||||
repository = mockRepository,
|
repository = mockRepository,
|
||||||
eventsClientProvider = { mockEventsClient },
|
eventsClientProvider = { mockEventsClient },
|
||||||
|
enableFallbackPolling = enableFallbackPolling,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
@@ -285,4 +287,38 @@ class KanbanTaskViewModelTest {
|
|||||||
assertTrue(deleted)
|
assertTrue(deleted)
|
||||||
assertEquals("Task deleted", vm.uiState.value.toastMessage)
|
assertEquals("Task deleted", vm.uiState.value.toastMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFallbackPollingReloadsTaskWithoutEvents() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val first = KanbanTaskFull(id = "t_1", title = "Before", status = "todo")
|
||||||
|
val second = KanbanTaskFull(id = "t_1", title = "After", status = "todo")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returnsMany
|
||||||
|
listOf(
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = first)),
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = second)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel(enableFallbackPolling = true)
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.runCurrent()
|
||||||
|
assertEquals(
|
||||||
|
"Before",
|
||||||
|
vm.uiState.value.detail
|
||||||
|
?.task
|
||||||
|
?.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
testDispatcher.scheduler.advanceTimeBy(30_000L)
|
||||||
|
testDispatcher.scheduler.runCurrent()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"After",
|
||||||
|
vm.uiState.value.detail
|
||||||
|
?.task
|
||||||
|
?.title,
|
||||||
|
)
|
||||||
|
coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev") }
|
||||||
|
vm.onCleared()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.m57.hermescontrol.util
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.net.Uri
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okio.Buffer
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class StreamingUriRequestBodyTest {
|
||||||
|
@Test
|
||||||
|
fun testStreamsDirectlyIntoSink() {
|
||||||
|
val testData = "test archive data streaming content".toByteArray(Charsets.UTF_8)
|
||||||
|
val mockResolver = mockk<ContentResolver>()
|
||||||
|
val mockUri = mockk<Uri>()
|
||||||
|
|
||||||
|
every { mockResolver.openInputStream(mockUri) } answers {
|
||||||
|
ByteArrayInputStream(testData)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaType = "application/gzip".toMediaTypeOrNull()
|
||||||
|
val requestBody =
|
||||||
|
StreamingUriRequestBody(
|
||||||
|
contentResolver = mockResolver,
|
||||||
|
uri = mockUri,
|
||||||
|
contentType = mediaType,
|
||||||
|
contentLength = testData.size.toLong(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(mediaType, requestBody.contentType())
|
||||||
|
assertEquals(testData.size.toLong(), requestBody.contentLength())
|
||||||
|
|
||||||
|
val buffer = Buffer()
|
||||||
|
requestBody.writeTo(buffer)
|
||||||
|
|
||||||
|
assertEquals("test archive data streaming content", buffer.readUtf8())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testThrowsWhenInputStreamCannotBeOpened() {
|
||||||
|
val mockResolver = mockk<ContentResolver>()
|
||||||
|
val mockUri = mockk<Uri>()
|
||||||
|
|
||||||
|
every { mockResolver.openInputStream(mockUri) } returns null
|
||||||
|
|
||||||
|
val requestBody =
|
||||||
|
StreamingUriRequestBody(
|
||||||
|
contentResolver = mockResolver,
|
||||||
|
uri = mockUri,
|
||||||
|
contentType = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
var thrown = false
|
||||||
|
try {
|
||||||
|
val buffer = Buffer()
|
||||||
|
requestBody.writeTo(buffer)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
thrown = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(thrown)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user