package com.peachpasswords.ui.vault /* * D6 — Inline per-field editing for EntryDetailScreen. * * Self-contained drop-in composables (orchestrator merges these into * EntryDetailScreen.kt — D6/D7/E6 all touch that file, so this stays isolated * with anchors documented in INTEGRATION.md). * * Provides: * - EditableFieldRow : view <-> edit toggle, underlined BasicTextField, * ✓ save / ✗ cancel, optional inline identity list. * - EditablePasswordRow : same, but gated behind a biometric/password unlock * before entering edit mode (mirrors the reveal flow). * * Both reuse the file's existing private helpers FieldShell, FieldRow-style * label/value layout, and CopyButton. Those are already present in * EntryDetailScreen.kt; this draft references them by name. If the orchestrator * places this in a separate file instead of inlining, FieldShell + CopyButton * must be made `internal` (see INTEGRATION.md). */ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn 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.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material.icons.rounded.Close import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity import com.peachpasswords.ui.components.CopyButton import com.peachpasswords.ui.lock.Biometrics import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType /** * A field row that toggles between read-only display and inline edit. * * View mode: value text + copy button (unchanged look from the existing FieldRow). * Edit mode: an underlined BasicTextField with ✓ save / ✗ cancel beneath it, and * — when [suggestions] is non-empty — an inline, scrollable list of prior values. * * @param field logical field key passed to [onSave] (e.g. "username", "email", "notes"). * @param onSave called with the trimmed draft when the user taps ✓; the caller * (EntryDetailViewModel.updateField) persists it. * @param suggestions identity-suggest values for USERNAME/EMAIL; empty hides the list. * @param mono mono body style for the value (false = serif body, used by notes). * @param displayText rendered form of [value] for view mode; null shows [value] itself. * Notes pass their parsed HTML here (see NoteHtml.kt). * @param draftText what edit mode starts from; null edits [value] itself. Notes * edit the markup form and [onSave] converts it back. */ @Composable fun EditableFieldRow( label: String, field: String, value: String, copied: Boolean, onCopy: () -> Unit, onSave: (String) -> Unit, suggestions: List = emptyList(), mono: Boolean = true, singleLine: Boolean = true, placeholder: String = "", displayText: AnnotatedString? = null, draftText: String? = null, onEditingChange: (Boolean) -> Unit = {}, stacked: Boolean = false, divider: Boolean = false, ) { val editable = draftText ?: value var editing by remember(value) { mutableStateOf(false) } var draft by remember(value) { mutableStateOf(editable) } FieldShell(label, stacked = stacked, divider = divider) { if (!editing) { val isEmpty = value.isBlank() Text( if (isEmpty) AnnotatedString(placeholder) else displayText ?: AnnotatedString(value), color = if (isEmpty) peachColors.T2 else peachColors.T0, style = if (mono) PeachType.bodyMono else PeachType.body, modifier = Modifier .weight(1f) .clickable { draft = editable; editing = true; onEditingChange(true) }, ) if (!isEmpty) CopyButton(copied, onCopy) } else { InlineEditor( draft = draft, onDraftChange = { draft = it }, onSave = { onSave(draft.trim()); editing = false; onEditingChange(false) }, onCancel = { draft = editable; editing = false; onEditingChange(false) }, suggestions = suggestions, onPickSuggestion = { draft = it }, singleLine = singleLine, modifier = Modifier.weight(1f), ) } } } /** * Password variant of [EditableFieldRow]. Entering edit mode is gated behind a * biometric (or device-credential) prompt, mirroring the existing reveal flow in * PasswordRow. The draft starts masked; a show/hide toggle controls visibility. * * The gate runs only on the view→edit transition. Once authenticated the field is * editable with the cleartext draft available. */ @Composable fun EditablePasswordRow( value: String, copied: Boolean, entryTitle: String, onCopy: () -> Unit, onSave: (String) -> Unit, onGenerate: () -> Unit, stacked: Boolean = false, divider: Boolean = false, ) { val accent = LocalPeachAccent.current val context = LocalContext.current var editing by remember(value) { mutableStateOf(false) } var draft by remember(value) { mutableStateOf(value) } var show by remember { mutableStateOf(false) } var reveal by remember(value) { mutableStateOf(false) } fun beginEdit() { val activity = context as? FragmentActivity if (activity != null && Biometrics.isAvailable(context)) { Biometrics.authenticate( activity, title = "Unlock Peach", subtitle = entryTitle, onSuccess = { draft = value; show = false; editing = true }, ) } else { // No biometrics: fall through (same fallback the reveal flow uses). draft = value; show = false; editing = true } } FieldShell("password", stacked = stacked, divider = divider) { if (!editing) { // Revealing is not the same act as editing, so it does not go // through the biometric edit gate — the vault is already unlocked // and the value is one tap from the clipboard either way. Text( text = if (reveal) value else "•".repeat(value.length.coerceIn(8, 16)), color = peachColors.T0, style = PeachType.bodyMono, modifier = Modifier.weight(1f).clickable { beginEdit() }, ) RevealButton(revealed = reveal, onClick = { reveal = !reveal }) Spacer(Modifier.width(6.dp)) CopyButton(copied, onCopy) } else { Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { UnderlinedField( draft = draft, onDraftChange = { draft = it }, transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(), singleLine = true, modifier = Modifier.weight(1f), ) Text( if (show) "hide" else "show", color = accent, style = PeachType.labelMono, modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp), ) Spacer(Modifier.width(6.dp)) // Generate belongs to the moment you are choosing a // password, not to the resting row — a wrench parked next // to a saved password invites overwriting it by accident. WrenchButton(onClick = onGenerate) } Spacer(Modifier.height(8.dp)) SaveCancelRow( onSave = { onSave(draft); editing = false }, onCancel = { draft = value; editing = false }, ) } } } } /** Eye toggle for the resting password row. */ @Composable private fun RevealButton(revealed: Boolean, onClick: () -> Unit) { Box( Modifier .size(36.dp) .clip(RoundedCornerShape(6.dp)) .background(peachColors.S2) .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { Icon( if (revealed) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, contentDescription = if (revealed) "hide password" else "reveal password", tint = peachColors.T2, modifier = Modifier.size(15.dp), ) } } /** Underlined editor + inline scrollable suggestions + save/cancel. */ @Composable private fun InlineEditor( draft: String, onDraftChange: (String) -> Unit, onSave: () -> Unit, onCancel: () -> Unit, suggestions: List, onPickSuggestion: (String) -> Unit, singleLine: Boolean, modifier: Modifier = Modifier, ) { Column(modifier) { UnderlinedField( draft = draft, onDraftChange = onDraftChange, singleLine = singleLine, modifier = Modifier.fillMaxWidth(), ) if (suggestions.isNotEmpty()) { Spacer(Modifier.height(6.dp)) LazyColumn( modifier = Modifier .fillMaxWidth() .heightIn(max = 236.dp) .clip(RoundedCornerShape(8.dp)) .background(peachColors.SurfaceCard), ) { items(suggestions, key = { it.value }) { suggestion -> Row( Modifier .fillMaxWidth() .clickable { onPickSuggestion(suggestion.value) } .padding(horizontal = 12.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( suggestion.value, color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.weight(1f), ) Text( "${suggestion.count}×", color = peachColors.T2, style = PeachType.labelMono, ) } } } } Spacer(Modifier.height(8.dp)) SaveCancelRow(onSave = onSave, onCancel = onCancel) } } /** A BasicTextField with an accent underline drawn beneath it (spec §5 inline edit). */ @Composable private fun UnderlinedField( draft: String, onDraftChange: (String) -> Unit, transformation: VisualTransformation = VisualTransformation.None, singleLine: Boolean = true, modifier: Modifier = Modifier, ) { val accent = LocalPeachAccent.current BasicTextField( value = draft, onValueChange = onDraftChange, singleLine = singleLine, textStyle = PeachType.bodyMono.copy(color = peachColors.T0), cursorBrush = SolidColor(accent), visualTransformation = transformation, modifier = modifier .padding(bottom = 4.dp) .drawBehind { drawLine( color = accent, start = Offset(0f, size.height), end = Offset(size.width, size.height), strokeWidth = 1.dp.toPx(), ) }, ) } @Composable private fun SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) { val accent = LocalPeachAccent.current // Matches CopyButton's icon-button convention (36dp, 6dp rounded, filled // background) — these used to be bare "✓ save" / "✗" text at 10sp with a // few dp of padding, well under the 48dp touch-target minimum and visibly // inconsistent with every other action button on this screen. Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { Box( Modifier .size(36.dp) .clip(RoundedCornerShape(6.dp)) .background(peachColors.S2) .clickable(onClick = onSave), contentAlignment = Alignment.Center, ) { Icon(Icons.Rounded.Check, contentDescription = "save", tint = accent, modifier = Modifier.size(16.dp)) } Box( Modifier .size(36.dp) .clip(RoundedCornerShape(6.dp)) .background(peachColors.S2) .clickable(onClick = onCancel), contentAlignment = Alignment.Center, ) { Icon(Icons.Rounded.Close, contentDescription = "cancel", tint = peachColors.T2, modifier = Modifier.size(16.dp)) } } }