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:
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 }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user