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 authored and GitHub committed 2026-07-24 02:16:22 +03:00
1 parent 8d3d4b83f3
commit 9089b4f8ac
3 files changed
+524 -176

No files matched your search

@@ -1,7 +1,19 @@
@file:OptIn(
androidx.compose.material3.ExperimentalMaterial3Api::class,
androidx.compose.foundation.layout.ExperimentalLayoutApi::class,
)
package com.m57.hermescontrol.ui.keys 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -11,28 +23,44 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.filled.Add 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.Delete
import androidx.compose.material.icons.filled.Edit 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.RestartAlt
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
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
@@ -41,13 +69,24 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.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.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
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.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
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
@@ -55,11 +94,15 @@ import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.SectionHeader import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.SkeletonListState 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.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 @Composable
fun KeysScreen( fun KeysScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -67,8 +110,10 @@ fun KeysScreen(
viewModel: KeysViewModel = viewModel { KeysViewModel() }, viewModel: KeysViewModel = viewModel { KeysViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf(FILTER_ALL) }
val filterAllLabel = stringResource(R.string.keys_filter_all)
val filteredCategories = val filteredCategories =
remember(query, state.categories) { 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() } val hasAnyVars = state.categories.any { it.vars.isNotEmpty() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -98,12 +152,72 @@ fun KeysScreen(
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) 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( HermesScaffold(
title = { Text(stringResource(R.string.screen_keys)) }, title = { Text(stringResource(R.string.screen_keys)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadKeys() }, onRefresh = { viewModel.loadKeys() },
actions = {
IconButton(onClick = { viewModel.openAddDialog() }) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(R.string.content_desc_add_key),
)
}
},
) { paddingValues -> ) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
when { when {
state.isLoading && !hasAnyVars -> { state.isLoading && !hasAnyVars -> {
SkeletonListState(modifier = Modifier.padding(paddingValues)) SkeletonListState(modifier = Modifier.padding(paddingValues))
@@ -130,10 +244,20 @@ fun KeysScreen(
else -> { else -> {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding, contentPadding = keysContentPadding,
verticalArrangement = listItemSpacing, 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") { item(key = "search") {
SearchBar( SearchBar(
query = query, query = query,
@@ -142,42 +266,43 @@ fun KeysScreen(
) )
} }
// ── 2. Add new key form ───────────────────────────────── // ── 3. Category Filter Chips ─────────────────────────────
item(key = "add-key") { item(key = "filter-bar") {
AddKeySection( CategoryFilterBar(
newKeyName = state.newKeyName, categories = state.categories,
newKeyValue = state.newKeyValue, selectedCategory = selectedCategory,
isAdding = state.isAddingKey, onCategorySelected = { selectedCategory = it },
onNameChange = viewModel::setNewKeyName, filterAllLabel = filterAllLabel,
onValueChange = viewModel::setNewKeyValue,
onAdd = viewModel::addKey,
) )
} }
// ── 3. Categorized sections ────────────────────────────── // ── 4. Keys List ─────────────────────────────────────────
if (filteredCategories.isEmpty() && query.isNotBlank()) { if (visibleCategories.isEmpty()) {
item(key = "no-results") { item(key = "no-results") {
Box(
modifier = Modifier.fillParentMaxHeight(0.6f),
contentAlignment = Alignment.Center,
) {
EmptyState( EmptyState(
title = stringResource(R.string.keys_no_matching_title), title = stringResource(R.string.keys_no_matching_title),
subtitle = stringResource(R.string.keys_no_matching_desc), subtitle = stringResource(R.string.keys_no_matching_desc),
actionLabel = stringResource(R.string.empty_action_clear_search), actionLabel = stringResource(R.string.empty_action_clear_search),
onAction = { query = "" }, onAction = {
modifier = Modifier.padding(paddingValues), 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(
items = section.vars.toList(), items = section.vars.toList(),
key = { (key, _) -> "key-$key" }, key = { (key, _) -> "key-$key" },
@@ -190,246 +315,380 @@ fun KeysScreen(
onReveal = { viewModel.revealKey(key) }, onReveal = { viewModel.revealKey(key) },
onHide = { viewModel.hideKey(key) }, onHide = { viewModel.hideKey(key) },
onSave = { value -> viewModel.updateKey(key, value) }, onSave = { value -> viewModel.updateKey(key, value) },
onDelete = { viewModel.deleteKey(key) }, onRequestDelete = { viewModel.requestDeleteKey(key) },
) onShowToast = { msg -> viewModel.showToast(msg) },
}
}
}
// ── 4. Restart banner ────────────────────────────────────
if (state.keysChanged) {
item(key = "restart-banner") {
RestartBanner(
isRestarting = state.isRestartingGateway,
onRestart = viewModel::restartGateway,
onDismiss = { /* changes stay dirty until restart */ },
) )
} }
} }
} }
} }
} }
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 @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, newKeyName: String,
newKeyValue: String, newKeyValue: String,
isAdding: Boolean, isAdding: Boolean,
onNameChange: (String) -> Unit, onNameChange: (String) -> Unit,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onAdd: () -> Unit, onAdd: () -> Unit,
onDismiss: () -> Unit,
) { ) {
Column( var valueVisible by remember { mutableStateOf(false) }
modifier = Modifier.fillMaxWidth(),
) { AlertDialog(
SectionHeader(title = stringResource(R.string.keys_add_header)) onDismissRequest = { if (!isAdding) onDismiss() },
Card( title = {
modifier = Row(verticalAlignment = Alignment.CenterVertically) {
Modifier Icon(
.fillMaxWidth() imageVector = Icons.Filled.Add,
.padding(horizontal = 16.dp, vertical = 4.dp), contentDescription = null,
colors = tint = MaterialTheme.colorScheme.primary,
CardDefaults.cardColors( modifier = Modifier.size(20.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainer, )
), Spacer(modifier = Modifier.width(8.dp))
) { Text(stringResource(R.string.keys_dialog_add_title))
Column(modifier = Modifier.padding(16.dp)) { }
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField( OutlinedTextField(
value = newKeyName, value = newKeyName,
onValueChange = onNameChange, onValueChange = onNameChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
label = { Text(stringResource(R.string.keys_add_key_label)) }, 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, enabled = !isAdding,
) )
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = newKeyValue, value = newKeyValue,
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
label = { Text(stringResource(R.string.keys_add_value_label)) }, 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, enabled = !isAdding,
) )
Spacer(modifier = Modifier.height(12.dp)) }
},
confirmButton = {
Button( Button(
onClick = onAdd, onClick = onAdd,
modifier = Modifier.fillMaxWidth(),
enabled = !isAdding && newKeyName.isNotBlank() && newKeyValue.isNotBlank(), enabled = !isAdding && newKeyName.isNotBlank() && newKeyValue.isNotBlank(),
) { ) {
if (isAdding) { if (isAdding) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(18.dp), modifier = Modifier.size(16.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(6.dp))
} else {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
} }
Text(stringResource(R.string.keys_add_action)) Text(stringResource(R.string.keys_add_action))
} }
} },
} dismissButton = {
} TextButton(
} onClick = onDismiss,
enabled = !isAdding,
// ── Category header ──────────────────────────────────────────────────────────
@Composable
private fun CategoryHeader(
name: String,
count: Int,
expanded: Boolean,
onToggle: () -> Unit,
) { ) {
Card( Text(stringResource(R.string.action_cancel))
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),
)
} }
Icon( },
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSecondaryContainer,
) )
} }
}
}
// ── Env var card ────────────────────────────────────────────────────────────── // ── Env var card ──────────────────────────────────────────────────────────────
@Composable @Composable
private fun EnvVarCard( private fun EnvVarCard(
key: String, key: String,
config: com.m57.hermescontrol.data.model.EnvVarConfig, config: EnvVarConfig,
revealedValue: String?, revealedValue: String?,
isDeleting: Boolean, isDeleting: Boolean,
onReveal: () -> Unit, onReveal: () -> Unit,
onHide: () -> Unit, onHide: () -> Unit,
onSave: (String) -> Unit, onSave: (String) -> Unit,
onDelete: () -> Unit, onRequestDelete: () -> Unit,
onShowToast: (String) -> Unit,
) { ) {
var isEditing by remember { mutableStateOf(false) } var isEditing by remember { mutableStateOf(false) }
var valueVisible by remember { mutableStateOf(false) }
val isRevealed = revealedValue != null val isRevealed = revealedValue != null
var editedValue by remember(config, revealedValue) { mutableStateOf(revealedValue ?: "") } var editedValue by remember(config, revealedValue) { mutableStateOf(revealedValue ?: "") }
val clipboardManager = LocalClipboardManager.current
val uriHandler = LocalUriHandler.current
val displayValue = val displayValue =
if (isRevealed) { if (isRevealed) {
revealedValue.orEmpty() revealedValue.orEmpty()
} else if (config.isSet) { } else if (config.isSet) {
config.redactedValue ?: "********" config.redactedValue ?: "••••••••••••"
} else { } else {
stringResource(R.string.keys_label_not_configured) 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)) { Column(modifier = Modifier.padding(16.dp)) {
// Key name + delete button row // Header: Key name (breaks cleanly at underscores _) + Delete Button
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.Top,
) { ) {
Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = key, text = formattedKeyName,
style = MaterialTheme.typography.titleMedium, style =
if (key.length > 24) {
MaterialTheme.typography.titleSmall
} else {
MaterialTheme.typography.titleMedium
},
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f), 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) { if (!isEditing) {
IconButton( IconButton(
onClick = onDelete, onClick = onRequestDelete,
enabled = !isDeleting, enabled = !isDeleting,
) { ) {
if (isDeleting) { if (isDeleting) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.error,
) )
} else { } else {
Icon( Icon(
imageVector = Icons.Filled.Delete, imageVector = Icons.Filled.Delete,
contentDescription = contentDescription = stringResource(R.string.content_desc_delete_key),
stringResource( tint = MaterialTheme.colorScheme.error.copy(alpha = 0.8f),
R.string.content_desc_delete_key,
),
tint = MaterialTheme.colorScheme.error,
) )
} }
} }
} }
} }
// Description // External documentation link button
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
if (!config.url.isNullOrBlank()) { 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(
text = config.url, text = config.url,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary, 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) { if (isEditing) {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField( OutlinedTextField(
value = editedValue, value = editedValue,
onValueChange = { editedValue = it }, onValueChange = { editedValue = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
label = { Text(stringResource(R.string.keys_add_value_label)) }, 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)) Spacer(modifier = Modifier.height(8.dp))
Row( Row(
@@ -447,19 +706,43 @@ private fun EnvVarCard(
Text(stringResource(R.string.action_save)) Text(stringResource(R.string.action_save))
} }
} }
}
} else { } 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(), modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier =
Modifier
.weight(1f)
.horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = displayValue, text = displayValue,
style = MaterialTheme.typography.bodyMedium, 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, fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f), maxLines = 1,
softWrap = false,
) )
}
Row { Row {
if (config.isSet) { if (config.isSet) {
IconButton(onClick = if (isRevealed) onHide else onReveal) { IconButton(onClick = if (isRevealed) onHide else onReveal) {
@@ -470,12 +753,22 @@ private fun EnvVarCard(
} else { } else {
Icons.Filled.Visibility Icons.Filled.Visibility
}, },
contentDescription = contentDescription = stringResource(R.string.keys_action_toggle_visibility),
stringResource( tint = MaterialTheme.colorScheme.onSurfaceVariant,
R.string.keys_action_toggle_visibility,
),
) )
} }
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 = { IconButton(onClick = {
editedValue = if (isRevealed) revealedValue.orEmpty() else "" editedValue = if (isRevealed) revealedValue.orEmpty() else ""
@@ -484,6 +777,7 @@ private fun EnvVarCard(
Icon( Icon(
imageVector = Icons.Filled.Edit, imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.content_desc_edit), contentDescription = stringResource(R.string.content_desc_edit),
tint = MaterialTheme.colorScheme.primary,
) )
} }
} }
@@ -492,6 +786,7 @@ private fun EnvVarCard(
} }
} }
} }
}
// ── Restart banner ──────────────────────────────────────────────────────────── // ── Restart banner ────────────────────────────────────────────────────────────
@@ -499,20 +794,23 @@ private fun EnvVarCard(
private fun RestartBanner( private fun RestartBanner(
isRestarting: Boolean, isRestarting: Boolean,
onRestart: () -> Unit, onRestart: () -> Unit,
onDismiss: () -> Unit,
) { ) {
val statusColors = LocalHermesStatusColors.current
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, statusColors.warning.copy(alpha = 0.4f)),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer, containerColor = statusColors.warningContainer,
), ),
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -521,34 +819,36 @@ private fun RestartBanner(
text = stringResource(R.string.keys_restart_title), text = stringResource(R.string.keys_restart_title),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onTertiaryContainer, color = statusColors.warning,
) )
Spacer(modifier = Modifier.height(2.dp))
Text( Text(
text = stringResource(R.string.keys_restart_desc), text = stringResource(R.string.keys_restart_desc),
style = MaterialTheme.typography.bodySmall, 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( Button(
onClick = onRestart, onClick = onRestart,
enabled = !isRestarting, enabled = !isRestarting,
colors = colors =
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiary, containerColor = statusColors.warning,
contentColor = MaterialTheme.colorScheme.surface,
), ),
) { ) {
if (isRestarting) { if (isRestarting) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(18.dp), modifier = Modifier.size(16.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onTertiary, color = MaterialTheme.colorScheme.surface,
) )
} else { } else {
Icon( Icon(
imageVector = Icons.Filled.RestartAlt, imageVector = Icons.Filled.RestartAlt,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(18.dp), modifier = Modifier.size(16.dp),
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.keys_restart_action)) Text(stringResource(R.string.keys_restart_action))
@@ -32,6 +32,8 @@ data class KeysUiState(
val newKeyName: String = "", val newKeyName: String = "",
val newKeyValue: String = "", val newKeyValue: String = "",
val isAddingKey: Boolean = false, val isAddingKey: Boolean = false,
val showAddDialog: Boolean = false,
val deleteTargetKey: String? = null,
val deletingKeys: Set<String> = emptySet(), val deletingKeys: Set<String> = emptySet(),
val keysChanged: Boolean = false, val keysChanged: Boolean = false,
val isRestartingGateway: 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) { fun setNewKeyName(name: String) {
_uiState.update { it.copy(newKeyName = name) } _uiState.update { it.copy(newKeyName = name) }
} }
@@ -155,6 +171,7 @@ class KeysViewModel :
_uiState.update { _uiState.update {
it.copy( it.copy(
isAddingKey = false, isAddingKey = false,
showAddDialog = false,
newKeyName = "", newKeyName = "",
newKeyValue = "", newKeyValue = "",
keysChanged = true, 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) } _uiState.update { it.copy(deletingKeys = it.deletingKeys + key) }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
@@ -260,6 +291,10 @@ class KeysViewModel :
} }
} }
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
fun restartGateway() { fun restartGateway() {
_uiState.update { it.copy(isRestartingGateway = true) } _uiState.update { it.copy(isRestartingGateway = true) }
viewModelScope.launch { 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_title">No matching keys</string>
<string name="keys_no_matching_desc">Try a different search term.</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_header">Add new key</string>
<string name="keys_add_key_label">Key</string> <string name="keys_add_key_label">Key name</string>
<string name="keys_add_value_label">Value</string> <string name="keys_add_value_label">Key value</string>
<string name="keys_add_action">Add key</string> <string name="keys_add_action">Add key</string>
<string name="keys_restart_title">Gateway restart required</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_desc">Environment changes take effect after a gateway restart.</string>
<string name="keys_restart_action">Restart gateway</string> <string name="keys_restart_action">Restart gateway</string>
<string name="content_desc_delete_key">Delete key</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 --> <!-- Channels Screen -->
<string name="channels_empty_title">No channels configured</string> <string name="channels_empty_title">No channels configured</string>