feat(config): redo config tab — lazy list, full-coverage search, per-field reset (#898)
- LazyColumn + precomputed flat dot-path value map: O(1) lookups, only
visible fields compose (829 live leaves on a real config)
- Search now matches keys, labels, descriptions, categories, CURRENT
values and select options; non-schema ('Other') paths are searchable too.
Result count + category chip per hit while searching
- Per-field reset-to-default icon; clear-to-blank honoring schema
'clearable'; searchable dropdown honoring schema 'searchable'
(select-all-free fresh filter, exact-match commits on blur)
- Number/object/list editors keep local text state: intermediate typing
('0.') no longer drops keystrokes; objects/lists edit as multi-line JSON
with validation
- Honor backend searchable/clearable schema flags (SchemaField model)
- Category reset now previews default values immediately
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
90c1e8dc5f
commit
9e454ce67b
5 files changed
+770
-215
No files matched your search
@@ -26,6 +26,8 @@ data class SchemaField(
|
|||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val category: String? = null,
|
val category: String? = null,
|
||||||
val options: List<String>? = null,
|
val options: List<String>? = null,
|
||||||
|
val searchable: Boolean = false,
|
||||||
|
val clearable: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.m57.hermescontrol.ui.config
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.SchemaField
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One renderable row of the config form: a schema-driven field or an
|
||||||
|
* uncovered ("Other") config path. All rows are FLAT dot-paths — the nested
|
||||||
|
* config response is flattened once on load so the form does O(1) lookups.
|
||||||
|
*/
|
||||||
|
data class ConfigRow(
|
||||||
|
val key: String,
|
||||||
|
val field: SchemaField?,
|
||||||
|
val value: JsonElement?,
|
||||||
|
) {
|
||||||
|
/** Human label: last path segment, underscores → spaces. */
|
||||||
|
val label: String =
|
||||||
|
key.split(".").last().replace("_", " ").replaceFirstChar { it.uppercase() }
|
||||||
|
|
||||||
|
/** Search/display text of the current value. */
|
||||||
|
val valueText: String = value?.let(::jsonText) ?: ""
|
||||||
|
|
||||||
|
val category: String = if (field == null) "Other" else field.category ?: "general"
|
||||||
|
|
||||||
|
val isUncovered: Boolean = field == null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten a nested config map into dot-path → leaf-value pairs. */
|
||||||
|
fun flattenConfig(nested: Map<String, JsonElement>): Map<String, JsonElement> {
|
||||||
|
val flat = mutableMapOf<String, JsonElement>()
|
||||||
|
|
||||||
|
fun walk(
|
||||||
|
prefix: String,
|
||||||
|
map: Map<String, JsonElement>,
|
||||||
|
) {
|
||||||
|
for ((key, value) in map) {
|
||||||
|
val dotPath = if (prefix.isEmpty()) key else "$prefix.$key"
|
||||||
|
if (value is JsonObject) {
|
||||||
|
walk(dotPath, value)
|
||||||
|
} else {
|
||||||
|
flat[dotPath] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk("", nested)
|
||||||
|
return flat
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when [dotPath] is a schema key or lives under one (ancestor-or-self). */
|
||||||
|
fun isCoveredBySchema(
|
||||||
|
dotPath: String,
|
||||||
|
schemaKeys: Set<String>,
|
||||||
|
): Boolean = schemaKeys.any { dotPath == it || dotPath.startsWith("$it.") }
|
||||||
|
|
||||||
|
/** Flat dot-paths present in config but not covered by the schema (sorted). */
|
||||||
|
fun collectUncoveredPaths(
|
||||||
|
flatValues: Map<String, JsonElement>,
|
||||||
|
schemaKeys: Set<String>,
|
||||||
|
): List<String> =
|
||||||
|
flatValues.keys
|
||||||
|
.filterNot { isCoveredBySchema(it, schemaKeys) }
|
||||||
|
.sorted()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a row against a search query. Covers the key, human label, schema
|
||||||
|
* description, category, the CURRENT VALUE, and every select option — so
|
||||||
|
* searching "gpt-4o", "daytona" or "kittentts" finds the right field even
|
||||||
|
* though none of them appear in a key or description.
|
||||||
|
*/
|
||||||
|
fun rowMatchesQuery(
|
||||||
|
row: ConfigRow,
|
||||||
|
query: String,
|
||||||
|
): Boolean {
|
||||||
|
if (query.isBlank()) return true
|
||||||
|
val q = query.lowercase()
|
||||||
|
if (row.key.lowercase().contains(q)) return true
|
||||||
|
if (row.label.lowercase().contains(q)) return true
|
||||||
|
row.field?.let { field ->
|
||||||
|
if (field.description?.lowercase()?.contains(q) == true) return true
|
||||||
|
if (field.category?.lowercase()?.contains(q) == true) return true
|
||||||
|
field.options?.let { options ->
|
||||||
|
if (options.any { it.lowercase().contains(q) }) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row.valueText.lowercase().contains(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable text form of a JSON value for display and search. */
|
||||||
|
fun jsonText(value: JsonElement): String =
|
||||||
|
when (value) {
|
||||||
|
is JsonPrimitive -> value.content
|
||||||
|
is JsonArray -> value.toString()
|
||||||
|
is JsonObject -> value.toString()
|
||||||
|
}
|
||||||
@@ -1,19 +1,25 @@
|
|||||||
package com.m57.hermescontrol.ui.config
|
package com.m57.hermescontrol.ui.config
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
import androidx.compose.material.icons.filled.Code
|
import androidx.compose.material.icons.filled.Code
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.filled.Save
|
||||||
@@ -23,13 +29,18 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
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.ExposedDropdownMenuAnchorType
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
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.SecondaryScrollableTabRow
|
import androidx.compose.material3.SecondaryScrollableTabRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -37,10 +48,12 @@ 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
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
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.font.FontWeight
|
||||||
@@ -50,7 +63,7 @@ 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.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.SchemaField
|
import com.m57.hermescontrol.data.model.ConfigSchemaResponse
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
import com.m57.hermescontrol.ui.common.ExposedDropdownField
|
import com.m57.hermescontrol.ui.common.ExposedDropdownField
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
@@ -58,9 +71,8 @@ import com.m57.hermescontrol.ui.common.NavIcon
|
|||||||
import com.m57.hermescontrol.ui.common.SearchBar
|
import com.m57.hermescontrol.ui.common.SearchBar
|
||||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.booleanOrNull
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
|
||||||
@@ -96,11 +108,11 @@ fun ConfigScreen(
|
|||||||
},
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
when {
|
when {
|
||||||
state.isLoading && state.config == null -> {
|
state.isLoading && state.values == null -> {
|
||||||
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
||||||
}
|
}
|
||||||
|
|
||||||
state.errorMessage != null && state.config == null -> {
|
state.errorMessage != null && state.values == null -> {
|
||||||
ErrorState(
|
ErrorState(
|
||||||
message = state.errorMessage ?: "",
|
message = state.errorMessage ?: "",
|
||||||
onRetry = { viewModel.loadAll() },
|
onRetry = { viewModel.loadAll() },
|
||||||
@@ -115,6 +127,8 @@ fun ConfigScreen(
|
|||||||
onSearchQueryChange = viewModel::setSearchQuery,
|
onSearchQueryChange = viewModel::setSearchQuery,
|
||||||
onCategoryChange = viewModel::setActiveCategory,
|
onCategoryChange = viewModel::setActiveCategory,
|
||||||
onFieldChange = viewModel::updateField,
|
onFieldChange = viewModel::updateField,
|
||||||
|
onResetField = viewModel::resetField,
|
||||||
|
onClearField = viewModel::clearField,
|
||||||
onSave = viewModel::saveConfig,
|
onSave = viewModel::saveConfig,
|
||||||
onYamlTextChange = viewModel::setYamlText,
|
onYamlTextChange = viewModel::setYamlText,
|
||||||
onYamlSave = viewModel::saveYamlConfig,
|
onYamlSave = viewModel::saveYamlConfig,
|
||||||
@@ -133,6 +147,8 @@ private fun ConfigContent(
|
|||||||
onSearchQueryChange: (String) -> Unit,
|
onSearchQueryChange: (String) -> Unit,
|
||||||
onCategoryChange: (String) -> Unit,
|
onCategoryChange: (String) -> Unit,
|
||||||
onFieldChange: (String, JsonElement) -> Unit,
|
onFieldChange: (String, JsonElement) -> Unit,
|
||||||
|
onResetField: (String) -> Unit,
|
||||||
|
onClearField: (String) -> Unit,
|
||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
onYamlTextChange: (String) -> Unit,
|
onYamlTextChange: (String) -> Unit,
|
||||||
onYamlSave: () -> Unit,
|
onYamlSave: () -> Unit,
|
||||||
@@ -232,13 +248,6 @@ private fun ConfigContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Scrollable content ──
|
// ── Scrollable content ──
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
) {
|
|
||||||
if (state.yamlMode) {
|
if (state.yamlMode) {
|
||||||
YAMLEditor(
|
YAMLEditor(
|
||||||
yamlText = state.yamlText ?: "",
|
yamlText = state.yamlText ?: "",
|
||||||
@@ -249,22 +258,22 @@ private fun ConfigContent(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
FormEditor(
|
FormEditor(
|
||||||
|
values = state.values,
|
||||||
schema = state.schema,
|
schema = state.schema,
|
||||||
config = state.config,
|
|
||||||
defaults = state.defaults,
|
defaults = state.defaults,
|
||||||
|
uncoveredPaths = state.uncoveredPaths,
|
||||||
activeCategory = state.activeCategory,
|
activeCategory = state.activeCategory,
|
||||||
searchQuery = state.searchQuery,
|
searchQuery = state.searchQuery,
|
||||||
modifiedKeys = state.modifiedKeys,
|
modifiedKeys = state.modifiedKeys,
|
||||||
isSaving = state.isSaving,
|
isSaving = state.isSaving,
|
||||||
onCategoryChange = onCategoryChange,
|
onCategoryChange = onCategoryChange,
|
||||||
onFieldChange = onFieldChange,
|
onFieldChange = onFieldChange,
|
||||||
|
onResetField = onResetField,
|
||||||
|
onClearField = onClearField,
|
||||||
onSave = onSave,
|
onSave = onSave,
|
||||||
onResetCategory = onResetCategory,
|
onResetCategory = onResetCategory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,13 +290,19 @@ private fun YAMLEditor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = yamlText,
|
value = yamlText,
|
||||||
onValueChange = onYamlTextChange,
|
onValueChange = onYamlTextChange,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(400.dp),
|
.weight(1f),
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
maxLines = Int.MAX_VALUE,
|
maxLines = Int.MAX_VALUE,
|
||||||
)
|
)
|
||||||
@@ -315,23 +330,29 @@ private fun YAMLEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FormEditor(
|
private fun FormEditor(
|
||||||
schema: com.m57.hermescontrol.data.model.ConfigSchemaResponse?,
|
values: Map<String, JsonElement>?,
|
||||||
config: Map<String, JsonElement>?,
|
schema: ConfigSchemaResponse?,
|
||||||
defaults: Map<String, JsonElement>?,
|
defaults: Map<String, JsonElement>?,
|
||||||
|
uncoveredPaths: List<String>,
|
||||||
activeCategory: String,
|
activeCategory: String,
|
||||||
searchQuery: String,
|
searchQuery: String,
|
||||||
modifiedKeys: Set<String>,
|
modifiedKeys: Set<String>,
|
||||||
isSaving: Boolean,
|
isSaving: Boolean,
|
||||||
onCategoryChange: (String) -> Unit,
|
onCategoryChange: (String) -> Unit,
|
||||||
onFieldChange: (String, JsonElement) -> Unit,
|
onFieldChange: (String, JsonElement) -> Unit,
|
||||||
|
onResetField: (String) -> Unit,
|
||||||
|
onClearField: (String) -> Unit,
|
||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
onResetCategory: (String) -> Unit,
|
onResetCategory: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (schema == null || config == null) return
|
if (schema == null || values == null) return
|
||||||
|
|
||||||
val isSearching = searchQuery.isNotBlank()
|
val isSearching = searchQuery.isNotBlank()
|
||||||
val categoryCounts =
|
val categoryCounts =
|
||||||
@@ -341,37 +362,73 @@ private fun FormEditor(
|
|||||||
.eachCount()
|
.eachCount()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-schema config values — all dot-paths in the config that aren't in the schema
|
// Every row is a flat dot-path with an O(1) value lookup — no nested walks.
|
||||||
val nonSchemaPaths =
|
val rows: List<ConfigRow> =
|
||||||
remember(config, schema) {
|
remember(values, schema, uncoveredPaths) {
|
||||||
collectUncoveredPaths(config, schema.fields.keys.toSet())
|
val covered =
|
||||||
|
schema.fields.map { (key, field) ->
|
||||||
|
ConfigRow(key = key, field = field, value = values[key])
|
||||||
|
}
|
||||||
|
val uncovered =
|
||||||
|
uncoveredPaths.map { dotPath ->
|
||||||
|
ConfigRow(key = dotPath, field = null, value = values[dotPath])
|
||||||
|
}
|
||||||
|
covered + uncovered
|
||||||
}
|
}
|
||||||
|
|
||||||
val isOtherCategory = activeCategory == "Other"
|
val visibleRows: List<ConfigRow> =
|
||||||
|
remember(rows, activeCategory, searchQuery) {
|
||||||
// Show tabs only when not searching
|
if (isSearching) {
|
||||||
if (!isSearching) {
|
rows.filter { rowMatchesQuery(it, searchQuery) }
|
||||||
val allCategories =
|
|
||||||
if (nonSchemaPaths.isNotEmpty()) {
|
|
||||||
schema.category_order.filter { it in categoryCounts } + "Other"
|
|
||||||
} else {
|
} else {
|
||||||
schema.category_order.filter { it in categoryCounts }
|
rows.filter { row ->
|
||||||
|
if (activeCategory == "Other") {
|
||||||
|
row.isUncovered
|
||||||
|
} else {
|
||||||
|
!row.isUncovered && row.category == activeCategory
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val allCategories =
|
||||||
|
remember(schema, uncoveredPaths, categoryCounts) {
|
||||||
|
val ordered =
|
||||||
|
schema.category_order.filter { it in categoryCounts } +
|
||||||
|
uncoveredPaths.takeIf { it.isNotEmpty() }?.let { listOf("Other") }.orEmpty()
|
||||||
|
ordered.ifEmpty { categoryCounts.keys.sorted() }
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
// Category tabs — hidden while searching
|
||||||
|
if (!isSearching) {
|
||||||
|
item(key = "tabs") {
|
||||||
ConfigTabs(
|
ConfigTabs(
|
||||||
categories = allCategories,
|
categories = allCategories,
|
||||||
categoryCounts = categoryCounts,
|
categoryCounts = categoryCounts,
|
||||||
selectedCategory = activeCategory,
|
selectedCategory = activeCategory,
|
||||||
onCategorySelected = onCategoryChange,
|
onCategorySelected = onCategoryChange,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
item(key = "search-meta") {
|
||||||
|
val count = visibleRows.size
|
||||||
|
Text(
|
||||||
|
text = if (count == 1) "1 result" else "$count results",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fields list
|
if (activeCategory == "Other" && !isSearching) {
|
||||||
if (isOtherCategory) {
|
// Non-schema config values — read-only cards
|
||||||
// Render non-schema config dot-paths as read-only cards
|
items(uncoveredPaths, key = { it }) { dotPath ->
|
||||||
nonSchemaPaths.forEach { dotPath ->
|
|
||||||
val jsonText = getJsonValue(config, dotPath)?.toString() ?: ""
|
|
||||||
Card(
|
Card(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -390,7 +447,7 @@ private fun FormEditor(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = jsonText,
|
text = values[dotPath]?.let(::jsonText) ?: "",
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -398,50 +455,38 @@ private fun FormEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val visibleFields: List<Pair<String, SchemaField>> =
|
items(visibleRows, key = { it.key }) { row ->
|
||||||
remember(schema, activeCategory, searchQuery) {
|
ConfigFieldCard(
|
||||||
if (isSearching) {
|
row = row,
|
||||||
val query = searchQuery.lowercase()
|
defaultValue = defaults?.get(row.key),
|
||||||
schema.fields
|
isModified = row.key in modifiedKeys,
|
||||||
.filter { (key, field) ->
|
showCategoryChip = isSearching,
|
||||||
val label = key.split(".").last().replace("_", " ")
|
onChange = { onFieldChange(row.key, it) },
|
||||||
key.lowercase().contains(query) ||
|
onReset = { onResetField(row.key) },
|
||||||
label.contains(query) ||
|
onClear = { onClearField(row.key) },
|
||||||
(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()) {
|
if (visibleRows.isEmpty()) {
|
||||||
|
item(key = "empty") {
|
||||||
Text(
|
Text(
|
||||||
text = if (isSearching) "No settings match your search." else "No fields in this category.",
|
text =
|
||||||
|
if (isSearching) {
|
||||||
|
"No settings match your search."
|
||||||
|
} else {
|
||||||
|
"No fields in this category."
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(vertical = 24.dp),
|
modifier = Modifier.padding(vertical = 24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// Save + Reset buttons
|
// Save + Reset buttons
|
||||||
|
item(key = "actions") {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -481,6 +526,8 @@ private fun FormEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -518,22 +565,20 @@ private fun ConfigTabs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConfigField(
|
private fun ConfigFieldCard(
|
||||||
key: String,
|
row: ConfigRow,
|
||||||
field: SchemaField,
|
defaultValue: JsonElement?,
|
||||||
currentValue: JsonElement?,
|
|
||||||
isModified: Boolean,
|
isModified: Boolean,
|
||||||
|
showCategoryChip: Boolean,
|
||||||
onChange: (JsonElement) -> Unit,
|
onChange: (JsonElement) -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
|
onClear: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val label =
|
val field = row.field
|
||||||
key
|
val description =
|
||||||
.split(".")
|
field?.description
|
||||||
.last()
|
?: row.key.replace(".", " → ").replace("_", " ").replaceFirstChar { it.uppercase() }
|
||||||
.replace("_", " ")
|
|
||||||
.replaceFirstChar { it.uppercase() }
|
|
||||||
val description = field.description ?: key.replace(".", " → ").replace("_", " ").replaceFirstChar { it.uppercase() }
|
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -551,12 +596,79 @@ private fun ConfigField(
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
// ── Header row: category chip (searching), label, actions ──
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (showCategoryChip) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = row.category.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = row.label,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = if (isModified) FontWeight.Bold else FontWeight.Medium,
|
fontWeight = if (isModified) FontWeight.Bold else FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
if (!field.type.isNullOrEmpty() && field.type != "string") {
|
|
||||||
|
if (isModified) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 6.dp)
|
||||||
|
.size(8.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultValue != null && defaultValue != row.value) {
|
||||||
|
CompactIconButton(
|
||||||
|
icon = Icons.Filled.Refresh,
|
||||||
|
contentDescription = "Reset to default",
|
||||||
|
onClick = onReset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field?.clearable == true && row.valueText.isNotEmpty()) {
|
||||||
|
CompactIconButton(
|
||||||
|
icon = Icons.Filled.Clear,
|
||||||
|
contentDescription = "Clear value",
|
||||||
|
onClick = onClear,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field == null) {
|
||||||
|
// Uncovered path reached via search — show its value read-only
|
||||||
|
Text(
|
||||||
|
text = row.valueText,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type.isNotEmpty() && field.type != "string") {
|
||||||
Text(
|
Text(
|
||||||
text = field.type,
|
text = field.type,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -580,20 +692,30 @@ private fun ConfigField(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Switch(
|
Switch(
|
||||||
checked = (currentValue as? JsonPrimitive)?.booleanOrNull ?: false,
|
checked = (row.value as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||||
onCheckedChange = { onChange(JsonPrimitive(it)) },
|
onCheckedChange = { onChange(JsonPrimitive(it)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"select" -> {
|
"select" -> {
|
||||||
ExposedDropdownField(
|
if (field.searchable) {
|
||||||
label = label,
|
SearchableSelectField(
|
||||||
|
key = row.key,
|
||||||
|
label = row.label,
|
||||||
options = field.options ?: emptyList(),
|
options = field.options ?: emptyList(),
|
||||||
selectedValue = (currentValue as? JsonPrimitive)?.content ?: "",
|
selectedValue = (row.value as? JsonPrimitive)?.content ?: "",
|
||||||
onOptionSelected = { onChange(JsonPrimitive(it)) },
|
onOptionSelected = { onChange(JsonPrimitive(it)) },
|
||||||
)
|
)
|
||||||
if (description != label) {
|
} else {
|
||||||
|
ExposedDropdownField(
|
||||||
|
label = row.label,
|
||||||
|
options = field.options ?: emptyList(),
|
||||||
|
selectedValue = (row.value as? JsonPrimitive)?.content ?: "",
|
||||||
|
onOptionSelected = { onChange(JsonPrimitive(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (description != row.label) {
|
||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -604,41 +726,27 @@ private fun ConfigField(
|
|||||||
}
|
}
|
||||||
|
|
||||||
"number" -> {
|
"number" -> {
|
||||||
OutlinedTextField(
|
NumberField(
|
||||||
value =
|
key = row.key,
|
||||||
currentValue?.let {
|
value = row.value,
|
||||||
if (it is JsonPrimitive) it.content else ""
|
label = description,
|
||||||
} ?: "",
|
onChange = onChange,
|
||||||
onValueChange = { text ->
|
)
|
||||||
text.toDoubleOrNull()?.let { doubleVal ->
|
|
||||||
if (doubleVal == doubleVal.toLong().toDouble()) {
|
|
||||||
onChange(JsonPrimitive(doubleVal.toLong()))
|
|
||||||
} else {
|
|
||||||
onChange(JsonPrimitive(doubleVal))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
"object", "list" -> {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
JsonField(
|
||||||
label = { Text(description) },
|
key = row.key,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
value = row.value,
|
||||||
singleLine = true,
|
label = description,
|
||||||
shape = RoundedCornerShape(8.dp),
|
onChange = onChange,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
// String or other type
|
// String
|
||||||
val textValue =
|
|
||||||
currentValue?.let {
|
|
||||||
when (it) {
|
|
||||||
is JsonPrimitive -> it.content
|
|
||||||
is JsonObject -> it.toString()
|
|
||||||
is JsonArray -> it.toString()
|
|
||||||
}
|
|
||||||
} ?: ""
|
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = textValue,
|
value = (row.value as? JsonPrimitive)?.content ?: "",
|
||||||
onValueChange = { onChange(JsonPrimitive(it)) },
|
onValueChange = { onChange(JsonPrimitive(it)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text(description) },
|
label = { Text(description) },
|
||||||
@@ -651,55 +759,218 @@ private fun ConfigField(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recursively find all config dot-paths not covered by the schema. */
|
/** Compact header action icon (reset / clear). */
|
||||||
private fun collectUncoveredPaths(
|
@Composable
|
||||||
config: Map<String, JsonElement>,
|
private fun CompactIconButton(
|
||||||
schemaPaths: Set<String>,
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
prefix: String = "",
|
contentDescription: String,
|
||||||
): List<String> {
|
onClick: () -> Unit,
|
||||||
val result = mutableListOf<String>()
|
) {
|
||||||
for ((key, value) in config) {
|
Icon(
|
||||||
val dotPath = if (prefix.isEmpty()) key else "$prefix.$key"
|
imageVector = icon,
|
||||||
// If this exact path is in the schema, it's covered — skip entirely
|
contentDescription = contentDescription,
|
||||||
if (dotPath in schemaPaths) continue
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
if (value is JsonObject) {
|
modifier =
|
||||||
// Before recursing deeper, check if this whole sub-tree has any schema coverage
|
Modifier
|
||||||
val hasSchemaCoverage = schemaPaths.any { it == dotPath || it.startsWith("$dotPath.") }
|
.padding(start = 4.dp)
|
||||||
if (hasSchemaCoverage) {
|
.size(18.dp)
|
||||||
// Schema covers some sub-paths — recurse to find what's NOT covered
|
.clickable(onClick = onClick),
|
||||||
result.addAll(collectUncoveredPaths(value, schemaPaths, dotPath))
|
)
|
||||||
} else {
|
|
||||||
// No schema coverage at all for this subtree — add the whole thing
|
|
||||||
result.add(dotPath)
|
|
||||||
}
|
|
||||||
} else if (value is 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(
|
* Searchable dropdown (schema `searchable: true`, e.g. timezone's 590 options).
|
||||||
config: Map<String, JsonElement>,
|
* Typing filters the menu; only a picked option (or an exact-match on blur)
|
||||||
dotPath: String,
|
* commits — closed-world, matching the desktop SearchableSelect. The first
|
||||||
): JsonElement? {
|
* keystroke after focus starts a FRESH filter (the existing value's prefix is
|
||||||
val parts = dotPath.split(".")
|
* stripped) so typing "amman" over "Asia/Amman" doesn't append to it.
|
||||||
var current: JsonElement? = null
|
*/
|
||||||
for (i in parts.indices) {
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
val key = parts[i]
|
@Composable
|
||||||
if (i == 0) {
|
private fun SearchableSelectField(
|
||||||
current = config[key]
|
key: String,
|
||||||
|
label: String,
|
||||||
|
options: List<String>,
|
||||||
|
selectedValue: String,
|
||||||
|
onOptionSelected: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
var query by remember(key) { mutableStateOf(selectedValue) }
|
||||||
|
var hasFocus by remember { mutableStateOf(false) }
|
||||||
|
var untouched by remember(key) { mutableStateOf(true) }
|
||||||
|
|
||||||
|
LaunchedEffect(selectedValue) {
|
||||||
|
if (!hasFocus) {
|
||||||
|
query = selectedValue
|
||||||
|
untouched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it },
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { newText ->
|
||||||
|
if (untouched && query == selectedValue && newText.startsWith(selectedValue)) {
|
||||||
|
// Fresh filter over a pre-filled value: drop the old value
|
||||||
|
query = newText.removePrefix(selectedValue)
|
||||||
} else {
|
} else {
|
||||||
current = (current as? JsonObject)?.get(key)
|
query = newText
|
||||||
|
}
|
||||||
|
untouched = false
|
||||||
|
expanded = true
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable)
|
||||||
|
.onFocusChanged {
|
||||||
|
hasFocus = it.isFocused
|
||||||
|
if (!it.isFocused) {
|
||||||
|
untouched = true
|
||||||
|
if (query in options && query != selectedValue) {
|
||||||
|
onOptionSelected(query)
|
||||||
|
} else {
|
||||||
|
query = selectedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(label) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false },
|
||||||
|
) {
|
||||||
|
val filtered = options.filter { it.contains(query, ignoreCase = true) }
|
||||||
|
if (filtered.isEmpty()) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = "No matches",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
filtered.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(option) },
|
||||||
|
onClick = {
|
||||||
|
query = option
|
||||||
|
onOptionSelected(option)
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (current == null) return null
|
|
||||||
}
|
}
|
||||||
return current
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number editor with local text state. The value is committed only when the
|
||||||
|
* text parses, and the field's text never snaps while focused — so typing
|
||||||
|
* intermediate states like "0." or "42.0" works instead of dropping keys.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NumberField(
|
||||||
|
key: String,
|
||||||
|
value: JsonElement?,
|
||||||
|
label: String,
|
||||||
|
onChange: (JsonElement) -> Unit,
|
||||||
|
) {
|
||||||
|
var text by remember(key) { mutableStateOf((value as? JsonPrimitive)?.content ?: "") }
|
||||||
|
var hasFocus by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(value) {
|
||||||
|
if (!hasFocus) text = (value as? JsonPrimitive)?.content ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { newText ->
|
||||||
|
text = newText
|
||||||
|
newText.toDoubleOrNull()?.let { doubleVal ->
|
||||||
|
onChange(
|
||||||
|
if (doubleVal == doubleVal.toLong().toDouble()) {
|
||||||
|
JsonPrimitive(doubleVal.toLong())
|
||||||
|
} else {
|
||||||
|
JsonPrimitive(doubleVal)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onFocusChanged {
|
||||||
|
hasFocus = it.isFocused
|
||||||
|
if (!it.isFocused) text = (value as? JsonPrimitive)?.content ?: ""
|
||||||
|
},
|
||||||
|
isError = text.isNotEmpty() && text.toDoubleOrNull() == null,
|
||||||
|
label = { Text(label) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object/list JSON editor with local text state. Commits only valid JSON;
|
||||||
|
* invalid input shows an error but is never sent. Multi-line + monospace.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun JsonField(
|
||||||
|
key: String,
|
||||||
|
value: JsonElement?,
|
||||||
|
label: String,
|
||||||
|
onChange: (JsonElement) -> Unit,
|
||||||
|
) {
|
||||||
|
var text by remember(key) { mutableStateOf(value?.let(::jsonText) ?: "") }
|
||||||
|
var hasFocus by remember { mutableStateOf(false) }
|
||||||
|
var invalid by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(value) {
|
||||||
|
if (!hasFocus) text = value?.let(::jsonText) ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { newText ->
|
||||||
|
text = newText
|
||||||
|
val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull()
|
||||||
|
invalid = newText.isNotBlank() && parsed == null
|
||||||
|
if (parsed != null && newText.isNotBlank()) {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 96.dp)
|
||||||
|
.onFocusChanged {
|
||||||
|
hasFocus = it.isFocused
|
||||||
|
if (!it.isFocused) {
|
||||||
|
invalid = false
|
||||||
|
text = value?.let(::jsonText) ?: ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isError = invalid,
|
||||||
|
supportingText =
|
||||||
|
if (invalid) {
|
||||||
|
{ Text("Invalid JSON") }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
label = { Text(label) },
|
||||||
|
maxLines = Int.MAX_VALUE,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -20,13 +20,17 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
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,
|
/** Flattened dot-path → value map (see [flattenConfig]). */
|
||||||
|
val values: Map<String, JsonElement>? = null,
|
||||||
val schema: ConfigSchemaResponse? = null,
|
val schema: ConfigSchemaResponse? = null,
|
||||||
val defaults: Map<String, JsonElement>? = null,
|
val defaults: Map<String, JsonElement>? = null,
|
||||||
|
/** Config paths present but not covered by the schema (the "Other" tab). */
|
||||||
|
val uncoveredPaths: List<String> = emptyList(),
|
||||||
val path: String? = null,
|
val path: String? = null,
|
||||||
val yamlText: String? = null,
|
val yamlText: String? = null,
|
||||||
val modifiedKeys: Set<String> = emptySet(),
|
val modifiedKeys: Set<String> = emptySet(),
|
||||||
@@ -68,7 +72,7 @@ class ConfigViewModel :
|
|||||||
val rawResult = rawDeferred.await()
|
val rawResult = rawDeferred.await()
|
||||||
|
|
||||||
if (configResult is NetworkResult.Success) {
|
if (configResult is NetworkResult.Success) {
|
||||||
val configData = configResult.data
|
val values = flattenConfig(configResult.data)
|
||||||
val schema = (schemaResult as? NetworkResult.Success)?.data
|
val schema = (schemaResult as? NetworkResult.Success)?.data
|
||||||
val defaults = (defaultsResult as? NetworkResult.Success)?.data
|
val defaults = (defaultsResult as? NetworkResult.Success)?.data
|
||||||
val path = (rawResult as? NetworkResult.Success)?.data?.path
|
val path = (rawResult as? NetworkResult.Success)?.data?.path
|
||||||
@@ -76,9 +80,14 @@ class ConfigViewModel :
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
config = configData,
|
values = values,
|
||||||
schema = schema,
|
schema = schema,
|
||||||
defaults = defaults,
|
defaults = defaults,
|
||||||
|
uncoveredPaths =
|
||||||
|
collectUncoveredPaths(
|
||||||
|
values,
|
||||||
|
schema?.fields?.keys ?: emptySet(),
|
||||||
|
),
|
||||||
path = path,
|
path = path,
|
||||||
activeCategory =
|
activeCategory =
|
||||||
if (schema?.category_order?.isNotEmpty() == true) {
|
if (schema?.category_order?.isNotEmpty() == true) {
|
||||||
@@ -108,17 +117,34 @@ class ConfigViewModel :
|
|||||||
value: JsonElement,
|
value: JsonElement,
|
||||||
) {
|
) {
|
||||||
pendingChanges[key] = value
|
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 {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
config = updatedConfig,
|
values = it.values?.toMutableMap()?.apply { this[key] = value },
|
||||||
|
modifiedKeys = pendingChanges.keys.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset ONE field to its default value (pending until Save). */
|
||||||
|
fun resetField(key: String) {
|
||||||
|
val state = _uiState.value
|
||||||
|
val defaultVal = state.defaults?.get(key) ?: return
|
||||||
|
pendingChanges[key] = defaultVal
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
values = it.values?.toMutableMap()?.apply { this[key] = defaultVal },
|
||||||
|
modifiedKeys = pendingChanges.keys.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear a clearable field to blank (e.g. timezone → system default). */
|
||||||
|
fun clearField(key: String) {
|
||||||
|
val blank = JsonPrimitive("")
|
||||||
|
pendingChanges[key] = blank
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
values = it.values?.toMutableMap()?.apply { this[key] = blank },
|
||||||
modifiedKeys = pendingChanges.keys.toSet(),
|
modifiedKeys = pendingChanges.keys.toSet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -193,7 +219,9 @@ class ConfigViewModel :
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
config = (configResult as? NetworkResult.Success)?.data ?: it.config,
|
values =
|
||||||
|
(configResult as? NetworkResult.Success)?.data?.let(::flattenConfig)
|
||||||
|
?: it.values,
|
||||||
modifiedKeys = emptySet(),
|
modifiedKeys = emptySet(),
|
||||||
toastMessage = "Configuration saved successfully",
|
toastMessage = "Configuration saved successfully",
|
||||||
)
|
)
|
||||||
@@ -233,7 +261,9 @@ class ConfigViewModel :
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
yamlIsSaving = false,
|
yamlIsSaving = false,
|
||||||
config = (configResult as? NetworkResult.Success)?.data ?: it.config,
|
values =
|
||||||
|
(configResult as? NetworkResult.Success)?.data?.let(::flattenConfig)
|
||||||
|
?: it.values,
|
||||||
toastMessage = "YAML configuration saved",
|
toastMessage = "YAML configuration saved",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -262,15 +292,20 @@ class ConfigViewModel :
|
|||||||
}
|
}
|
||||||
|
|
||||||
var count = 0
|
var count = 0
|
||||||
|
val updatedValues = state.values?.toMutableMap()
|
||||||
for ((key, _) in categoryFields) {
|
for ((key, _) in categoryFields) {
|
||||||
val defaultVal = defaults[key]
|
val defaultVal = defaults[key]
|
||||||
if (defaultVal != null) {
|
if (defaultVal != null) {
|
||||||
pendingChanges[key] = defaultVal
|
pendingChanges[key] = defaultVal
|
||||||
|
updatedValues?.set(key, defaultVal)
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(modifiedKeys = pendingChanges.keys.toSet())
|
it.copy(
|
||||||
|
values = updatedValues ?: it.values,
|
||||||
|
modifiedKeys = pendingChanges.keys.toSet(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
@@ -284,34 +319,6 @@ class ConfigViewModel :
|
|||||||
_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 as? JsonObject) ?: JsonObject(emptyMap())
|
|
||||||
val updatedNested =
|
|
||||||
applyNestedValue(
|
|
||||||
nestedJson,
|
|
||||||
rest,
|
|
||||||
value,
|
|
||||||
)
|
|
||||||
val nestedObj = JsonObject(updatedNested)
|
|
||||||
val mutable = config.toMutableMap()
|
|
||||||
mutable[topKey] = nestedObj
|
|
||||||
return mutable
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a nested JSON changeset from dot-path pending changes. */
|
/** Build a nested JSON changeset from dot-path pending changes. */
|
||||||
private fun buildChangeset(changes: Map<String, JsonElement>): Map<String, JsonElement> {
|
private fun buildChangeset(changes: Map<String, JsonElement>): Map<String, JsonElement> {
|
||||||
val root = mutableMapOf<String, Any>()
|
val root = mutableMapOf<String, Any>()
|
||||||
@@ -333,18 +340,15 @@ class ConfigViewModel :
|
|||||||
current[parts.last()] = value
|
current[parts.last()] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toJsonObject(map: Map<String, Any>): JsonObject {
|
fun toJsonObject(map: Map<String, Any>): Map<String, JsonElement> =
|
||||||
val content =
|
|
||||||
map.mapValues { (_, v) ->
|
map.mapValues { (_, v) ->
|
||||||
if (v is Map<*, *>) {
|
if (v is Map<*, *>) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
toJsonObject(v as Map<String, Any>)
|
JsonObject(toJsonObject(v as Map<String, Any>))
|
||||||
} else {
|
} else {
|
||||||
v as JsonElement
|
v as JsonElement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return JsonObject(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return toJsonObject(root)
|
return toJsonObject(root)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.m57.hermescontrol.ui.config
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.SchemaField
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-logic tests for the config form's data layer: flattening the nested
|
||||||
|
* config response into dot-paths, "Other" path discovery, and the search
|
||||||
|
* matcher (which must cover keys, labels, descriptions, categories, CURRENT
|
||||||
|
* VALUES and select options — the value/option coverage was missing before
|
||||||
|
* the redo).
|
||||||
|
*/
|
||||||
|
class ConfigFormDataTest {
|
||||||
|
// ── flattenConfig ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `flattenConfig walks nested objects into dot paths`() {
|
||||||
|
val nested =
|
||||||
|
JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"model" to JsonPrimitive("gpt-4o"),
|
||||||
|
"terminal" to
|
||||||
|
JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"backend" to JsonPrimitive("local"),
|
||||||
|
"timeout" to JsonPrimitive(30),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val flat = flattenConfig(nested)
|
||||||
|
assertEquals(
|
||||||
|
mapOf(
|
||||||
|
"model" to JsonPrimitive("gpt-4o"),
|
||||||
|
"terminal.backend" to JsonPrimitive("local"),
|
||||||
|
"terminal.timeout" to JsonPrimitive(30),
|
||||||
|
),
|
||||||
|
flat,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `flattenConfig keeps arrays and nulls as leaf values`() {
|
||||||
|
val nested =
|
||||||
|
JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"toolsets" to JsonArray(listOf(JsonPrimitive("terminal"), JsonPrimitive("web"))),
|
||||||
|
"unset" to JsonNull,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val flat = flattenConfig(nested)
|
||||||
|
assertEquals(2, flat.size)
|
||||||
|
assertEquals(
|
||||||
|
JsonArray(listOf(JsonPrimitive("terminal"), JsonPrimitive("web"))),
|
||||||
|
flat["toolsets"],
|
||||||
|
)
|
||||||
|
assertEquals(JsonNull, flat["unset"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `flattenConfig on already-flat map is identity`() {
|
||||||
|
val flat = mapOf("a.b" to JsonPrimitive(1), "c" to JsonPrimitive("x"))
|
||||||
|
assertEquals(flat, flattenConfig(flat))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── isCoveredBySchema ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isCoveredBySchema matches exact key and ancestors`() {
|
||||||
|
val schema = setOf("tts.provider", "model")
|
||||||
|
assertTrue(isCoveredBySchema("tts.provider", schema))
|
||||||
|
assertTrue(isCoveredBySchema("model", schema))
|
||||||
|
// Ancestor relationship: key below a schema key is covered
|
||||||
|
assertTrue(isCoveredBySchema("tts.provider.x", schema))
|
||||||
|
assertFalse(isCoveredBySchema("tts.other", schema))
|
||||||
|
// Prefix boundary: "tts.providers" must NOT match "tts.provider"
|
||||||
|
assertFalse(isCoveredBySchema("tts.providers.custom", schema))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── collectUncoveredPaths ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `collectUncoveredPaths finds only schema-orphaned leaves`() {
|
||||||
|
val flat =
|
||||||
|
mapOf(
|
||||||
|
"model" to JsonPrimitive("x"),
|
||||||
|
"tts.provider" to JsonPrimitive("edge"),
|
||||||
|
"tts.providers.custom.voice" to JsonPrimitive("amy"),
|
||||||
|
"dashboard.host" to JsonPrimitive("0.0.0.0"),
|
||||||
|
)
|
||||||
|
val schema = setOf("model", "tts.provider")
|
||||||
|
assertEquals(
|
||||||
|
listOf("dashboard.host", "tts.providers.custom.voice"),
|
||||||
|
collectUncoveredPaths(flat, schema),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── rowMatchesQuery ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun row(
|
||||||
|
key: String,
|
||||||
|
field: SchemaField?,
|
||||||
|
value: kotlinx.serialization.json.JsonElement? = null,
|
||||||
|
) = ConfigRow(key = key, field = field, value = value)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search matches dot key and human label`() {
|
||||||
|
val r = row("model_context_length", SchemaField(type = "number"), JsonPrimitive(8192))
|
||||||
|
assertTrue(rowMatchesQuery(r, "model_context"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "context length"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search matches description and category`() {
|
||||||
|
val r =
|
||||||
|
row(
|
||||||
|
"stt.local.model",
|
||||||
|
SchemaField(type = "select", description = "Local faster-whisper model size", category = "stt"),
|
||||||
|
)
|
||||||
|
assertTrue(rowMatchesQuery(r, "whisper"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "stt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search matches current value`() {
|
||||||
|
val r = row("model", SchemaField(type = "string"), JsonPrimitive("anthropic/claude-sonnet-4.6"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "claude-sonnet"))
|
||||||
|
assertFalse(rowMatchesQuery(r, "gpt-4o"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search matches select options`() {
|
||||||
|
val r =
|
||||||
|
row(
|
||||||
|
"terminal.backend",
|
||||||
|
SchemaField(type = "select", options = listOf("local", "docker", "ssh", "modal", "daytona")),
|
||||||
|
JsonPrimitive("local"),
|
||||||
|
)
|
||||||
|
assertTrue(rowMatchesQuery(r, "daytona"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "docker"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search matches uncovered paths by key and value`() {
|
||||||
|
val r = row("plugins.myplugin.enabled", field = null, value = JsonPrimitive("true"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "myplugin"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search is case-insensitive and blank query matches all`() {
|
||||||
|
val r = row("Model", SchemaField(type = "string"), JsonPrimitive("OpenAI"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "openai"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "MODEL"))
|
||||||
|
assertTrue(rowMatchesQuery(r, ""))
|
||||||
|
assertTrue(rowMatchesQuery(r, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search does not match unrelated keys`() {
|
||||||
|
val r = row("memory.provider", SchemaField(type = "select"), JsonPrimitive("builtin"))
|
||||||
|
assertFalse(rowMatchesQuery(r, "terminal"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── jsonText ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `jsonText renders primitives, objects and arrays`() {
|
||||||
|
assertEquals("42", jsonText(JsonPrimitive(42)))
|
||||||
|
assertEquals("hello", jsonText(JsonPrimitive("hello")))
|
||||||
|
assertEquals("{\"a\":1}", jsonText(JsonObject(mapOf("a" to JsonPrimitive(1)))))
|
||||||
|
assertEquals("[1,2]", jsonText(JsonArray(listOf(JsonPrimitive(1), JsonPrimitive(2)))))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user