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