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