From 9089b4f8ac2cf2e53c4621fd3be4a6a94875ff96 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:16:22 +0300 Subject: [PATCH] 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 --- .../m57/hermescontrol/ui/keys/KeysScreen.kt | 850 ++++++++++++------ .../hermescontrol/ui/keys/KeysViewModel.kt | 37 +- app/src/main/res/values/strings.xml | 17 +- 3 files changed, 626 insertions(+), 278 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt index 6270874d..b4a8e119 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt @@ -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,86 +152,157 @@ 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 -> - when { - state.isLoading && !hasAnyVars -> { - SkeletonListState(modifier = Modifier.padding(paddingValues)) - } + Box(modifier = Modifier.fillMaxSize()) { + when { + state.isLoading && !hasAnyVars -> { + SkeletonListState(modifier = Modifier.padding(paddingValues)) + } - state.errorMessage != null && !hasAnyVars -> { - ErrorState( - message = state.errorMessage ?: "", - onRetry = { viewModel.loadKeys() }, - modifier = Modifier.padding(paddingValues), - ) - } + state.errorMessage != null && !hasAnyVars -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadKeys() }, + modifier = Modifier.padding(paddingValues), + ) + } - !hasAnyVars && query.isBlank() -> { - EmptyState( - title = stringResource(R.string.keys_empty_title), - subtitle = stringResource(R.string.keys_empty_desc), - onAction = { viewModel.loadKeys() }, - actionLabel = stringResource(R.string.content_desc_refresh), - modifier = Modifier.padding(paddingValues), - ) - } + !hasAnyVars && query.isBlank() -> { + EmptyState( + title = stringResource(R.string.keys_empty_title), + subtitle = stringResource(R.string.keys_empty_desc), + onAction = { viewModel.loadKeys() }, + actionLabel = stringResource(R.string.content_desc_refresh), + modifier = Modifier.padding(paddingValues), + ) + } - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listContentPadding, - verticalArrangement = listItemSpacing, - ) { - // ── 1. Search ──────────────────────────────────────────── - item(key = "search") { - SearchBar( - query = query, - onQueryChange = { query = it }, - placeholder = stringResource(R.string.keys_search_placeholder), - ) - } - - // ── 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. Categorized sections ────────────────────────────── - if (filteredCategories.isEmpty() && query.isNotBlank()) { - item(key = "no-results") { - 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), - ) + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = keysContentPadding, + verticalArrangement = keysItemSpacing, + ) { + // ── 1. Restart banner (pinned at top when dirty) ───────── + if (state.keysChanged) { + item(key = "restart-banner") { + RestartBanner( + isRestarting = state.isRestartingGateway, + onRestart = viewModel::restartGateway, + ) + } } - } - 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) }, + // ── 2. Search Bar ──────────────────────────────────────── + item(key = "search") { + SearchBar( + query = query, + onQueryChange = { query = it }, + placeholder = stringResource(R.string.keys_search_placeholder), ) } - if (section.expanded) { + // ── 3. Category Filter Chips ───────────────────────────── + item(key = "filter-bar") { + CategoryFilterBar( + categories = state.categories, + selectedCategory = selectedCategory, + onCategorySelected = { selectedCategory = it }, + filterAllLabel = filterAllLabel, + ) + } + + // ── 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 = "" + selectedCategory = FILTER_ALL + }, + ) + } + } + } + + 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,149 +315,197 @@ fun KeysScreen( onReveal = { viewModel.revealKey(key) }, onHide = { viewModel.hideKey(key) }, 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 -private fun AddKeySection( +private fun CategoryFilterBar( + categories: List, + 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)) - Button( - onClick = onAdd, - modifier = Modifier.fillMaxWidth(), - enabled = !isAdding && newKeyName.isNotBlank() && newKeyValue.isNotBlank(), - ) { - if (isAdding) { - CircularProgressIndicator( - modifier = Modifier.size(18.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)) - } - Text(stringResource(R.string.keys_add_action)) + } + }, + confirmButton = { + Button( + onClick = onAdd, + enabled = !isAdding && newKeyName.isNotBlank() && newKeyValue.isNotBlank(), + ) { + if (isAdding) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + 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, -) { - 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), - ) + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isAdding, + ) { + 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 ────────────────────────────────────────────────────────────── @@ -340,152 +513,274 @@ private fun CategoryHeader( @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, ) { - Text( - text = key, - style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = formattedKeyName, + style = + if (key.length > 24) { + MaterialTheme.typography.titleSmall + } else { + MaterialTheme.typography.titleMedium + }, + fontFamily = FontFamily.Monospace, + 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()) { - Text( - text = config.url, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 4.dp), - ) + 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, + ) + } } - 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) { - OutlinedTextField( - value = editedValue, - onValueChange = { editedValue = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text(stringResource(R.string.keys_add_value_label)) }, - ) - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - OutlinedButton(onClick = { isEditing = false }) { - Text(stringResource(R.string.action_cancel)) - } - Spacer(modifier = Modifier.width(8.dp)) - Button(onClick = { - onSave(editedValue) - isEditing = false - }) { - Text(stringResource(R.string.action_save)) - } - } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = displayValue, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - modifier = Modifier.weight(1f), - ) - Row { - if (config.isSet) { - IconButton(onClick = if (isRevealed) onHide else onReveal) { + 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 (isRevealed) { + if (valueVisible) { Icons.Filled.VisibilityOff } else { Icons.Filled.Visibility }, - contentDescription = - stringResource( - R.string.keys_action_toggle_visibility, - ), + contentDescription = null, ) } + }, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton(onClick = { isEditing = false }) { + Text(stringResource(R.string.action_cancel)) } - IconButton(onClick = { - editedValue = if (isRevealed) revealedValue.orEmpty() else "" - isEditing = true + Spacer(modifier = Modifier.width(8.dp)) + Button(onClick = { + onSave(editedValue) + isEditing = false }) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = stringResource(R.string.content_desc_edit), + Text(stringResource(R.string.action_save)) + } + } + } + } else { + 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 = + if (config.isSet) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + }, + fontFamily = FontFamily.Monospace, + maxLines = 1, + softWrap = false, ) } + Row { + if (config.isSet) { + IconButton(onClick = if (isRevealed) onHide else onReveal) { + Icon( + imageVector = + if (isRevealed) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.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 "" + isEditing = true + }) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = stringResource(R.string.content_desc_edit), + tint = MaterialTheme.colorScheme.primary, + ) + } + } } } } @@ -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)) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt index eb19a480..9af30639 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt @@ -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 = 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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88eb9f0e..9d894978 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -735,13 +735,26 @@ No matching keys Try a different search term. Add new key - Key - Value + Key name + Key value Add key Gateway restart required Environment changes take effect after a gateway restart. Restart gateway Delete key + Add new environment key + Delete Key? + Are you sure you want to delete environment key \"%1$s\"? Changes take effect after restarting the gateway. + Delete + Add Environment Key + Copy value + Copied key value to clipboard + Configured + Not Set + e.g. OPENAI_API_KEY + e.g. sk-... + Open provider documentation + All No channels configured