diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Config.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Config.kt index 11ec62c2..b1b59c05 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Config.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Config.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.data.model +import com.google.gson.JsonElement + data class RawConfigResponse( val path: String?, val yaml: String?, @@ -9,3 +11,20 @@ data class UpdateRawConfigRequest( val yaml_text: String, val profile: String? = null, ) + +data class ConfigSchemaResponse( + val fields: Map, + val category_order: List, +) + +data class SchemaField( + val type: String, + val description: String? = null, + val category: String? = null, + val options: List? = null, +) + +data class ConfigUpdateRequest( + val config: Map, + val profile: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 2c737025..2907d8dc 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -1,8 +1,11 @@ package com.m57.hermescontrol.data.remote +import com.google.gson.JsonElement import com.m57.hermescontrol.data.model.AchievementsResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.AgentPluginInstallBody +import com.m57.hermescontrol.data.model.ConfigSchemaResponse +import com.m57.hermescontrol.data.model.ConfigUpdateRequest import com.m57.hermescontrol.data.model.CreateCronJobRequest import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateWebhookRequest @@ -222,6 +225,20 @@ interface HermesApiService { @Body body: UpdateRawConfigRequest, ): Response + @GET("api/config") + suspend fun getConfig(): Response> + + @GET("api/config/schema") + suspend fun getConfigSchema(): Response + + @GET("api/config/defaults") + suspend fun getConfigDefaults(): Response> + + @PUT("api/config") + suspend fun updateConfig( + @Body body: ConfigUpdateRequest, + ): Response + @GET("api/mcp/servers") suspend fun getMcpServers(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt index d458e049..db78a582 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt @@ -1,6 +1,6 @@ package com.m57.hermescontrol.ui.config -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -9,13 +9,34 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.Badge import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -26,14 +47,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.gson.JsonElement +import com.google.gson.JsonPrimitive import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SchemaField import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.ToastEffect @OptIn(ExperimentalMaterial3Api::class) @@ -44,10 +72,9 @@ fun ConfigScreen( viewModel: ConfigViewModel = viewModel { ConfigViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - var editingText by remember(state.yamlText) { mutableStateOf(state.yamlText ?: "") } LaunchedEffect(Unit) { - viewModel.loadRawConfig() + viewModel.loadAll() } ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) @@ -56,92 +83,661 @@ fun ConfigScreen( title = { Text(stringResource(R.string.config_screen_title)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, - onRefresh = { viewModel.loadRawConfig() }, + onRefresh = { viewModel.loadAll() }, + actions = { + if (!state.yamlMode && state.modifiedKeys.isNotEmpty()) { + IconButton(onClick = { viewModel.saveConfig() }) { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = "Save changes", + ) + } + } + }, ) { paddingValues -> when { - state.isLoading && state.yamlText == null -> { + state.isLoading && state.config == null -> { LoadingState(modifier = Modifier.padding(paddingValues)) } - state.errorMessage != null -> { + state.errorMessage != null && state.config == null -> { ErrorState( message = state.errorMessage ?: "", - onRetry = { viewModel.loadRawConfig() }, + onRetry = { viewModel.loadAll() }, modifier = Modifier.padding(paddingValues), ) } else -> { - Box(Modifier.fillMaxSize()) { - if (state.isLoading && state.yamlText == null) { - CircularProgressIndicator() - } else if (state.errorMessage != null && state.yamlText == null) { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + ConfigContent( + state = state, + onModeToggle = viewModel::toggleYamlMode, + onSearchQueryChange = viewModel::setSearchQuery, + onCategoryChange = viewModel::setActiveCategory, + onFieldChange = viewModel::updateField, + onSave = viewModel::saveConfig, + onYamlTextChange = viewModel::setYamlText, + onYamlSave = viewModel::saveYamlConfig, + onResetCategory = viewModel::resetCategoryToDefaults, + modifier = Modifier, + ) + } + } + } +} + +@Composable +private fun ConfigContent( + state: ConfigUiState, + onModeToggle: () -> Unit, + onSearchQueryChange: (String) -> Unit, + onCategoryChange: (String) -> Unit, + onFieldChange: (String, JsonElement) -> Unit, + onSave: () -> Unit, + onYamlTextChange: (String) -> Unit, + onYamlSave: () -> Unit, + onResetCategory: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxSize()) { + // ── Non-scrollable top section ── + Column( + modifier = + Modifier + .padding(horizontal = 16.dp) + .padding(top = 8.dp), + ) { + // Path display + state.path?.let { path -> + Text( + text = "Path: $path", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + + // Mode toggle + search + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onModeToggle) { + Icon( + imageVector = if (state.yamlMode) Icons.Filled.Tune else Icons.Filled.Code, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text( + if (state.yamlMode) "Form" else "YAML", + fontWeight = FontWeight.Medium, + ) + } + + if (!state.yamlMode) { + SearchBar( + query = state.searchQuery, + onQueryChange = onSearchQueryChange, + placeholder = "Search settings…", + modifier = Modifier.weight(1f), + ) + } + } + + // Top save bar — sticky, always visible when changes pending + if (!state.yamlMode && state.modifiedKeys.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${state.modifiedKeys.size} change(s) pending", + style = MaterialTheme.typography.bodySmall, + ) + Button( + onClick = onSave, + enabled = !state.isSaving, ) { - Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.loadRawConfig() }) { - Text(stringResource(R.string.action_retry)) - } - } - } else { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - ) { - state.path?.let { - Text( - text = "Path: $it", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), + if (state.isSaving) { + CircularProgressIndicator( + modifier = Modifier.width(16.dp), + strokeWidth = 2.dp, ) - } - - OutlinedTextField( - value = editingText, - onValueChange = { editingText = it }, - modifier = - Modifier - .fillMaxWidth() - .weight(1f), - placeholder = { Text(stringResource(R.string.config_placeholder_yaml)) }, - textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - maxLines = Int.MAX_VALUE, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Row(modifier = Modifier.fillMaxWidth()) { - OutlinedButton( - onClick = { editingText = state.yamlText ?: "" }, - modifier = Modifier.weight(1f), - ) { - Text(stringResource(R.string.config_action_reset)) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Button( - onClick = { viewModel.saveRawConfig(editingText) }, - modifier = Modifier.weight(1f), - enabled = !state.isSaving, - ) { - if (state.isSaving) { - CircularProgressIndicator(modifier = Modifier.width(16.dp)) - } else { - Text(stringResource(R.string.config_action_save)) - } - } + } else { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = null, + modifier = Modifier.width(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Save", style = MaterialTheme.typography.labelMedium) } } } } } } + + // ── Scrollable content ── + Column( + modifier = + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + if (state.yamlMode) { + YAMLEditor( + yamlText = state.yamlText ?: "", + onYamlTextChange = onYamlTextChange, + isSaving = state.yamlIsSaving, + isLoading = state.yamlIsLoading, + onSave = onYamlSave, + ) + } else { + FormEditor( + schema = state.schema, + config = state.config, + defaults = state.defaults, + activeCategory = state.activeCategory, + searchQuery = state.searchQuery, + modifiedKeys = state.modifiedKeys, + isSaving = state.isSaving, + onCategoryChange = onCategoryChange, + onFieldChange = onFieldChange, + onSave = onSave, + onResetCategory = onResetCategory, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } } } + +@Composable +private fun YAMLEditor( + yamlText: String, + onYamlTextChange: (String) -> Unit, + isSaving: Boolean, + isLoading: Boolean, + onSave: () -> Unit, +) { + if (isLoading) { + LoadingState() + return + } + + OutlinedTextField( + value = yamlText, + onValueChange = onYamlTextChange, + modifier = + Modifier + .fillMaxWidth() + .height(400.dp), + textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + maxLines = Int.MAX_VALUE, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = onSave, + modifier = Modifier.fillMaxWidth(), + enabled = !isSaving, + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.width(16.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text( + stringResource(R.string.config_action_save_yaml), + ) + } + } +} + +@Composable +private fun FormEditor( + schema: com.m57.hermescontrol.data.model.ConfigSchemaResponse?, + config: Map?, + defaults: Map?, + activeCategory: String, + searchQuery: String, + modifiedKeys: Set, + 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> = + 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, + categoryCounts: Map, + 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, + 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, + schemaPaths: Set, + prefix: String = "", +): List { + val result = mutableListOf() + 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, + 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 +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt index 5a42040a..d7393ff6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt @@ -2,6 +2,10 @@ package com.m57.hermescontrol.ui.config import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.m57.hermescontrol.data.model.ConfigSchemaResponse +import com.m57.hermescontrol.data.model.ConfigUpdateRequest import com.m57.hermescontrol.data.model.UpdateRawConfigRequest import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult @@ -19,8 +23,17 @@ import kotlinx.coroutines.withContext data class ConfigUiState( val isLoading: Boolean = false, val isSaving: Boolean = false, + val config: Map? = null, + val schema: ConfigSchemaResponse? = null, + val defaults: Map? = null, val path: String? = null, val yamlText: String? = null, + val modifiedKeys: Set = emptySet(), + val activeCategory: String = "", + val searchQuery: String = "", + val yamlMode: Boolean = false, + val yamlIsLoading: Boolean = false, + val yamlIsSaving: Boolean = false, val errorMessage: String? = null, val toastMessage: String? = null, ) @@ -29,53 +42,162 @@ class ConfigViewModel : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(ConfigUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadRawConfig() { + private val pendingChanges = mutableMapOf() + + init { + loadAll() + } + + fun loadAll() { safeLaunchLoad( - apiCall = { safeApiCall { ApiClient.hermesApi.getRawConfig() } }, + apiCall = { + safeApiCall { ApiClient.hermesApi.getConfig() } + }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, - onSuccess = { data -> - _uiState.update { - it.copy( - isLoading = false, - path = data.path, - yamlText = data.yaml, - ) + onSuccess = { configData -> + viewModelScope.launch { + val schemaResult = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getConfigSchema() } + } + val defaultsResult = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getConfigDefaults() } + } + + val schema = (schemaResult as? NetworkResult.Success)?.data + val defaults = (defaultsResult as? NetworkResult.Success)?.data + val rawResult = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getRawConfig() } + } + val path = (rawResult as? NetworkResult.Success)?.data?.path + + _uiState.update { + it.copy( + isLoading = false, + config = configData, + schema = schema, + defaults = defaults, + path = path, + activeCategory = + if (schema?.category_order?.isNotEmpty() == true) { + schema.category_order.first() + } else { + "" + }, + ) + } } }, onError = { errorMsg -> _uiState.update { - it.copy( - isLoading = false, - errorMessage = "Failed to load config: $errorMsg", - ) + it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg") } }, ) } - fun saveRawConfig(yamlText: String) { + fun updateField( + key: String, + value: JsonElement, + ) { + pendingChanges[key] = value + val state = _uiState.value + + // Apply the change locally so the UI reflects it immediately + val updatedConfig = + state.config?.let { config -> + applyNestedValue(config, key, value) + } + + _uiState.update { + it.copy( + config = updatedConfig, + modifiedKeys = pendingChanges.keys.toSet(), + ) + } + } + + fun setActiveCategory(category: String) { + _uiState.update { it.copy(activeCategory = category) } + } + + fun setSearchQuery(query: String) { + _uiState.update { it.copy(searchQuery = query) } + } + + fun toggleYamlMode() { + val current = _uiState.value + if (current.yamlMode) { + _uiState.update { it.copy(yamlMode = false, yamlText = null) } + } else { + _uiState.update { it.copy(yamlMode = true, yamlIsLoading = true) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getRawConfig() } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + yamlIsLoading = false, + yamlText = result.data.yaml ?: "", + ) + } + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + yamlIsLoading = false, + toastMessage = "Failed to load YAML: ${result.error.message}", + ) + } + } + } + } + } + } + + fun setYamlText(text: String) { + _uiState.update { it.copy(yamlText = text) } + } + + fun saveConfig() { + if (pendingChanges.isEmpty()) return _uiState.update { it.copy(isSaving = true) } viewModelScope.launch { + val changeset = buildChangeset(pendingChanges) val result = withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.updateRawConfig(UpdateRawConfigRequest(yamlText)) } + safeApiCall { + ApiClient.hermesApi.updateConfig( + ConfigUpdateRequest(config = changeset), + ) + } } when (result) { is NetworkResult.Success -> { + pendingChanges.clear() + val configResult = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getConfig() } + } _uiState.update { it.copy( isSaving = false, - yamlText = yamlText, + config = (configResult as? NetworkResult.Success)?.data ?: it.config, + modifiedKeys = emptySet(), toastMessage = "Configuration saved successfully", ) } } - is NetworkResult.Failure -> { _uiState.update { it.copy( isSaving = false, - toastMessage = "Failed to save config: ${result.error.message}", + toastMessage = "Failed to save: ${result.error.message}", ) } } @@ -83,7 +205,125 @@ class ConfigViewModel : ViewModel(), ToastHost { } } + fun saveYamlConfig() { + val yamlText = _uiState.value.yamlText ?: return + _uiState.update { it.copy(yamlIsSaving = true) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.updateRawConfig( + UpdateRawConfigRequest(yaml_text = yamlText), + ) + } + } + when (result) { + is NetworkResult.Success -> { + val configResult = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getConfig() } + } + _uiState.update { + it.copy( + yamlIsSaving = false, + config = (configResult as? NetworkResult.Success)?.data ?: it.config, + toastMessage = "YAML configuration saved", + ) + } + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + yamlIsSaving = false, + toastMessage = "Failed to save YAML: ${result.error.message}", + ) + } + } + } + } + } + + fun resetCategoryToDefaults(category: String) { + val state = _uiState.value + val schema = state.schema ?: return + val defaults = state.defaults ?: return + + val categoryFields = + schema.fields.filter { (_, field) -> + (field.category ?: "general") == category + } + + var count = 0 + for ((key, _) in categoryFields) { + val defaultVal = defaults[key] + if (defaultVal != null) { + pendingChanges[key] = defaultVal + count++ + } + } + _uiState.update { + it.copy(modifiedKeys = pendingChanges.keys.toSet()) + } + + if (count > 0) { + _uiState.update { + it.copy(toastMessage = "Reset $count field(s) to defaults (tap Save to apply)") + } + } + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } + + /** Apply a value at a dot-path like "terminal.backend" into a flat config map. */ + private fun applyNestedValue( + config: Map, + dotPath: String, + value: JsonElement, + ): Map { + 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): Map { + 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 } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b5d5897..92f8710b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -336,7 +336,8 @@ Configuration YAML Config... Reset - Save Config + Save + Save YAML No profiles available