feat(keys): overhaul Keys screen UI and UX (#707)

* feat(keys): overhaul Keys screen UI and UX

Fix card padding alignment, add delete confirmation dialog, add FAB with AddKeyDialog, support copying revealed values, add status badges, and pin restart banner.

* fix(keys): fix scaffold actions parameter and missing string resource

Resolve compilation errors in KeysScreen by removing floatingActionButton from HermesScaffold and adding missing keys_action_open_url resource.

* feat(keys): replace dropdown headers with horizontal category filter chips

Add CategoryFilterBar featuring horizontal FilterChips for quick category filtering (All, LLM Providers, Tool API Keys, Messaging Platforms, Agent Settings) with total key counts.

* style(keys): enhance key card padding, spacing, borders, and surface contrast

Use surfaceContainerLow with subtle outlineVariant border and 16dp rounded corners for high contrast cards, and set 12dp list spacing between cards.

* fix(keys): wrap key title and status badge in FlowRow to prevent layout overflow on long key names

Prevent status badge from squishing or overflowing when displaying long API key names by using a FlowRow that wraps gracefully.

* fix(keys): add file-level OptIn for ExperimentalLayoutApi

Fix compilation error by adding @file:OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) to KeysScreen.kt.

* fix(keys): fully qualify class names in file-level OptIn annotation

Fix unresolved reference build error by fully qualifying ExperimentalMaterial3Api and ExperimentalLayoutApi in @file:OptIn before package statement.

* fix(keys): remove invalid verticalAlignment parameter from FlowRow

* fix(keys): optimize long API key name readability and wrapping

Insert zero-width spaces after underscores to ensure clean word-boundary wrapping, add adaptive typography (titleSmall with 20sp line height for long names), and stack StatusBadge cleanly below the title.

* feat(keys): make API key names and values single-line and horizontally scrollable

Enforce singleLine=true / maxLines=1 with horizontalScroll(rememberScrollState()) for both key names and revealed key values so cards remain compact with uniform heights.

* feat(keys): keep single-line scrollable key values and clean multi-line key names

Keep secret key values single-line and horizontally scrollable, while restoring clean multi-line word-boundary breaking (at underscores) for key names.

* style(keys): format KeysScreen.kt with ktlint
This commit is contained in:
M57
2026-07-24 02:16:22 +03:00
committed by GitHub
parent 8d3d4b83f3
commit 9089b4f8ac
3 changed files with 626 additions and 278 deletions
@@ -1,7 +1,19 @@
@file:OptIn(
androidx.compose.material3.ExperimentalMaterial3Api::class,
androidx.compose.foundation.layout.ExperimentalLayoutApi::class,
)
package com.m57.hermescontrol.ui.keys
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -11,28 +23,44 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.Build
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material.icons.outlined.SmartToy
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
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.Surface
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
@@ -41,13 +69,24 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -55,11 +94,15 @@ import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@OptIn(ExperimentalMaterial3Api::class)
private const val FILTER_ALL = "ALL"
private val keysContentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)
private val keysItemSpacing = Arrangement.spacedBy(12.dp)
@Composable
fun KeysScreen(
modifier: Modifier = Modifier,
@@ -67,8 +110,10 @@ fun KeysScreen(
viewModel: KeysViewModel = viewModel { KeysViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf(FILTER_ALL) }
val filterAllLabel = stringResource(R.string.keys_filter_all)
val filteredCategories =
remember(query, state.categories) {
@@ -90,6 +135,15 @@ fun KeysScreen(
}
}
val visibleCategories =
remember(filteredCategories, selectedCategory) {
if (selectedCategory == FILTER_ALL) {
filteredCategories
} else {
filteredCategories.filter { it.name == selectedCategory }
}
}
val hasAnyVars = state.categories.any { it.vars.isNotEmpty() }
LaunchedEffect(Unit) {
@@ -98,12 +152,72 @@ fun KeysScreen(
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
// ── Delete Confirmation Dialog ──────────────────────────────────────────
state.deleteTargetKey?.let { key ->
AlertDialog(
onDismissRequest = { viewModel.dismissDeleteDialog() },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.keys_dialog_delete_title))
}
},
text = {
Text(stringResource(R.string.keys_dialog_delete_message, key))
},
confirmButton = {
Button(
onClick = { viewModel.confirmDeleteKey() },
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.keys_action_delete))
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissDeleteDialog() }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
// ── Add Key Dialog ──────────────────────────────────────────────────────
if (state.showAddDialog) {
AddKeyDialog(
newKeyName = state.newKeyName,
newKeyValue = state.newKeyValue,
isAdding = state.isAddingKey,
onNameChange = viewModel::setNewKeyName,
onValueChange = viewModel::setNewKeyValue,
onAdd = viewModel::addKey,
onDismiss = viewModel::dismissAddDialog,
)
}
HermesScaffold(
title = { Text(stringResource(R.string.screen_keys)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadKeys() },
actions = {
IconButton(onClick = { viewModel.openAddDialog() }) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(R.string.content_desc_add_key),
)
}
},
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
when {
state.isLoading && !hasAnyVars -> {
SkeletonListState(modifier = Modifier.padding(paddingValues))
@@ -130,10 +244,20 @@ fun KeysScreen(
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
contentPadding = keysContentPadding,
verticalArrangement = keysItemSpacing,
) {
// ── 1. Search ────────────────────────────────────────────
// ── 1. Restart banner (pinned at top when dirty) ─────────
if (state.keysChanged) {
item(key = "restart-banner") {
RestartBanner(
isRestarting = state.isRestartingGateway,
onRestart = viewModel::restartGateway,
)
}
}
// ── 2. Search Bar ────────────────────────────────────────
item(key = "search") {
SearchBar(
query = query,
@@ -142,42 +266,43 @@ fun KeysScreen(
)
}
// ── 2. Add new key form ─────────────────────────────────
item(key = "add-key") {
AddKeySection(
newKeyName = state.newKeyName,
newKeyValue = state.newKeyValue,
isAdding = state.isAddingKey,
onNameChange = viewModel::setNewKeyName,
onValueChange = viewModel::setNewKeyValue,
onAdd = viewModel::addKey,
// ── 3. Category Filter Chips ─────────────────────────────
item(key = "filter-bar") {
CategoryFilterBar(
categories = state.categories,
selectedCategory = selectedCategory,
onCategorySelected = { selectedCategory = it },
filterAllLabel = filterAllLabel,
)
}
// ── 3. Categorized sections ──────────────────────────────
if (filteredCategories.isEmpty() && query.isNotBlank()) {
// ── 4. Keys List ─────────────────────────────────────────
if (visibleCategories.isEmpty()) {
item(key = "no-results") {
Box(
modifier = Modifier.fillParentMaxHeight(0.6f),
contentAlignment = Alignment.Center,
) {
EmptyState(
title = stringResource(R.string.keys_no_matching_title),
subtitle = stringResource(R.string.keys_no_matching_desc),
actionLabel = stringResource(R.string.empty_action_clear_search),
onAction = { query = "" },
modifier = Modifier.padding(paddingValues),
onAction = {
query = ""
selectedCategory = FILTER_ALL
},
)
}
}
filteredCategories.forEach { section ->
item(key = "category-header-${section.name}") {
CategoryHeader(
name = section.name,
count = section.vars.size,
expanded = section.expanded,
onToggle = { viewModel.toggleCategory(section.name) },
)
}
if (section.expanded) {
visibleCategories.forEach { section ->
if (selectedCategory == FILTER_ALL && filteredCategories.size > 1) {
item(key = "category-section-${section.name}") {
SectionHeader(title = "${section.name} (${section.vars.size})")
}
}
items(
items = section.vars.toList(),
key = { (key, _) -> "key-$key" },
@@ -190,246 +315,380 @@ fun KeysScreen(
onReveal = { viewModel.revealKey(key) },
onHide = { viewModel.hideKey(key) },
onSave = { value -> viewModel.updateKey(key, value) },
onDelete = { viewModel.deleteKey(key) },
)
}
}
}
// ── 4. Restart banner ────────────────────────────────────
if (state.keysChanged) {
item(key = "restart-banner") {
RestartBanner(
isRestarting = state.isRestartingGateway,
onRestart = viewModel::restartGateway,
onDismiss = { /* changes stay dirty until restart */ },
onRequestDelete = { viewModel.requestDeleteKey(key) },
onShowToast = { msg -> viewModel.showToast(msg) },
)
}
}
}
}
}
FloatingActionButton(
onClick = { viewModel.openAddDialog() },
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(R.string.content_desc_add_key),
)
}
}
}
}
// ── Add new key form ─────────────────────────────────────────────────────────
// ── Category Filter Bar ──────────────────────────────────────────────────────
@Composable
private fun AddKeySection(
private fun CategoryFilterBar(
categories: List<CategorySection>,
selectedCategory: String,
onCategorySelected: (String) -> Unit,
filterAllLabel: String,
modifier: Modifier = Modifier,
) {
val totalCount = remember(categories) { categories.sumOf { it.vars.size } }
LazyRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 2.dp),
) {
item(key = "filter-all") {
FilterChip(
selected = selectedCategory == FILTER_ALL,
onClick = { onCategorySelected(FILTER_ALL) },
label = { Text("$filterAllLabel ($totalCount)") },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Apps,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
},
)
}
items(
items = categories,
key = { section -> "filter-${section.name}" },
) { section ->
val icon: ImageVector =
when (section.name) {
"LLM Providers" -> Icons.Outlined.SmartToy
"Tool API Keys" -> Icons.Outlined.Build
"Messaging Platforms" -> Icons.Outlined.Forum
"Agent Settings" -> Icons.Outlined.Tune
else -> Icons.Outlined.Folder
}
FilterChip(
selected = selectedCategory == section.name,
onClick = { onCategorySelected(section.name) },
label = { Text("${section.name} (${section.vars.size})") },
leadingIcon = {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
},
)
}
}
}
// ── Add key dialog ───────────────────────────────────────────────────────────
@Composable
private fun AddKeyDialog(
newKeyName: String,
newKeyValue: String,
isAdding: Boolean,
onNameChange: (String) -> Unit,
onValueChange: (String) -> Unit,
onAdd: () -> Unit,
onDismiss: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
SectionHeader(title = stringResource(R.string.keys_add_header))
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
var valueVisible by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { if (!isAdding) onDismiss() },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.keys_dialog_add_title))
}
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = newKeyName,
onValueChange = onNameChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringResource(R.string.keys_add_key_label)) },
placeholder = { Text("MY_API_KEY") },
placeholder = { Text(stringResource(R.string.keys_add_key_placeholder)) },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
trailingIcon = {
if (newKeyName.isNotEmpty() && !isAdding) {
IconButton(onClick = { onNameChange("") }) {
Icon(Icons.Filled.Clear, contentDescription = null)
}
}
},
enabled = !isAdding,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = newKeyValue,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringResource(R.string.keys_add_value_label)) },
placeholder = { Text("sk-...") },
placeholder = { Text(stringResource(R.string.keys_add_value_placeholder)) },
visualTransformation =
if (valueVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { valueVisible = !valueVisible }) {
Icon(
imageVector =
if (valueVisible) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = null,
)
}
},
enabled = !isAdding,
)
Spacer(modifier = Modifier.height(12.dp))
}
},
confirmButton = {
Button(
onClick = onAdd,
modifier = Modifier.fillMaxWidth(),
enabled = !isAdding && newKeyName.isNotBlank() && newKeyValue.isNotBlank(),
) {
if (isAdding) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(modifier = Modifier.width(8.dp))
} else {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(6.dp))
}
Text(stringResource(R.string.keys_add_action))
}
}
}
}
}
// ── Category header ──────────────────────────────────────────────────────────
@Composable
private fun CategoryHeader(
name: String,
count: Int,
expanded: Boolean,
onToggle: () -> Unit,
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isAdding,
) {
Card(
onClick = onToggle,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$count",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f),
)
Text(stringResource(R.string.action_cancel))
}
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSecondaryContainer,
},
)
}
}
}
// ── Env var card ──────────────────────────────────────────────────────────────
@Composable
private fun EnvVarCard(
key: String,
config: com.m57.hermescontrol.data.model.EnvVarConfig,
config: EnvVarConfig,
revealedValue: String?,
isDeleting: Boolean,
onReveal: () -> Unit,
onHide: () -> Unit,
onSave: (String) -> Unit,
onDelete: () -> Unit,
onRequestDelete: () -> Unit,
onShowToast: (String) -> Unit,
) {
var isEditing by remember { mutableStateOf(false) }
var valueVisible by remember { mutableStateOf(false) }
val isRevealed = revealedValue != null
var editedValue by remember(config, revealedValue) { mutableStateOf(revealedValue ?: "") }
val clipboardManager = LocalClipboardManager.current
val uriHandler = LocalUriHandler.current
val displayValue =
if (isRevealed) {
revealedValue.orEmpty()
} else if (config.isSet) {
config.redactedValue ?: "********"
config.redactedValue ?: "••••••••••••"
} else {
stringResource(R.string.keys_label_not_configured)
}
Card(modifier = Modifier.fillMaxWidth()) {
val copiedMessage = stringResource(R.string.keys_copied_toast)
// Insert zero-width spaces (\u200B) after underscores so line breaks happen cleanly between words
val formattedKeyName = remember(key) { key.replace("_", "_\u200B") }
Card(
modifier =
Modifier
.fillMaxWidth()
.animateContentSize(),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
// Key name + delete button row
// Header: Key name (breaks cleanly at underscores _) + Delete Button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = key,
style = MaterialTheme.typography.titleMedium,
text = formattedKeyName,
style =
if (key.length > 24) {
MaterialTheme.typography.titleSmall
} else {
MaterialTheme.typography.titleMedium
},
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.SemiBold,
lineHeight = 20.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(6.dp))
StatusBadge(
text =
if (config.isSet) {
stringResource(R.string.keys_status_configured)
} else {
stringResource(R.string.keys_status_not_set)
},
status =
if (config.isSet) {
StatusBadgeType.SUCCESS
} else {
StatusBadgeType.WARNING
},
)
// Description
if (!config.description.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = config.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
)
}
}
if (!isEditing) {
IconButton(
onClick = onDelete,
onClick = onRequestDelete,
enabled = !isDeleting,
) {
if (isDeleting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.error,
)
} else {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription =
stringResource(
R.string.content_desc_delete_key,
),
tint = MaterialTheme.colorScheme.error,
contentDescription = stringResource(R.string.content_desc_delete_key),
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.8f),
)
}
}
}
}
// Description
if (!config.description.isNullOrBlank()) {
Text(
text = config.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(vertical = 4.dp),
)
}
// Provider link
// External documentation link button
if (!config.url.isNullOrBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.clickable {
try {
uriHandler.openUri(config.url)
} catch (_: Exception) {
// ignore invalid URLs
}
}.padding(vertical = 2.dp),
) {
Icon(
imageVector = Icons.Outlined.OpenInNew,
contentDescription = stringResource(R.string.keys_action_open_url),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = config.url,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp),
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(10.dp))
// Value display / edit
// Value Display Box (Single-line, horizontally scrollable) or Edit Form
if (isEditing) {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = editedValue,
onValueChange = { editedValue = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringResource(R.string.keys_add_value_label)) },
visualTransformation =
if (valueVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { valueVisible = !valueVisible }) {
Icon(
imageVector =
if (valueVisible) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = null,
)
}
},
)
Spacer(modifier = Modifier.height(8.dp))
Row(
@@ -447,19 +706,43 @@ private fun EnvVarCard(
Text(stringResource(R.string.action_save))
}
}
}
} else {
Row(
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceContainerLowest,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier =
Modifier
.weight(1f)
.horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = displayValue,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color =
if (config.isSet) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
},
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
maxLines = 1,
softWrap = false,
)
}
Row {
if (config.isSet) {
IconButton(onClick = if (isRevealed) onHide else onReveal) {
@@ -470,12 +753,22 @@ private fun EnvVarCard(
} else {
Icons.Filled.Visibility
},
contentDescription =
stringResource(
R.string.keys_action_toggle_visibility,
),
contentDescription = stringResource(R.string.keys_action_toggle_visibility),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isRevealed && !revealedValue.isNullOrEmpty()) {
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(revealedValue))
onShowToast(copiedMessage)
}) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = stringResource(R.string.keys_action_copy),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
IconButton(onClick = {
editedValue = if (isRevealed) revealedValue.orEmpty() else ""
@@ -484,6 +777,7 @@ private fun EnvVarCard(
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.content_desc_edit),
tint = MaterialTheme.colorScheme.primary,
)
}
}
@@ -492,6 +786,7 @@ private fun EnvVarCard(
}
}
}
}
// ── Restart banner ────────────────────────────────────────────────────────────
@@ -499,20 +794,23 @@ private fun EnvVarCard(
private fun RestartBanner(
isRestarting: Boolean,
onRestart: () -> Unit,
onDismiss: () -> Unit,
) {
val statusColors = LocalHermesStatusColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, statusColors.warning.copy(alpha = 0.4f)),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
containerColor = statusColors.warningContainer,
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -521,34 +819,36 @@ private fun RestartBanner(
text = stringResource(R.string.keys_restart_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onTertiaryContainer,
color = statusColors.warning,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.keys_restart_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f),
color = statusColors.warning.copy(alpha = 0.9f),
)
}
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(10.dp))
Button(
onClick = onRestart,
enabled = !isRestarting,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiary,
containerColor = statusColors.warning,
contentColor = MaterialTheme.colorScheme.surface,
),
) {
if (isRestarting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onTertiary,
color = MaterialTheme.colorScheme.surface,
)
} else {
Icon(
imageVector = Icons.Filled.RestartAlt,
contentDescription = null,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.keys_restart_action))
@@ -32,6 +32,8 @@ data class KeysUiState(
val newKeyName: String = "",
val newKeyValue: String = "",
val isAddingKey: Boolean = false,
val showAddDialog: Boolean = false,
val deleteTargetKey: String? = null,
val deletingKeys: Set<String> = emptySet(),
val keysChanged: Boolean = false,
val isRestartingGateway: Boolean = false,
@@ -128,6 +130,20 @@ class KeysViewModel :
}
}
fun openAddDialog() {
_uiState.update { it.copy(showAddDialog = true) }
}
fun dismissAddDialog() {
_uiState.update {
it.copy(
showAddDialog = false,
newKeyName = "",
newKeyValue = "",
)
}
}
fun setNewKeyName(name: String) {
_uiState.update { it.copy(newKeyName = name) }
}
@@ -155,6 +171,7 @@ class KeysViewModel :
_uiState.update {
it.copy(
isAddingKey = false,
showAddDialog = false,
newKeyName = "",
newKeyValue = "",
keysChanged = true,
@@ -176,7 +193,21 @@ class KeysViewModel :
}
}
fun deleteKey(key: String) {
fun requestDeleteKey(key: String) {
_uiState.update { it.copy(deleteTargetKey = key) }
}
fun dismissDeleteDialog() {
_uiState.update { it.copy(deleteTargetKey = null) }
}
fun confirmDeleteKey() {
val key = _uiState.value.deleteTargetKey ?: return
_uiState.update { it.copy(deleteTargetKey = null) }
deleteKey(key)
}
private fun deleteKey(key: String) {
_uiState.update { it.copy(deletingKeys = it.deletingKeys + key) }
viewModelScope.launch {
val result =
@@ -260,6 +291,10 @@ class KeysViewModel :
}
}
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
fun restartGateway() {
_uiState.update { it.copy(isRestartingGateway = true) }
viewModelScope.launch {
+15 -2
View File
@@ -735,13 +735,26 @@
<string name="keys_no_matching_title">No matching keys</string>
<string name="keys_no_matching_desc">Try a different search term.</string>
<string name="keys_add_header">Add new key</string>
<string name="keys_add_key_label">Key</string>
<string name="keys_add_value_label">Value</string>
<string name="keys_add_key_label">Key name</string>
<string name="keys_add_value_label">Key value</string>
<string name="keys_add_action">Add key</string>
<string name="keys_restart_title">Gateway restart required</string>
<string name="keys_restart_desc">Environment changes take effect after a gateway restart.</string>
<string name="keys_restart_action">Restart gateway</string>
<string name="content_desc_delete_key">Delete key</string>
<string name="content_desc_add_key">Add new environment key</string>
<string name="keys_dialog_delete_title">Delete Key?</string>
<string name="keys_dialog_delete_message">Are you sure you want to delete environment key \"%1$s\"? Changes take effect after restarting the gateway.</string>
<string name="keys_action_delete">Delete</string>
<string name="keys_dialog_add_title">Add Environment Key</string>
<string name="keys_action_copy">Copy value</string>
<string name="keys_copied_toast">Copied key value to clipboard</string>
<string name="keys_status_configured">Configured</string>
<string name="keys_status_not_set">Not Set</string>
<string name="keys_add_key_placeholder">e.g. OPENAI_API_KEY</string>
<string name="keys_add_value_placeholder">e.g. sk-...</string>
<string name="keys_action_open_url">Open provider documentation</string>
<string name="keys_filter_all">All</string>
<!-- Channels Screen -->
<string name="channels_empty_title">No channels configured</string>