EditableFieldRow.kt

14.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 inline identity list.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.heightIn33import androidx.compose.foundation.layout.padding34import androidx.compose.foundation.layout.size35import androidx.compose.foundation.layout.width36import androidx.compose.foundation.lazy.LazyColumn37import androidx.compose.foundation.lazy.items38import androidx.compose.foundation.shape.RoundedCornerShape39import androidx.compose.foundation.text.BasicTextField40import androidx.compose.material.icons.Icons41import androidx.compose.material.icons.rounded.Check42import androidx.compose.material.icons.outlined.Visibility43import androidx.compose.material.icons.outlined.VisibilityOff44import androidx.compose.material.icons.rounded.Close45import androidx.compose.material3.Icon46import androidx.compose.material3.Text47import androidx.compose.runtime.Composable48import androidx.compose.runtime.getValue49import androidx.compose.runtime.mutableStateOf50import androidx.compose.runtime.remember51import androidx.compose.runtime.setValue52import androidx.compose.ui.Alignment53import androidx.compose.ui.Modifier54import androidx.compose.ui.draw.clip55import androidx.compose.ui.draw.drawBehind56import androidx.compose.ui.geometry.Offset57import androidx.compose.ui.graphics.SolidColor58import androidx.compose.ui.platform.LocalContext59import androidx.compose.ui.text.AnnotatedString60import androidx.compose.ui.text.input.PasswordVisualTransformation61import androidx.compose.ui.text.input.VisualTransformation62import androidx.compose.ui.unit.dp63import androidx.fragment.app.FragmentActivity64import com.peachpasswords.ui.components.CopyButton65import com.peachpasswords.ui.lock.Biometrics66import com.peachpasswords.ui.theme.LocalPeachAccent67import com.peachpasswords.ui.theme.peachColors68import com.peachpasswords.ui.theme.PeachType6970/**71 * A field row that toggles between read-only display and inline edit.72 *73 * View mode: value text + copy button (unchanged look from the existing FieldRow).74 * Edit mode: an underlined BasicTextField with ✓ save / ✗ cancel beneath it, and75 * — when [suggestions] is non-empty — an inline, scrollable list of prior values.76 *77 * @param field   logical field key passed to [onSave] (e.g. "username", "email", "notes").78 * @param onSave  called with the trimmed draft when the user taps ✓; the caller79 *                (EntryDetailViewModel.updateField) persists it.80 * @param suggestions identity-suggest values for USERNAME/EMAIL; empty hides the list.81 * @param mono    mono body style for the value (false = serif body, used by notes).82 * @param displayText rendered form of [value] for view mode; null shows [value] itself.83 *                Notes pass their parsed HTML here (see NoteHtml.kt).84 * @param draftText what edit mode starts from; null edits [value] itself. Notes85 *                edit the markup form and [onSave] converts it back.86 */87@Composable88fun EditableFieldRow(89    label: String,90    field: String,91    value: String,92    copied: Boolean,93    onCopy: () -> Unit,94    onSave: (String) -> Unit,95    suggestions: List<IdentitySuggestion> = emptyList(),96    mono: Boolean = true,97    singleLine: Boolean = true,98    placeholder: String = "",99    displayText: AnnotatedString? = null,100    draftText: String? = null,101    onEditingChange: (Boolean) -> Unit = {},102    stacked: Boolean = false,103    divider: Boolean = false,104) {105    val editable = draftText ?: value106    var editing by remember(value) { mutableStateOf(false) }107    var draft by remember(value) { mutableStateOf(editable) }108109    FieldShell(label, stacked = stacked, divider = divider) {110        if (!editing) {111            val isEmpty = value.isBlank()112            Text(113                if (isEmpty) AnnotatedString(placeholder) else displayText ?: AnnotatedString(value),114                color = if (isEmpty) peachColors.T2 else peachColors.T0,115                style = if (mono) PeachType.bodyMono else PeachType.body,116                modifier = Modifier117                    .weight(1f)118                    .clickable { draft = editable; editing = true; onEditingChange(true) },119            )120            if (!isEmpty) CopyButton(copied, onCopy)121        } else {122            InlineEditor(123                draft = draft,124                onDraftChange = { draft = it },125                onSave = { onSave(draft.trim()); editing = false; onEditingChange(false) },126                onCancel = { draft = editable; editing = false; onEditingChange(false) },127                suggestions = suggestions,128                onPickSuggestion = { draft = it },129                singleLine = singleLine,130                modifier = Modifier.weight(1f),131            )132        }133    }134}135136/**137 * Password variant of [EditableFieldRow]. Entering edit mode is gated behind a138 * biometric (or device-credential) prompt, mirroring the existing reveal flow in139 * PasswordRow. The draft starts masked; a show/hide toggle controls visibility.140 *141 * The gate runs only on the view→edit transition. Once authenticated the field is142 * editable with the cleartext draft available.143 */144@Composable145fun EditablePasswordRow(146    value: String,147    copied: Boolean,148    entryTitle: String,149    onCopy: () -> Unit,150    onSave: (String) -> Unit,151    onGenerate: () -> Unit,152    stacked: Boolean = false,153    divider: Boolean = false,154) {155    val accent = LocalPeachAccent.current156    val context = LocalContext.current157    var editing by remember(value) { mutableStateOf(false) }158    var draft by remember(value) { mutableStateOf(value) }159    var show by remember { mutableStateOf(false) }160    var reveal by remember(value) { mutableStateOf(false) }161162    fun beginEdit() {163        val activity = context as? FragmentActivity164        if (activity != null && Biometrics.isAvailable(context)) {165            Biometrics.authenticate(166                activity,167                title = "Unlock Peach",168                subtitle = entryTitle,169                onSuccess = { draft = value; show = false; editing = true },170            )171        } else {172            // No biometrics: fall through (same fallback the reveal flow uses).173            draft = value; show = false; editing = true174        }175    }176177    FieldShell("password", stacked = stacked, divider = divider) {178        if (!editing) {179            // Revealing is not the same act as editing, so it does not go180            // through the biometric edit gate — the vault is already unlocked181            // and the value is one tap from the clipboard either way.182            Text(183                text = if (reveal) value else "•".repeat(value.length.coerceIn(8, 16)),184                color = peachColors.T0,185                style = PeachType.bodyMono,186                modifier = Modifier.weight(1f).clickable { beginEdit() },187            )188            RevealButton(revealed = reveal, onClick = { reveal = !reveal })189            Spacer(Modifier.width(6.dp))190            CopyButton(copied, onCopy)191        } else {192            Column(Modifier.weight(1f)) {193                Row(verticalAlignment = Alignment.CenterVertically) {194                    UnderlinedField(195                        draft = draft,196                        onDraftChange = { draft = it },197                        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),198                        singleLine = true,199                        modifier = Modifier.weight(1f),200                    )201                    Text(202                        if (show) "hide" else "show", color = accent, style = PeachType.labelMono,203                        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),204                    )205                    Spacer(Modifier.width(6.dp))206                    // Generate belongs to the moment you are choosing a207                    // password, not to the resting row — a wrench parked next208                    // to a saved password invites overwriting it by accident.209                    WrenchButton(onClick = onGenerate)210                }211                Spacer(Modifier.height(8.dp))212                SaveCancelRow(213                    onSave = { onSave(draft); editing = false },214                    onCancel = { draft = value; editing = false },215                )216            }217        }218    }219}220221/** Eye toggle for the resting password row. */222@Composable223private fun RevealButton(revealed: Boolean, onClick: () -> Unit) {224    Box(225        Modifier226            .size(36.dp)227            .clip(RoundedCornerShape(6.dp))228            .background(peachColors.S2)229            .clickable(onClick = onClick),230        contentAlignment = Alignment.Center,231    ) {232        Icon(233            if (revealed) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,234            contentDescription = if (revealed) "hide password" else "reveal password",235            tint = peachColors.T2,236            modifier = Modifier.size(15.dp),237        )238    }239}240241/** Underlined editor + inline scrollable suggestions + save/cancel. */242@Composable243private fun InlineEditor(244    draft: String,245    onDraftChange: (String) -> Unit,246    onSave: () -> Unit,247    onCancel: () -> Unit,248    suggestions: List<IdentitySuggestion>,249    onPickSuggestion: (String) -> Unit,250    singleLine: Boolean,251    modifier: Modifier = Modifier,252) {253    Column(modifier) {254        UnderlinedField(255            draft = draft,256            onDraftChange = onDraftChange,257            singleLine = singleLine,258            modifier = Modifier.fillMaxWidth(),259        )260        if (suggestions.isNotEmpty()) {261            Spacer(Modifier.height(6.dp))262            LazyColumn(263                modifier = Modifier264                    .fillMaxWidth()265                    .heightIn(max = 236.dp)266                    .clip(RoundedCornerShape(8.dp))267                    .background(peachColors.SurfaceCard),268            ) {269                items(suggestions, key = { it.value }) { suggestion ->270                    Row(271                        Modifier272                            .fillMaxWidth()273                            .clickable { onPickSuggestion(suggestion.value) }274                            .padding(horizontal = 12.dp, vertical = 9.dp),275                        verticalAlignment = Alignment.CenterVertically,276                    ) {277                        Text(278                            suggestion.value,279                            color = peachColors.T1,280                            style = PeachType.bodyMono,281                            modifier = Modifier.weight(1f),282                        )283                        Text(284                            "${suggestion.count}×",285                            color = peachColors.T2,286                            style = PeachType.labelMono,287                        )288                    }289                }290            }291        }292        Spacer(Modifier.height(8.dp))293        SaveCancelRow(onSave = onSave, onCancel = onCancel)294    }295}296297/** A BasicTextField with an accent underline drawn beneath it (spec §5 inline edit). */298@Composable299private fun UnderlinedField(300    draft: String,301    onDraftChange: (String) -> Unit,302    transformation: VisualTransformation = VisualTransformation.None,303    singleLine: Boolean = true,304    modifier: Modifier = Modifier,305) {306    val accent = LocalPeachAccent.current307    BasicTextField(308        value = draft,309        onValueChange = onDraftChange,310        singleLine = singleLine,311        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),312        cursorBrush = SolidColor(accent),313        visualTransformation = transformation,314        modifier = modifier315            .padding(bottom = 4.dp)316            .drawBehind {317                drawLine(318                    color = accent,319                    start = Offset(0f, size.height),320                    end = Offset(size.width, size.height),321                    strokeWidth = 1.dp.toPx(),322                )323            },324    )325}326327@Composable328private fun SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {329    val accent = LocalPeachAccent.current330    // Matches CopyButton's icon-button convention (36dp, 6dp rounded, filled331    // background) — these used to be bare "✓ save" / "✗" text at 10sp with a332    // few dp of padding, well under the 48dp touch-target minimum and visibly333    // inconsistent with every other action button on this screen.334    Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {335        Box(336            Modifier337                .size(36.dp)338                .clip(RoundedCornerShape(6.dp))339                .background(peachColors.S2)340                .clickable(onClick = onSave),341            contentAlignment = Alignment.Center,342        ) {343            Icon(Icons.Rounded.Check, contentDescription = "save", tint = accent, modifier = Modifier.size(16.dp))344        }345        Box(346            Modifier347                .size(36.dp)348                .clip(RoundedCornerShape(6.dp))349                .background(peachColors.S2)350                .clickable(onClick = onCancel),351            contentAlignment = Alignment.Center,352        ) {353            Icon(Icons.Rounded.Close, contentDescription = "cancel", tint = peachColors.T2, modifier = Modifier.size(16.dp))354        }355    }356}