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 package com.m57.hermescontrol.data.model
import com.google.gson.JsonElement
data class RawConfigResponse( data class RawConfigResponse(
val path: String?, val path: String?,
val yaml: String?, val yaml: String?,
@@ -9,3 +11,20 @@ data class UpdateRawConfigRequest(
val yaml_text: String, val yaml_text: String,
val profile: String? = null, 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 package com.m57.hermescontrol.data.remote
import com.google.gson.JsonElement
import com.m57.hermescontrol.data.model.AchievementsResponse import com.m57.hermescontrol.data.model.AchievementsResponse
import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AgentPluginInstallBody 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.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateWebhookRequest import com.m57.hermescontrol.data.model.CreateWebhookRequest
@@ -222,6 +225,20 @@ interface HermesApiService {
@Body body: UpdateRawConfigRequest, @Body body: UpdateRawConfigRequest,
): Response<Unit> ): 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") @GET("api/mcp/servers")
suspend fun getMcpServers(): Response<McpServersResponse> suspend fun getMcpServers(): Response<McpServersResponse>
@@ -1,6 +1,6 @@
package com.m57.hermescontrol.ui.config 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api 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.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField 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.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -26,14 +47,21 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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.R
import com.m57.hermescontrol.data.model.SchemaField
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.ToastEffect
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -44,10 +72,9 @@ fun ConfigScreen(
viewModel: ConfigViewModel = viewModel { ConfigViewModel() }, viewModel: ConfigViewModel = viewModel { ConfigViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
var editingText by remember(state.yamlText) { mutableStateOf(state.yamlText ?: "") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadRawConfig() viewModel.loadAll()
} }
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
@@ -56,92 +83,661 @@ fun ConfigScreen(
title = { Text(stringResource(R.string.config_screen_title)) }, title = { Text(stringResource(R.string.config_screen_title)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, 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 -> ) { paddingValues ->
when { when {
state.isLoading && state.yamlText == null -> { state.isLoading && state.config == null -> {
LoadingState(modifier = Modifier.padding(paddingValues)) LoadingState(modifier = Modifier.padding(paddingValues))
} }
state.errorMessage != null -> { state.errorMessage != null && state.config == null -> {
ErrorState( ErrorState(
message = state.errorMessage ?: "", message = state.errorMessage ?: "",
onRetry = { viewModel.loadRawConfig() }, onRetry = { viewModel.loadAll() },
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
) )
} }
else -> { else -> {
Box(Modifier.fillMaxSize()) { ConfigContent(
if (state.isLoading && state.yamlText == null) { state = state,
CircularProgressIndicator() onModeToggle = viewModel::toggleYamlMode,
} else if (state.errorMessage != null && state.yamlText == null) { onSearchQueryChange = viewModel::setSearchQuery,
Column( onCategoryChange = viewModel::setActiveCategory,
modifier = Modifier.padding(16.dp), onFieldChange = viewModel::updateField,
horizontalAlignment = Alignment.CenterHorizontally, 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) if (state.isSaving) {
Spacer(modifier = Modifier.height(16.dp)) CircularProgressIndicator(
Button(onClick = { viewModel.loadRawConfig() }) { modifier = Modifier.width(16.dp),
Text(stringResource(R.string.action_retry)) strokeWidth = 2.dp,
}
}
} 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),
) )
} } else {
Icon(
OutlinedTextField( imageVector = Icons.Filled.Save,
value = editingText, contentDescription = null,
onValueChange = { editingText = it }, modifier = Modifier.width(16.dp),
modifier = )
Modifier Spacer(modifier = Modifier.width(4.dp))
.fillMaxWidth() Text("Save", style = MaterialTheme.typography.labelMedium)
.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))
}
}
} }
} }
} }
} }
} }
} }
// ── 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.ViewModel
import androidx.lifecycle.viewModelScope 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.model.UpdateRawConfigRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
@@ -19,8 +23,17 @@ import kotlinx.coroutines.withContext
data class ConfigUiState( data class ConfigUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isSaving: 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 path: String? = null,
val yamlText: 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 errorMessage: String? = null,
val toastMessage: String? = null, val toastMessage: String? = null,
) )
@@ -29,53 +42,162 @@ class ConfigViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(ConfigUiState()) private val _uiState = MutableStateFlow(ConfigUiState())
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow() val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
fun loadRawConfig() { private val pendingChanges = mutableMapOf<String, JsonElement>()
init {
loadAll()
}
fun loadAll() {
safeLaunchLoad( safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getRawConfig() } }, apiCall = {
safeApiCall { ApiClient.hermesApi.getConfig() }
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data -> onSuccess = { configData ->
_uiState.update { viewModelScope.launch {
it.copy( val schemaResult =
isLoading = false, withContext(Dispatchers.IO) {
path = data.path, safeApiCall { ApiClient.hermesApi.getConfigSchema() }
yamlText = data.yaml, }
) 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 -> onError = { errorMsg ->
_uiState.update { _uiState.update {
it.copy( it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg")
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) } _uiState.update { it.copy(isSaving = true) }
viewModelScope.launch { viewModelScope.launch {
val changeset = buildChangeset(pendingChanges)
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.updateRawConfig(UpdateRawConfigRequest(yamlText)) } safeApiCall {
ApiClient.hermesApi.updateConfig(
ConfigUpdateRequest(config = changeset),
)
}
} }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
pendingChanges.clear()
val configResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfig() }
}
_uiState.update { _uiState.update {
it.copy( it.copy(
isSaving = false, isSaving = false,
yamlText = yamlText, config = (configResult as? NetworkResult.Success)?.data ?: it.config,
modifiedKeys = emptySet(),
toastMessage = "Configuration saved successfully", toastMessage = "Configuration saved successfully",
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isSaving = false, 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() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _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 }
}
} }
+2 -1
View File
@@ -336,7 +336,8 @@
<string name="config_screen_title">Configuration</string> <string name="config_screen_title">Configuration</string>
<string name="config_placeholder_yaml">YAML Config...</string> <string name="config_placeholder_yaml">YAML Config...</string>
<string name="config_action_reset">Reset</string> <string name="config_action_reset">Reset</string>
<string name="config_action_save">Save Config</string> <string name="config_action_save">Save</string>
<string name="config_action_save_yaml">Save YAML</string>
<!-- Profiles Screen --> <!-- Profiles Screen -->
<string name="profiles_empty_title">No profiles available</string> <string name="profiles_empty_title">No profiles available</string>