release: promote v1.31.0-rc.2 to main (#1354)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Lx-204 <15377891490@163.com>
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com>
Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com>
Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-28 12:39:34 +03:00
1 parent e4004decb4
commit ed915d0816
111 files changed
+10563 -998

No files matched your search

@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.InstallDesktop
@@ -67,6 +69,7 @@ import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.common.toDetailRows
import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView
import com.m57.hermescontrol.ui.plugins.components.PluginDetailDialog
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -120,6 +123,37 @@ fun PluginsScreen(
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.updateConsent?.let { consent ->
AlertDialog(
onDismissRequest = viewModel::cancelPluginUpdate,
title = { Text(stringResource(R.string.plugins_update_consent_title)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(R.string.plugins_update_consent_text, consent.name))
val lines = consent.result.capabilityDelta
if (lines.isEmpty()) {
Text(consent.result.error ?: stringResource(R.string.plugins_update_consent_missing))
} else {
lines.forEach { Text(it) }
}
}
},
confirmButton = {
TextButton(onClick = viewModel::confirmPluginUpdate, enabled = state.rowBusy == null) {
Text(stringResource(R.string.plugins_update_accept))
}
},
dismissButton = {
TextButton(onClick = viewModel::cancelPluginUpdate) {
Text(stringResource(R.string.common_cancel))
}
},
)
}
// Remove confirmation dialog
if (state.removeConfirmPlugin != null) {
AlertDialog(
@@ -240,7 +274,7 @@ fun PluginsScreen(
plugin = plugin,
state = state,
viewModel = viewModel,
onClick = { showDetail = plugin },
onClick = { viewModel.openPluginDetail(plugin) },
)
}
@@ -256,7 +290,10 @@ fun PluginsScreen(
)
}
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin })
OrphanPluginCard(
plugin = plugin,
onClick = { viewModel.openPluginDetail(plugin) },
)
}
}
}
@@ -280,11 +317,19 @@ fun PluginsScreen(
}
}
showDetail?.let { plugin ->
DetailDialog(
title = plugin.name,
rows = plugin.toDetailRows(),
onDismiss = { showDetail = null },
state.activeDetailPlugin?.let { plugin ->
PluginDetailDialog(
plugin = plugin,
agentPluginRow = state.pluginDetailRow,
isLoading = state.isPluginDetailLoading,
isSaving = state.isPluginDetailSaving,
edits = state.pluginDetailEdits,
onFieldChange = { key, value -> viewModel.setPluginDetailEdit(key, value) },
onSaveSettings = { viewModel.savePluginSettings() },
onDismiss = {
showDetail = null
viewModel.closePluginDetail()
},
)
}
@@ -6,15 +6,22 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.EnvVarUpdate
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
import com.m57.hermescontrol.data.model.PluginUpdateRequest
import com.m57.hermescontrol.data.model.PluginUpdateResult
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.model.ProviderOption
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.AgentPluginRow
import com.m57.hermescontrol.data.ws.PluginManageRepository
import com.m57.hermescontrol.data.ws.PluginRemovalRepository
import com.m57.hermescontrol.data.ws.PluginSettingField
import com.m57.hermescontrol.data.ws.PluginSettingFieldType
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchAction
import com.m57.hermescontrol.ui.common.safeLaunchLoad
@@ -27,12 +34,24 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.longOrNull
enum class PluginsTab {
INSTALLED,
CATALOG,
}
data class PluginUpdateConsent(
val name: String,
val result: PluginUpdateResult,
val scope: DataScope?,
)
data class PluginsUiState(
val selectedTab: PluginsTab = PluginsTab.INSTALLED,
val isLoading: Boolean = false,
@@ -55,6 +74,13 @@ data class PluginsUiState(
val rowBusy: String? = null,
// Confirm remove dialog
val removeConfirmPlugin: String? = null,
val updateConsent: PluginUpdateConsent? = null,
// Active plugin detail with settings & diagnostics
val activeDetailPlugin: PluginInfo? = null,
val isPluginDetailLoading: Boolean = false,
val pluginDetailRow: AgentPluginRow? = null,
val pluginDetailEdits: Map<String, String> = emptyMap(),
val isPluginDetailSaving: Boolean = false,
// Rescan
val rescanBusy: Boolean = false,
// Catalog state
@@ -76,6 +102,7 @@ data class PluginsUiState(
class PluginsViewModel(
private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(),
private val pluginManage: PluginManageRepository = PluginManageRepository(),
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(PluginsUiState())
@@ -99,6 +126,10 @@ class PluginsViewModel(
providerBusy = false,
rowBusy = null,
removeConfirmPlugin = null,
updateConsent = null,
activeDetailPlugin = null,
pluginDetailRow = null,
pluginDetailEdits = emptyMap(),
rescanBusy = false,
)
}
@@ -450,23 +481,83 @@ class PluginsViewModel(
}
fun updatePlugin(name: String) {
viewModelScope.launch {
setRowBusy(name)
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.updatePlugin(name) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
clearRowBusy(name)
loadPlugins(forceRefresh = true)
}
if (_uiState.value.updateConsent != null) return
performPluginUpdate(name, acceptCapabilities = false)
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") }
clearRowBusy(name)
fun cancelPluginUpdate() {
_uiState.update { it.copy(updateConsent = null) }
}
fun confirmPluginUpdate() {
val consent = _uiState.value.updateConsent ?: return
if (_uiState.value.rowBusy != null) return
cancelPluginUpdate()
if (consent.scope != runCatching { AuthManager.currentDataScope() }.getOrNull()) return
performPluginUpdate(consent.name, acceptCapabilities = true)
}
private fun performPluginUpdate(
name: String,
acceptCapabilities: Boolean,
) {
if (_uiState.value.rowBusy != null) return
val generation = scopeGeneration
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val api = ApiClient.hermesApi
setRowBusy(name)
_uiState.update { it.copy(toastMessage = null) }
viewModelScope.launch {
try {
val result = safeApiCall { api.updatePlugin(name, PluginUpdateRequest(acceptCapabilities)) }
if (generation != scopeGeneration ||
requestScope != runCatching { AuthManager.currentDataScope() }.getOrNull()
) {
return@launch
}
when (result) {
is NetworkResult.Success -> {
val update = result.data
// #1282: HTTP success is not update success; consent must be explicit.
when {
update.consentRequired -> {
_uiState.update {
it.copy(updateConsent = PluginUpdateConsent(name, update, requestScope))
}
}
!update.ok -> {
_uiState.update {
it.copy(
toastMessage = "Failed to update plugin: ${update.error ?: "Update refused"}",
)
}
}
else -> {
_uiState.update {
it.copy(
toastMessage =
if (update.unchanged) {
"Plugin is already up to date"
} else {
"Plugin updated successfully"
},
)
}
loadPlugins(forceRefresh = true)
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to update plugin: ${result.error.message}")
}
}
}
} finally {
if (generation == scopeGeneration) clearRowBusy(name)
}
}
}
@@ -614,6 +705,198 @@ class PluginsViewModel(
)
}
fun openPluginDetail(plugin: PluginInfo) {
_uiState.update {
it.copy(
activeDetailPlugin = plugin,
isPluginDetailLoading = true,
pluginDetailRow = null,
pluginDetailEdits = emptyMap(),
)
}
viewModelScope.launch {
try {
val profile = AuthManager.activeProfileId.value
val rows = pluginManage.listPlugins(profile)
val matchingRow = rows.firstOrNull { it.key == plugin.name || it.name == plugin.name }
val initialEdits = mutableMapOf<String, String>()
matchingRow?.settingsSchema?.forEach { field ->
if (field.type != PluginSettingFieldType.SECRET) {
val strVal =
when (val v = field.value ?: field.default) {
null -> ""
is JsonPrimitive -> v.content
else -> v.toString()
}
initialEdits[field.key] = strVal
}
}
_uiState.update {
it.copy(
isPluginDetailLoading = false,
pluginDetailRow = matchingRow,
pluginDetailEdits = initialEdits,
)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
_uiState.update {
it.copy(
isPluginDetailLoading = false,
pluginDetailRow = null,
)
}
}
}
}
fun closePluginDetail() {
_uiState.update {
it.copy(
activeDetailPlugin = null,
pluginDetailRow = null,
pluginDetailEdits = emptyMap(),
isPluginDetailSaving = false,
)
}
}
fun setPluginDetailEdit(
key: String,
value: String,
) {
_uiState.update {
it.copy(pluginDetailEdits = it.pluginDetailEdits + (key to value))
}
}
fun savePluginSettings() {
val plugin = _uiState.value.pluginDetailRow ?: return
val schema = plugin.settingsSchema ?: return
val canonicalKey = plugin.key ?: plugin.name
val currentEdits = _uiState.value.pluginDetailEdits
_uiState.update { it.copy(isPluginDetailSaving = true) }
viewModelScope.launch {
try {
val profile = AuthManager.activeProfileId.value
val valuesToSave = mutableMapOf<String, JsonElement>()
for (field in schema) {
val raw = currentEdits[field.key] ?: ""
when (field.type) {
PluginSettingFieldType.SECRET -> {
val envVar = field.env
if (raw.isNotBlank() && !envVar.isNullOrBlank()) {
val envResult =
safeApiCall {
ApiClient.hermesApi.updateEnvVar(EnvVarUpdate(key = envVar, value = raw))
}
if (envResult is NetworkResult.Failure) {
throw IllegalStateException(
"Failed to update secret $envVar: ${envResult.error.message}",
)
}
}
}
PluginSettingFieldType.STRING -> {
valuesToSave[field.key] = JsonPrimitive(raw)
}
PluginSettingFieldType.NUMBER -> {
val num =
raw.trim().toLongOrNull()
?: raw.trim().toDoubleOrNull()
if (num != null) {
valuesToSave[field.key] =
if (num is Long) JsonPrimitive(num) else JsonPrimitive(num as Double)
} else if (raw.trim().isNotEmpty()) {
throw IllegalArgumentException("${field.label}: expected a number")
}
}
PluginSettingFieldType.BOOLEAN -> {
valuesToSave[field.key] = JsonPrimitive(raw.trim().equals("true", ignoreCase = true))
}
PluginSettingFieldType.ENUM -> {
if (!field.choices.isNullOrEmpty() && raw.trim() !in field.choices) {
throw IllegalArgumentException(
"${field.label}: must be one of ${field.choices.joinToString()}",
)
}
valuesToSave[field.key] = JsonPrimitive(raw.trim())
}
PluginSettingFieldType.JSON -> {
val parsed =
if (raw.trim().isEmpty()) {
JsonPrimitive("")
} else {
Json.parseToJsonElement(raw.trim())
}
valuesToSave[field.key] = parsed
}
}
}
if (valuesToSave.isNotEmpty()) {
val result = pluginManage.saveSettings(canonicalKey, valuesToSave, profile)
if (!result.ok) {
throw IllegalStateException(result.error ?: "Failed to save settings")
}
if (result.plugin != null) {
val refreshed = result.plugin
val newEdits = mutableMapOf<String, String>()
refreshed.settingsSchema?.forEach { f ->
if (f.type != PluginSettingFieldType.SECRET) {
val strVal =
when (val v = f.value ?: f.default) {
null -> ""
is JsonPrimitive -> v.content
else -> v.toString()
}
newEdits[f.key] = strVal
}
}
_uiState.update {
it.copy(
isPluginDetailSaving = false,
pluginDetailRow = refreshed,
pluginDetailEdits = newEdits,
toastMessage = "Plugin settings saved",
)
}
return@launch
}
}
// If only secrets were saved or result plugin is null
_uiState.update {
it.copy(
isPluginDetailSaving = false,
pluginDetailEdits =
it.pluginDetailEdits.filterKeys { k ->
schema.none { f -> f.key == k && f.type == PluginSettingFieldType.SECRET }
},
toastMessage = "Plugin settings saved",
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_uiState.update {
it.copy(
isPluginDetailSaving = false,
toastMessage = "Failed to save settings: ${e.message}",
)
}
}
}
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
@@ -0,0 +1,397 @@
package com.m57.hermescontrol.ui.plugins.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.ws.AgentPluginRow
import com.m57.hermescontrol.data.ws.PluginServerRow
import com.m57.hermescontrol.data.ws.PluginServerState
import com.m57.hermescontrol.data.ws.PluginSettingField
import com.m57.hermescontrol.data.ws.PluginSettingFieldType
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.DetailRow
import com.m57.hermescontrol.ui.common.DetailRowTone
import com.m57.hermescontrol.ui.common.ExposedDropdownField
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.toDetailRows
import com.m57.hermescontrol.ui.common.toneForStatus
@Composable
fun PluginDetailDialog(
plugin: PluginInfo,
agentPluginRow: AgentPluginRow?,
isLoading: Boolean,
isSaving: Boolean,
edits: Map<String, String>,
onFieldChange: (String, String) -> Unit,
onSaveSettings: () -> Unit,
onDismiss: () -> Unit,
) {
val statusColors = LocalHermesStatusColors.current
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
HermesScaffold(
title = { Text(plugin.name, style = MaterialTheme.typography.titleLarge) },
navigationIcon = NavIcon.Back(onDismiss),
isRefreshing = false,
) { _ ->
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Standard metadata rows
val rows = plugin.toDetailRows().filter { !it.value.isNullOrBlank() }
rows.forEachIndexed { index, (label, value, tone) ->
if (index > 0) {
HorizontalDivider()
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value ?: "",
style = MaterialTheme.typography.bodyMedium,
color =
when (tone) {
DetailRowTone.SUCCESS -> statusColors.success
DetailRowTone.WARNING -> statusColors.warning
DetailRowTone.ERROR -> statusColors.error
DetailRowTone.INFO -> statusColors.info
DetailRowTone.NEUTRAL -> statusColors.neutral
DetailRowTone.NONE -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
if (isLoading) {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.plugins_settings_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Diagnostics section (only if servers present)
val servers = agentPluginRow?.servers.orEmpty()
if (servers.isNotEmpty()) {
HorizontalDivider()
Text(
text = stringResource(R.string.plugins_diagnostics_heading),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
servers.forEach { server ->
PluginServerDiagnosticRow(server = server)
}
}
// Settings section (only if schema present)
val schema = agentPluginRow?.settingsSchema.orEmpty()
if (schema.isNotEmpty()) {
HorizontalDivider()
Text(
text = stringResource(R.string.plugins_settings_heading),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
schema.forEach { field ->
PluginSettingFieldRow(
field = field,
value = edits[field.key] ?: "",
enabled = !isSaving,
onChange = { onFieldChange(field.key, it) },
)
}
Button(
onClick = onSaveSettings,
enabled = !isSaving,
modifier = Modifier.align(Alignment.End),
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.plugins_settings_save))
}
}
// Close action
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_close))
}
}
}
}
}
}
}
@Composable
private fun PluginServerDiagnosticRow(server: PluginServerRow) {
val statusColors = LocalHermesStatusColors.current
val (statusLabel, statusColor, badgeType) =
when (server.state) {
PluginServerState.CONNECTED -> {
Triple(
stringResource(R.string.plugins_server_state_connected),
statusColors.success,
StatusBadgeType.SUCCESS,
)
}
PluginServerState.APP_NOT_RUNNING -> {
Triple(
stringResource(R.string.plugins_server_state_app_not_running),
statusColors.warning,
StatusBadgeType.WARNING,
)
}
PluginServerState.ENDPOINT_UNAVAILABLE -> {
Triple(
stringResource(R.string.plugins_server_state_endpoint_unavailable),
statusColors.warning,
StatusBadgeType.WARNING,
)
}
PluginServerState.NO_INTERACTIVE_SESSION -> {
Triple(
stringResource(R.string.plugins_server_state_no_interactive_session),
statusColors.warning,
StatusBadgeType.WARNING,
)
}
PluginServerState.VERSION_TOO_OLD -> {
Triple(
stringResource(R.string.plugins_server_state_version_too_old),
statusColors.error,
StatusBadgeType.ERROR,
)
}
PluginServerState.MISSING_APP -> {
Triple(
stringResource(R.string.plugins_server_state_missing_app),
statusColors.error,
StatusBadgeType.ERROR,
)
}
PluginServerState.UNKNOWN -> {
Triple(
stringResource(R.string.plugins_server_state_unknown),
statusColors.warning,
StatusBadgeType.WARNING,
)
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = server.name,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
StatusBadge(text = statusLabel, status = badgeType)
}
if (server.sentence.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = server.sentence,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun PluginSettingFieldRow(
field: PluginSettingField,
value: String,
enabled: Boolean,
onChange: (String) -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = field.label.ifEmpty { field.key },
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
if (field.type == PluginSettingFieldType.SECRET) {
val isSet = field.hasValue == true
val badgeRes =
if (isSet) {
R.string.plugins_settings_secret_set
} else {
R.string.plugins_settings_secret_not_set
}
StatusBadge(
text = stringResource(badgeRes),
status = if (isSet) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING,
)
}
}
if (field.description.isNotBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = field.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (field.type == PluginSettingFieldType.SECRET && !field.env.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.plugins_settings_secret_hint, field.env),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(8.dp))
when (field.type) {
PluginSettingFieldType.ENUM -> {
ExposedDropdownField(
label = field.label.ifEmpty { field.key },
options = field.choices.orEmpty().ifEmpty { listOf(value) },
selectedValue = value,
onOptionSelected = onChange,
)
}
PluginSettingFieldType.BOOLEAN -> {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Switch(
checked = value.equals("true", ignoreCase = true),
onCheckedChange = { onChange(it.toString()) },
enabled = enabled,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
if (value.equals("true", ignoreCase = true)) {
stringResource(R.string.memory_provider_bool_on)
} else {
stringResource(R.string.memory_provider_bool_off)
},
style = MaterialTheme.typography.bodyMedium,
)
}
}
else -> {
OutlinedTextField(
value = value,
onValueChange = onChange,
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
label = {
Text(
if (field.type == PluginSettingFieldType.SECRET) {
stringResource(R.string.memory_provider_secret_label)
} else {
field.label.ifEmpty { field.key }
},
)
},
singleLine = field.type != PluginSettingFieldType.JSON,
visualTransformation =
if (field.type == PluginSettingFieldType.SECRET) {
PasswordVisualTransformation()
} else {
androidx.compose.ui.text.input.VisualTransformation.None
},
keyboardOptions =
if (field.type == PluginSettingFieldType.NUMBER) {
KeyboardOptions(keyboardType = KeyboardType.Decimal)
} else {
KeyboardOptions.Default
},
)
}
}
}
}
}