EditableFieldRow.kt

13.3 KB · raw

1package com.peachpasswords.ui.vault23/*4 * D6 — Inline per-field editing for EntryDetailScreen.5 *6 * Self-contained drop-in composables (orchestrator merges these into7 * EntryDetailScreen.kt — D6/D7/E6 all touch that file, so this stays isolated8 * with anchors documented in INTEGRATION.md).9 *10 * Provides:11 *   - EditableFieldRow      : view <-> edit toggle, underlined BasicTextField,12 *                             ✓ save / ✗ cancel, optional identity-suggest dropdown.13 *   - EditablePasswordRow   : same, but gated behind a biometric/password unlock14 *                             before entering edit mode (mirrors the reveal flow).15 *16 * Both reuse the file's existing private helpers FieldShell, FieldRow-style17 * label/value layout, and CopyButton. Those are already present in18 * EntryDetailScreen.kt; this draft references them by name. If the orchestrator19 * places this in a separate file instead of inlining, FieldShell + CopyButton20 * must be made `internal` (see INTEGRATION.md).21 */2223import androidx.compose.foundation.background24import androidx.compose.foundation.clickable25import androidx.compose.foundation.layout.Arrangement26import androidx.compose.foundation.layout.Box27import androidx.compose.foundation.layout.Column28import androidx.compose.foundation.layout.Row29import androidx.compose.foundation.layout.Spacer30import androidx.compose.foundation.layout.fillMaxWidth31import androidx.compose.foundation.layout.height32import androidx.compose.foundation.layout.padding33import androidx.compose.foundation.layout.size34import androidx.compose.foundation.layout.width35import androidx.compose.foundation.shape.RoundedCornerShape36import androidx.compose.foundation.text.BasicTextField37import androidx.compose.material.icons.Icons38import androidx.compose.material.icons.rounded.Check39import androidx.compose.material.icons.outlined.Visibility40import androidx.compose.material.icons.outlined.VisibilityOff41import androidx.compose.material.icons.rounded.Close42import androidx.compose.material3.DropdownMenu43import androidx.compose.material3.DropdownMenuItem44import androidx.compose.material3.Icon45import androidx.compose.material3.Text46import androidx.compose.runtime.Composable47import androidx.compose.runtime.getValue48import androidx.compose.runtime.mutableStateOf49import androidx.compose.runtime.remember50import androidx.compose.runtime.setValue51import androidx.compose.ui.Alignment52import androidx.compose.ui.Modifier53import androidx.compose.ui.draw.clip54import androidx.compose.ui.draw.drawBehind55import androidx.compose.ui.geometry.Offset56import androidx.compose.ui.graphics.SolidColor57import androidx.compose.ui.platform.LocalContext58import androidx.compose.ui.text.input.PasswordVisualTransformation59import androidx.compose.ui.text.input.VisualTransformation60import androidx.compose.ui.unit.dp61import androidx.compose.ui.unit.em62import androidx.fragment.app.FragmentActivity63import com.peachpasswords.ui.components.CopyButton64import com.peachpasswords.ui.lock.Biometrics65import com.peachpasswords.ui.theme.LocalPeachAccent66import com.peachpasswords.ui.theme.peachColors67import com.peachpasswords.ui.theme.PeachType6869/**70 * A field row that toggles between read-only display and inline edit.71 *72 * View mode: value text + copy button (unchanged look from the existing FieldRow).73 * Edit mode: an underlined BasicTextField with ✓ save / ✗ cancel beneath it, and74 * — when [suggestions] is non-empty — a DropdownMenu of prior values to tap-fill.75 *76 * @param field   logical field key passed to [onSave] (e.g. "username", "email", "notes").77 * @param onSave  called with the trimmed draft when the user taps ✓; the caller78 *                (EntryDetailViewModel.updateField) persists it.79 * @param suggestions identity-suggest values for USERNAME/EMAIL; empty disables the dropdown.80 * @param mono    mono body style for the value (false = serif body, used by notes).81 */82@Composable83fun EditableFieldRow(84    label: String,85    field: String,86    value: String,87    copied: Boolean,88    onCopy: () -> Unit,89    onSave: (String) -> Unit,90    suggestions: List<String> = emptyList(),91    mono: Boolean = true,92    singleLine: Boolean = true,93    placeholder: String = "",94) {95    var editing by remember(value) { mutableStateOf(false) }96    var draft by remember(value) { mutableStateOf(value) }9798    FieldShell(label) {99        if (!editing) {100            val isEmpty = value.isBlank()101            Text(102                if (isEmpty) placeholder else value,103                color = if (isEmpty) peachColors.T2 else peachColors.T0,104                style = if (mono) PeachType.bodyMono else PeachType.body,105                modifier = Modifier106                    .weight(1f)107                    .clickable { draft = value; editing = true },108            )109            if (!isEmpty) CopyButton(copied, onCopy)110        } else {111            InlineEditor(112                draft = draft,113                onDraftChange = { draft = it },114                onSave = { onSave(draft.trim()); editing = false },115                onCancel = { draft = value; editing = false },116                suggestions = suggestions,117                onPickSuggestion = { draft = it },118                singleLine = singleLine,119                modifier = Modifier.weight(1f),120            )121        }122    }123}124125/**126 * Password variant of [EditableFieldRow]. Entering edit mode is gated behind a127 * biometric (or device-credential) prompt, mirroring the existing reveal flow in128 * PasswordRow. The draft starts masked; a show/hide toggle controls visibility.129 *130 * The gate runs only on the view→edit transition. Once authenticated the field is131 * editable with the cleartext draft available.132 */133@Composable134fun EditablePasswordRow(135    value: String,136    copied: Boolean,137    entryTitle: String,138    onCopy: () -> Unit,139    onSave: (String) -> Unit,140    onGenerate: () -> Unit,141) {142    val accent = LocalPeachAccent.current143    val context = LocalContext.current144    var editing by remember(value) { mutableStateOf(false) }145    var draft by remember(value) { mutableStateOf(value) }146    var show by remember { mutableStateOf(false) }147    var reveal by remember(value) { mutableStateOf(false) }148149    fun beginEdit() {150        val activity = context as? FragmentActivity151        if (activity != null && Biometrics.isAvailable(context)) {152            Biometrics.authenticate(153                activity,154                title = "Unlock Peach",155                subtitle = entryTitle,156                onSuccess = { draft = value; show = false; editing = true },157            )158        } else {159            // No biometrics: fall through (same fallback the reveal flow uses).160            draft = value; show = false; editing = true161        }162    }163164    FieldShell("password") {165        if (!editing) {166            // Revealing is not the same act as editing, so it does not go167            // through the biometric edit gate — the vault is already unlocked168            // and the value is one tap from the clipboard either way.169            Text(170                text = if (reveal) value else "•".repeat(value.length.coerceIn(8, 16)),171                color = peachColors.T0,172                style = PeachType.bodyMono,173                modifier = Modifier.weight(1f).clickable { beginEdit() },174            )175            Text(176                "edit", color = accent, style = PeachType.labelMono,177                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { beginEdit() }.padding(8.dp),178            )179            RevealButton(revealed = reveal, onClick = { reveal = !reveal })180            CopyButton(copied, onCopy)181        } else {182            Column(Modifier.weight(1f)) {183                Row(verticalAlignment = Alignment.CenterVertically) {184                    UnderlinedField(185                        draft = draft,186                        onDraftChange = { draft = it },187                        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),188                        singleLine = true,189                        modifier = Modifier.weight(1f),190                    )191                    Text(192                        if (show) "hide" else "show", color = accent, style = PeachType.labelMono,193                        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),194                    )195                    // Generate belongs to the moment you are choosing a196                    // password, not to the resting row — a wrench parked next197                    // to a saved password invites overwriting it by accident.198                    WrenchButton(onClick = onGenerate)199                }200                Spacer(Modifier.height(8.dp))201                SaveCancelRow(202                    onSave = { onSave(draft); editing = false },203                    onCancel = { draft = value; editing = false },204                )205            }206        }207    }208}209210/** Eye toggle for the resting password row. */211@Composable212private fun RevealButton(revealed: Boolean, onClick: () -> Unit) {213    Box(214        Modifier215            .size(36.dp)216            .clip(RoundedCornerShape(6.dp))217            .background(peachColors.S2)218            .clickable(onClick = onClick),219        contentAlignment = Alignment.Center,220    ) {221        Icon(222            if (revealed) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,223            contentDescription = if (revealed) "hide password" else "reveal password",224            tint = peachColors.T2,225            modifier = Modifier.size(15.dp),226        )227    }228}229230/** Underlined editor + suggestion dropdown + save/cancel — the shared edit body. */231@Composable232private fun InlineEditor(233    draft: String,234    onDraftChange: (String) -> Unit,235    onSave: () -> Unit,236    onCancel: () -> Unit,237    suggestions: List<String>,238    onPickSuggestion: (String) -> Unit,239    singleLine: Boolean,240    modifier: Modifier = Modifier,241) {242    var dropdownOpen by remember { mutableStateOf(suggestions.isNotEmpty()) }243244    Column(modifier) {245        Box {246            UnderlinedField(247                draft = draft,248                onDraftChange = onDraftChange,249                singleLine = singleLine,250                modifier = Modifier.fillMaxWidth(),251            )252            if (suggestions.isNotEmpty()) {253                DropdownMenu(254                    expanded = dropdownOpen,255                    onDismissRequest = { dropdownOpen = false },256                ) {257                    suggestions.take(4).forEach { suggestion ->258                        DropdownMenuItem(259                            text = {260                                Text(261                                    suggestion,262                                    color = peachColors.T1,263                                    style = PeachType.labelMono.copy(letterSpacing = 0.06.em),264                                )265                            },266                            onClick = { onPickSuggestion(suggestion); dropdownOpen = false },267                        )268                    }269                }270            }271        }272        Spacer(Modifier.height(8.dp))273        SaveCancelRow(onSave = onSave, onCancel = onCancel)274    }275}276277/** A BasicTextField with an accent underline drawn beneath it (spec §5 inline edit). */278@Composable279private fun UnderlinedField(280    draft: String,281    onDraftChange: (String) -> Unit,282    transformation: VisualTransformation = VisualTransformation.None,283    singleLine: Boolean = true,284    modifier: Modifier = Modifier,285) {286    val accent = LocalPeachAccent.current287    BasicTextField(288        value = draft,289        onValueChange = onDraftChange,290        singleLine = singleLine,291        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),292        cursorBrush = SolidColor(accent),293        visualTransformation = transformation,294        modifier = modifier295            .padding(bottom = 4.dp)296            .drawBehind {297                drawLine(298                    color = accent,299                    start = Offset(0f, size.height),300                    end = Offset(size.width, size.height),301                    strokeWidth = 1.dp.toPx(),302                )303            },304    )305}306307@Composable308private fun SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {309    val accent = LocalPeachAccent.current310    // Matches CopyButton's icon-button convention (36dp, 6dp rounded, filled311    // background) — these used to be bare "✓ save" / "✗" text at 10sp with a312    // few dp of padding, well under the 48dp touch-target minimum and visibly313    // inconsistent with every other action button on this screen.314    Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {315        Box(316            Modifier317                .size(36.dp)318                .clip(RoundedCornerShape(6.dp))319                .background(peachColors.S2)320                .clickable(onClick = onSave),321            contentAlignment = Alignment.Center,322        ) {323            Icon(Icons.Rounded.Check, contentDescription = "save", tint = accent, modifier = Modifier.size(16.dp))324        }325        Box(326            Modifier327                .size(36.dp)328                .clip(RoundedCornerShape(6.dp))329                .background(peachColors.S2)330                .clickable(onClick = onCancel),331            contentAlignment = Alignment.Center,332        ) {333            Icon(Icons.Rounded.Close, contentDescription = "cancel", tint = peachColors.T2, modifier = Modifier.size(16.dp))334        }335    }336}