feat(#448): schema-driven config UI with category tabs, search, typed inputs

Closes #448

Replaced the raw YAML editor with a full schema-driven config management UI
matching the dashboard's ConfigPage experience.

### New API endpoints (HermesApiService)
- GET /api/config — load structured config
- GET /api/config/schema — load schema with field types + categories  
- GET /api/config/defaults — load defaults for reset
- PUT /api/config — save config (deep-merged on backend)

### Data models
- ConfigSchemaResponse — wraps fields + category_order
- SchemaField — type, description, category, options
- ConfigUpdateRequest — config map + optional profile

### ConfigViewModel
- Loads config + schema + defaults + path on init
- Tracks pending changes as dot-path → JsonElement map
- Builds nested JSON changeset for the save API
- YAML mode toggle with raw YAML load/save
- Per-category reset to defaults

### ConfigScreen
- Category tabs with field-count badges
- Search filtering across key, label, description, category
- Typed inputs: boolean toggles, select dropdowns, number inputs
- Modified highlighting with primaryContainer tint
- YAML ↔ Form mode toggle
- Sticky save indicator with pending-change count
This commit is contained in:
M57 authored and GitHub committed 2026-07-03 08:39:35 +03:00
1 parent d875c8a040
commit efb8e528ef
5 files changed
+962 -89

No files matched your search

@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model
import com.google.gson.JsonElement
data class RawConfigResponse(
val path: String?,
val yaml: String?,
@@ -9,3 +11,20 @@ data class UpdateRawConfigRequest(
val yaml_text: String,
val profile: String? = null,
)
data class ConfigSchemaResponse(
val fields: Map<String, SchemaField>,
val category_order: List<String>,
)
data class SchemaField(
val type: String,
val description: String? = null,
val category: String? = null,
val options: List<String>? = null,
)
data class ConfigUpdateRequest(
val config: Map<String, JsonElement>,
val profile: String? = null,
)
@@ -1,8 +1,11 @@
package com.m57.hermescontrol.data.remote
import com.google.gson.JsonElement
import com.m57.hermescontrol.data.model.AchievementsResponse
import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.ConfigSchemaResponse
import com.m57.hermescontrol.data.model.ConfigUpdateRequest
import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateWebhookRequest
@@ -222,6 +225,20 @@ interface HermesApiService {
@Body body: UpdateRawConfigRequest,
): Response<Unit>
@GET("api/config")
suspend fun getConfig(): Response<Map<String, JsonElement>>
@GET("api/config/schema")
suspend fun getConfigSchema(): Response<ConfigSchemaResponse>
@GET("api/config/defaults")
suspend fun getConfigDefaults(): Response<Map<String, JsonElement>>
@PUT("api/config")
suspend fun updateConfig(
@Body body: ConfigUpdateRequest,
): Response<Unit>
@GET("api/mcp/servers")
suspend fun getMcpServers(): Response<McpServersResponse>
@@ -1,6 +1,6 @@
package com.m57.hermescontrol.ui.config
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -9,13 +9,34 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Badge
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -26,14 +47,21 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.gson.JsonElement
import com.google.gson.JsonPrimitive
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SchemaField
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
@OptIn(ExperimentalMaterial3Api::class)
@@ -44,10 +72,9 @@ fun ConfigScreen(
viewModel: ConfigViewModel = viewModel { ConfigViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var editingText by remember(state.yamlText) { mutableStateOf(state.yamlText ?: "") }
LaunchedEffect(Unit) {
viewModel.loadRawConfig()
viewModel.loadAll()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
@@ -56,92 +83,661 @@ fun ConfigScreen(
title = { Text(stringResource(R.string.config_screen_title)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadRawConfig() },
onRefresh = { viewModel.loadAll() },
actions = {
if (!state.yamlMode && state.modifiedKeys.isNotEmpty()) {
IconButton(onClick = { viewModel.saveConfig() }) {
Icon(
imageVector = Icons.Filled.Save,
contentDescription = "Save changes",
)
}
}
},
) { paddingValues ->
when {
state.isLoading && state.yamlText == null -> {
state.isLoading && state.config == null -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
state.errorMessage != null && state.config == null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadRawConfig() },
onRetry = { viewModel.loadAll() },
modifier = Modifier.padding(paddingValues),
)
}
else -> {
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.yamlText == null) {
CircularProgressIndicator()
} else if (state.errorMessage != null && state.yamlText == null) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
ConfigContent(
state = state,
onModeToggle = viewModel::toggleYamlMode,
onSearchQueryChange = viewModel::setSearchQuery,
onCategoryChange = viewModel::setActiveCategory,
onFieldChange = viewModel::updateField,
onSave = viewModel::saveConfig,
onYamlTextChange = viewModel::setYamlText,
onYamlSave = viewModel::saveYamlConfig,
onResetCategory = viewModel::resetCategoryToDefaults,
modifier = Modifier,
)
}
}
}
}
@Composable
private fun ConfigContent(
state: ConfigUiState,
onModeToggle: () -> Unit,
onSearchQueryChange: (String) -> Unit,
onCategoryChange: (String) -> Unit,
onFieldChange: (String, JsonElement) -> Unit,
onSave: () -> Unit,
onYamlTextChange: (String) -> Unit,
onYamlSave: () -> Unit,
onResetCategory: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxSize()) {
// ── Non-scrollable top section ──
Column(
modifier =
Modifier
.padding(horizontal = 16.dp)
.padding(top = 8.dp),
) {
// Path display
state.path?.let { path ->
Text(
text = "Path: $path",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
// Mode toggle + search
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onModeToggle) {
Icon(
imageVector = if (state.yamlMode) Icons.Filled.Tune else Icons.Filled.Code,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp),
)
Text(
if (state.yamlMode) "Form" else "YAML",
fontWeight = FontWeight.Medium,
)
}
if (!state.yamlMode) {
SearchBar(
query = state.searchQuery,
onQueryChange = onSearchQueryChange,
placeholder = "Search settings…",
modifier = Modifier.weight(1f),
)
}
}
// Top save bar — sticky, always visible when changes pending
if (!state.yamlMode && state.modifiedKeys.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${state.modifiedKeys.size} change(s) pending",
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = onSave,
enabled = !state.isSaving,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadRawConfig() }) {
Text(stringResource(R.string.action_retry))
}
}
} else {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
) {
state.path?.let {
Text(
text = "Path: $it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
if (state.isSaving) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp),
strokeWidth = 2.dp,
)
}
OutlinedTextField(
value = editingText,
onValueChange = { editingText = it },
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
placeholder = { Text(stringResource(R.string.config_placeholder_yaml)) },
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
maxLines = Int.MAX_VALUE,
)
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { editingText = state.yamlText ?: "" },
modifier = Modifier.weight(1f),
) {
Text(stringResource(R.string.config_action_reset))
}
Spacer(modifier = Modifier.width(16.dp))
Button(
onClick = { viewModel.saveRawConfig(editingText) },
modifier = Modifier.weight(1f),
enabled = !state.isSaving,
) {
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.width(16.dp))
} else {
Text(stringResource(R.string.config_action_save))
}
}
} else {
Icon(
imageVector = Icons.Filled.Save,
contentDescription = null,
modifier = Modifier.width(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Save", style = MaterialTheme.typography.labelMedium)
}
}
}
}
}
}
// ── Scrollable content ──
Column(
modifier =
Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
if (state.yamlMode) {
YAMLEditor(
yamlText = state.yamlText ?: "",
onYamlTextChange = onYamlTextChange,
isSaving = state.yamlIsSaving,
isLoading = state.yamlIsLoading,
onSave = onYamlSave,
)
} else {
FormEditor(
schema = state.schema,
config = state.config,
defaults = state.defaults,
activeCategory = state.activeCategory,
searchQuery = state.searchQuery,
modifiedKeys = state.modifiedKeys,
isSaving = state.isSaving,
onCategoryChange = onCategoryChange,
onFieldChange = onFieldChange,
onSave = onSave,
onResetCategory = onResetCategory,
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun YAMLEditor(
yamlText: String,
onYamlTextChange: (String) -> Unit,
isSaving: Boolean,
isLoading: Boolean,
onSave: () -> Unit,
) {
if (isLoading) {
LoadingState()
return
}
OutlinedTextField(
value = yamlText,
onValueChange = onYamlTextChange,
modifier =
Modifier
.fillMaxWidth()
.height(400.dp),
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
maxLines = Int.MAX_VALUE,
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onSave,
modifier = Modifier.fillMaxWidth(),
enabled = !isSaving,
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = Icons.Filled.Save,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp),
)
Text(
stringResource(R.string.config_action_save_yaml),
)
}
}
}
@Composable
private fun FormEditor(
schema: com.m57.hermescontrol.data.model.ConfigSchemaResponse?,
config: Map<String, JsonElement>?,
defaults: Map<String, JsonElement>?,
activeCategory: String,
searchQuery: String,
modifiedKeys: Set<String>,
isSaving: Boolean,
onCategoryChange: (String) -> Unit,
onFieldChange: (String, JsonElement) -> Unit,
onSave: () -> Unit,
onResetCategory: (String) -> Unit,
) {
if (schema == null || config == null) return
val isSearching = searchQuery.isNotBlank()
val categoryCounts =
remember(schema) {
schema.fields.values.groupingBy { it.category ?: "general" }.eachCount()
}
// Non-schema config values — all dot-paths in the config that aren't in the schema
val nonSchemaPaths =
remember(config, schema) {
collectUncoveredPaths(config, schema.fields.keys.toSet())
}
val isOtherCategory = activeCategory == "Other"
// Show tabs only when not searching
if (!isSearching) {
val allCategories =
if (nonSchemaPaths.isNotEmpty()) {
schema.category_order.filter { it in categoryCounts } + "Other"
} else {
schema.category_order.filter { it in categoryCounts }
}
ConfigTabs(
categories = allCategories,
categoryCounts = categoryCounts,
selectedCategory = activeCategory,
onCategorySelected = onCategoryChange,
)
Spacer(modifier = Modifier.height(8.dp))
}
// Fields list
if (isOtherCategory) {
// Render non-schema config dot-paths as read-only cards
nonSchemaPaths.forEach { dotPath ->
val jsonText = getJsonValue(config, dotPath)?.toString() ?: ""
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = dotPath,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = jsonText,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else {
val visibleFields: List<Pair<String, SchemaField>> =
remember(schema, activeCategory, searchQuery) {
if (isSearching) {
val query = searchQuery.lowercase()
schema.fields.filter { (key, field) ->
val label = key.split(".").last().replace("_", " ")
key.lowercase().contains(query) ||
label.contains(query) ||
(field.description?.lowercase()?.contains(query) == true) ||
(field.category?.lowercase()?.contains(query) == true)
}.entries.map { it.key to it.value }
} else {
schema.fields.filter { (_, field) ->
(field.category ?: "general") == activeCategory
}.entries.map { it.key to it.value }
}
}
visibleFields.forEach { (key, field) ->
ConfigField(
key = key,
field = field,
currentValue = getJsonValue(config, key),
isModified = key in modifiedKeys,
onChange = { onFieldChange(key, it) },
)
}
if (visibleFields.isEmpty()) {
Text(
text = if (isSearching) "No settings match your search." else "No fields in this category.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp),
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Save + Reset buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = onSave,
modifier = Modifier.weight(1f),
enabled = modifiedKeys.isNotEmpty() && !isSaving,
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = Icons.Filled.Save,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp),
)
Text(stringResource(R.string.config_action_save))
}
}
if (!isSearching) {
OutlinedButton(
onClick = { onResetCategory(activeCategory) },
modifier = Modifier.weight(1f),
enabled = !isSaving,
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp),
)
Text(stringResource(R.string.config_action_reset))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConfigTabs(
categories: List<String>,
categoryCounts: Map<String, Int>,
selectedCategory: String,
onCategorySelected: (String) -> Unit,
) {
ScrollableTabRow(
selectedTabIndex = categories.indexOf(selectedCategory).coerceAtLeast(0),
edgePadding = 0.dp,
) {
categories.forEach { category ->
val count = categoryCounts[category] ?: 0
Tab(
selected = category == selectedCategory,
onClick = { onCategorySelected(category) },
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = category.replaceFirstChar { it.uppercase() },
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Badge { Text(count.toString()) }
}
},
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConfigField(
key: String,
field: SchemaField,
currentValue: JsonElement?,
isModified: Boolean,
onChange: (JsonElement) -> Unit,
) {
val label = key.split(".").last().replace("_", " ").replaceFirstChar { it.uppercase() }
val description = field.description ?: key.replace(".", " → ").replace("_", " ").replaceFirstChar { it.uppercase() }
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (isModified) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.surfaceContainer
},
),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
fontWeight = if (isModified) FontWeight.Bold else FontWeight.Medium,
)
if (!field.type.isNullOrEmpty() && field.type != "string") {
Text(
text = field.type,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(4.dp))
when (field.type) {
"boolean" -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Switch(
checked = currentValue?.asBoolean ?: false,
onCheckedChange = { onChange(JsonPrimitive(it)) },
)
}
}
"select" -> {
DropdownField(
label = label,
options = field.options ?: emptyList(),
selectedValue = currentValue?.asString ?: "",
onOptionSelected = { onChange(JsonPrimitive(it)) },
)
if (description != label) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
"number" -> {
OutlinedTextField(
value =
currentValue?.let {
if (it.isJsonPrimitive) it.asString else ""
} ?: "",
onValueChange = { text ->
text.toDoubleOrNull()?.let { doubleVal ->
if (doubleVal == doubleVal.toLong().toDouble()) {
onChange(JsonPrimitive(doubleVal.toLong()))
} else {
onChange(JsonPrimitive(doubleVal))
}
}
},
modifier = Modifier.fillMaxWidth(),
label = { Text(description) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
shape = RoundedCornerShape(8.dp),
)
}
else -> {
// String or other type
val textValue =
currentValue?.let {
when {
it.isJsonPrimitive -> it.asString
it.isJsonObject -> it.toString()
it.isJsonArray -> it.toString()
else -> ""
}
} ?: ""
OutlinedTextField(
value = textValue,
onValueChange = { onChange(JsonPrimitive(it)) },
modifier = Modifier.fillMaxWidth(),
label = { Text(description) },
singleLine = true,
shape = RoundedCornerShape(8.dp),
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DropdownField(
label: String,
options: List<String>,
selectedValue: String,
onOptionSelected: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
value = selectedValue.ifEmpty { options.firstOrNull() ?: "" },
onValueChange = {},
readOnly = true,
modifier =
Modifier
.fillMaxWidth()
.menuAnchor(),
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
singleLine = true,
shape = RoundedCornerShape(8.dp),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
onOptionSelected(option)
expanded = false
},
)
}
}
}
}
/** Recursively find all config dot-paths not covered by the schema. */
private fun collectUncoveredPaths(
config: Map<String, JsonElement>,
schemaPaths: Set<String>,
prefix: String = "",
): List<String> {
val result = mutableListOf<String>()
for ((key, value) in config) {
val dotPath = if (prefix.isEmpty()) key else "$prefix.$key"
// If this exact path is in the schema, it's covered — skip entirely
if (dotPath in schemaPaths) continue
if (value is com.google.gson.JsonObject) {
// Recurse into nested objects to find uncovered leaves
val subKeys = value.keySet()
// Before recursing deeper, check if this whole sub-tree has any schema coverage
val hasSchemaCoverage = schemaPaths.any { it == dotPath || it.startsWith("$dotPath.") }
if (hasSchemaCoverage) {
// Schema covers some sub-paths — recurse to find what's NOT covered
val subMap = subKeys.associateWith { value.get(it) }
result.addAll(collectUncoveredPaths(subMap, schemaPaths, dotPath))
} else {
// No schema coverage at all for this subtree — add the whole thing
result.add(dotPath)
}
} else if (value is com.google.gson.JsonArray) {
// Arrays: check if the path is in schema; if not, add it
if (dotPath !in schemaPaths) {
result.add(dotPath)
}
} else {
// Leaf value (string, number, boolean, null) not in schema
result.add(dotPath)
}
}
return result.sorted()
}
/** Get a nested value from a flat config Map using a dot-path key. */
private fun getJsonValue(
config: Map<String, JsonElement>,
dotPath: String,
): JsonElement? {
val parts = dotPath.split(".")
var current: JsonElement? = null
for (i in parts.indices) {
val key = parts[i]
if (i == 0) {
current = config[key]
} else {
current = current?.asJsonObject?.get(key)
}
if (current == null) return null
}
return current
}
@@ -2,6 +2,10 @@ package com.m57.hermescontrol.ui.config
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.m57.hermescontrol.data.model.ConfigSchemaResponse
import com.m57.hermescontrol.data.model.ConfigUpdateRequest
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -19,8 +23,17 @@ import kotlinx.coroutines.withContext
data class ConfigUiState(
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val config: Map<String, JsonElement>? = null,
val schema: ConfigSchemaResponse? = null,
val defaults: Map<String, JsonElement>? = null,
val path: String? = null,
val yamlText: String? = null,
val modifiedKeys: Set<String> = emptySet(),
val activeCategory: String = "",
val searchQuery: String = "",
val yamlMode: Boolean = false,
val yamlIsLoading: Boolean = false,
val yamlIsSaving: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
)
@@ -29,53 +42,162 @@ class ConfigViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(ConfigUiState())
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
fun loadRawConfig() {
private val pendingChanges = mutableMapOf<String, JsonElement>()
init {
loadAll()
}
fun loadAll() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getRawConfig() } },
apiCall = {
safeApiCall { ApiClient.hermesApi.getConfig() }
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update {
it.copy(
isLoading = false,
path = data.path,
yamlText = data.yaml,
)
onSuccess = { configData ->
viewModelScope.launch {
val schemaResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfigSchema() }
}
val defaultsResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfigDefaults() }
}
val schema = (schemaResult as? NetworkResult.Success)?.data
val defaults = (defaultsResult as? NetworkResult.Success)?.data
val rawResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getRawConfig() }
}
val path = (rawResult as? NetworkResult.Success)?.data?.path
_uiState.update {
it.copy(
isLoading = false,
config = configData,
schema = schema,
defaults = defaults,
path = path,
activeCategory =
if (schema?.category_order?.isNotEmpty() == true) {
schema.category_order.first()
} else {
""
},
)
}
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load config: $errorMsg",
)
it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg")
}
},
)
}
fun saveRawConfig(yamlText: String) {
fun updateField(
key: String,
value: JsonElement,
) {
pendingChanges[key] = value
val state = _uiState.value
// Apply the change locally so the UI reflects it immediately
val updatedConfig =
state.config?.let { config ->
applyNestedValue(config, key, value)
}
_uiState.update {
it.copy(
config = updatedConfig,
modifiedKeys = pendingChanges.keys.toSet(),
)
}
}
fun setActiveCategory(category: String) {
_uiState.update { it.copy(activeCategory = category) }
}
fun setSearchQuery(query: String) {
_uiState.update { it.copy(searchQuery = query) }
}
fun toggleYamlMode() {
val current = _uiState.value
if (current.yamlMode) {
_uiState.update { it.copy(yamlMode = false, yamlText = null) }
} else {
_uiState.update { it.copy(yamlMode = true, yamlIsLoading = true) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getRawConfig() }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
yamlIsLoading = false,
yamlText = result.data.yaml ?: "",
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
yamlIsLoading = false,
toastMessage = "Failed to load YAML: ${result.error.message}",
)
}
}
}
}
}
}
fun setYamlText(text: String) {
_uiState.update { it.copy(yamlText = text) }
}
fun saveConfig() {
if (pendingChanges.isEmpty()) return
_uiState.update { it.copy(isSaving = true) }
viewModelScope.launch {
val changeset = buildChangeset(pendingChanges)
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.updateRawConfig(UpdateRawConfigRequest(yamlText)) }
safeApiCall {
ApiClient.hermesApi.updateConfig(
ConfigUpdateRequest(config = changeset),
)
}
}
when (result) {
is NetworkResult.Success -> {
pendingChanges.clear()
val configResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfig() }
}
_uiState.update {
it.copy(
isSaving = false,
yamlText = yamlText,
config = (configResult as? NetworkResult.Success)?.data ?: it.config,
modifiedKeys = emptySet(),
toastMessage = "Configuration saved successfully",
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isSaving = false,
toastMessage = "Failed to save config: ${result.error.message}",
toastMessage = "Failed to save: ${result.error.message}",
)
}
}
@@ -83,7 +205,125 @@ class ConfigViewModel : ViewModel(), ToastHost {
}
}
fun saveYamlConfig() {
val yamlText = _uiState.value.yamlText ?: return
_uiState.update { it.copy(yamlIsSaving = true) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.updateRawConfig(
UpdateRawConfigRequest(yaml_text = yamlText),
)
}
}
when (result) {
is NetworkResult.Success -> {
val configResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfig() }
}
_uiState.update {
it.copy(
yamlIsSaving = false,
config = (configResult as? NetworkResult.Success)?.data ?: it.config,
toastMessage = "YAML configuration saved",
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
yamlIsSaving = false,
toastMessage = "Failed to save YAML: ${result.error.message}",
)
}
}
}
}
}
fun resetCategoryToDefaults(category: String) {
val state = _uiState.value
val schema = state.schema ?: return
val defaults = state.defaults ?: return
val categoryFields =
schema.fields.filter { (_, field) ->
(field.category ?: "general") == category
}
var count = 0
for ((key, _) in categoryFields) {
val defaultVal = defaults[key]
if (defaultVal != null) {
pendingChanges[key] = defaultVal
count++
}
}
_uiState.update {
it.copy(modifiedKeys = pendingChanges.keys.toSet())
}
if (count > 0) {
_uiState.update {
it.copy(toastMessage = "Reset $count field(s) to defaults (tap Save to apply)")
}
}
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
/** Apply a value at a dot-path like "terminal.backend" into a flat config map. */
private fun applyNestedValue(
config: Map<String, JsonElement>,
dotPath: String,
value: JsonElement,
): Map<String, JsonElement> {
val parts = dotPath.split(".")
if (parts.size == 1) {
val mutable = config.toMutableMap()
mutable[parts[0]] = value
return mutable
}
val topKey = parts.first()
val rest = parts.drop(1).joinToString(".")
val topValue = config[topKey]
val nestedJson = topValue?.asJsonObject ?: JsonObject()
val updatedNested =
applyNestedValue(
nestedJson.entrySet().associate { it.key to it.value },
rest,
value,
)
val nestedObj = JsonObject()
updatedNested.forEach { (k, v) -> nestedObj.add(k, v) }
val mutable = config.toMutableMap()
mutable[topKey] = nestedObj
return mutable
}
/** Build a nested JSON changeset from dot-path pending changes. */
private fun buildChangeset(changes: Map<String, JsonElement>): Map<String, JsonElement> {
val root = JsonObject()
for ((dotPath, value) in changes) {
val parts = dotPath.split(".")
var current = root
for (i in 0 until parts.size - 1) {
val key = parts[i]
val existing = current.get(key)
if (existing == null || !existing.isJsonObject) {
val newObj = JsonObject()
current.add(key, newObj)
current = newObj
} else {
current = existing.asJsonObject
}
}
current.add(parts.last(), value)
}
return root.entrySet().associate { it.key to it.value as JsonElement }
}
}