EditableFieldRow.kt

13.8 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.AnnotatedString59import androidx.compose.ui.text.input.PasswordVisualTransformation60import androidx.compose.ui.text.input.VisualTransformation61import androidx.compose.ui.unit.dp62import androidx.compose.ui.unit.em63import 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 — a DropdownMenu of prior values to tap-fill.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 disables the dropdown.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<String> = emptyList(),96    mono: Boolean = true,97    singleLine: Boolean = true,98    placeholder: String = "",99    displayText: AnnotatedString? = null,100    draftText: String? = null,101) {102    val editable = draftText ?: value103    var editing by remember(value) { mutableStateOf(false) }104    var draft by remember(value) { mutableStateOf(editable) }105106    FieldShell(label) {107        if (!editing) {108            val isEmpty = value.isBlank()109            Text(110                if (isEmpty) AnnotatedString(placeholder) else displayText ?: AnnotatedString(value),111                color = if (isEmpty) peachColors.T2 else peachColors.T0,112                style = if (mono) PeachType.bodyMono else PeachType.body,113                modifier = Modifier114                    .weight(1f)115                    .clickable { draft = editable; editing = true },116            )117            if (!isEmpty) CopyButton(copied, onCopy)118        } else {119            InlineEditor(120                draft = draft,121                onDraftChange = { draft = it },122                onSave = { onSave(draft.trim()); editing = false },123                onCancel = { draft = editable; editing = false },124                suggestions = suggestions,125                onPickSuggestion = { draft = it },126                singleLine = singleLine,127                modifier = Modifier.weight(1f),128            )129        }130    }131}132133/**134 * Password variant of [EditableFieldRow]. Entering edit mode is gated behind a135 * biometric (or device-credential) prompt, mirroring the existing reveal flow in136 * PasswordRow. The draft starts masked; a show/hide toggle controls visibility.137 *138 * The gate runs only on the view→edit transition. Once authenticated the field is139 * editable with the cleartext draft available.140 */141@Composable142fun EditablePasswordRow(143    value: String,144    copied: Boolean,145    entryTitle: String,146    onCopy: () -> Unit,147    onSave: (String) -> Unit,148    onGenerate: () -> Unit,149) {150    val accent = LocalPeachAccent.current151    val context = LocalContext.current152    var editing by remember(value) { mutableStateOf(false) }153    var draft by remember(value) { mutableStateOf(value) }154    var show by remember { mutableStateOf(false) }155    var reveal by remember(value) { mutableStateOf(false) }156157    fun beginEdit() {158        val activity = context as? FragmentActivity159        if (activity != null && Biometrics.isAvailable(context)) {160            Biometrics.authenticate(161                activity,162                title = "Unlock Peach",163                subtitle = entryTitle,164                onSuccess = { draft = value; show = false; editing = true },165            )166        } else {167            // No biometrics: fall through (same fallback the reveal flow uses).168            draft = value; show = false; editing = true169        }170    }171172    FieldShell("password") {173        if (!editing) {174            // Revealing is not the same act as editing, so it does not go175            // through the biometric edit gate — the vault is already unlocked176            // and the value is one tap from the clipboard either way.177            Text(178                text = if (reveal) value else "•".repeat(value.length.coerceIn(8, 16)),179                color = peachColors.T0,180                style = PeachType.bodyMono,181                modifier = Modifier.weight(1f).clickable { beginEdit() },182            )183            Text(184                "edit", color = accent, style = PeachType.labelMono,185                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { beginEdit() }.padding(8.dp),186            )187            RevealButton(revealed = reveal, onClick = { reveal = !reveal })188            CopyButton(copied, onCopy)189        } else {190            Column(Modifier.weight(1f)) {191                Row(verticalAlignment = Alignment.CenterVertically) {192                    UnderlinedField(193                        draft = draft,194                        onDraftChange = { draft = it },195                        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),196                        singleLine = true,197                        modifier = Modifier.weight(1f),198                    )199                    Text(200                        if (show) "hide" else "show", color = accent, style = PeachType.labelMono,201                        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),202                    )203                    // Generate belongs to the moment you are choosing a204                    // password, not to the resting row — a wrench parked next205                    // to a saved password invites overwriting it by accident.206                    WrenchButton(onClick = onGenerate)207                }208                Spacer(Modifier.height(8.dp))209                SaveCancelRow(210                    onSave = { onSave(draft); editing = false },211                    onCancel = { draft = value; editing = false },212                )213            }214        }215    }216}217218/** Eye toggle for the resting password row. */219@Composable220private fun RevealButton(revealed: Boolean, onClick: () -> Unit) {221    Box(222        Modifier223            .size(36.dp)224            .clip(RoundedCornerShape(6.dp))225            .background(peachColors.S2)226            .clickable(onClick = onClick),227        contentAlignment = Alignment.Center,228    ) {229        Icon(230            if (revealed) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,231            contentDescription = if (revealed) "hide password" else "reveal password",232            tint = peachColors.T2,233            modifier = Modifier.size(15.dp),234        )235    }236}237238/** Underlined editor + suggestion dropdown + save/cancel — the shared edit body. */239@Composable240private fun InlineEditor(241    draft: String,242    onDraftChange: (String) -> Unit,243    onSave: () -> Unit,244    onCancel: () -> Unit,245    suggestions: List<String>,246    onPickSuggestion: (String) -> Unit,247    singleLine: Boolean,248    modifier: Modifier = Modifier,249) {250    var dropdownOpen by remember { mutableStateOf(suggestions.isNotEmpty()) }251252    Column(modifier) {253        Box {254            UnderlinedField(255                draft = draft,256                onDraftChange = onDraftChange,257                singleLine = singleLine,258                modifier = Modifier.fillMaxWidth(),259            )260            if (suggestions.isNotEmpty()) {261                DropdownMenu(262                    expanded = dropdownOpen,263                    onDismissRequest = { dropdownOpen = false },264                ) {265                    suggestions.take(4).forEach { suggestion ->266                        DropdownMenuItem(267                            text = {268                                Text(269                                    suggestion,270                                    color = peachColors.T1,271                                    style = PeachType.labelMono.copy(letterSpacing = 0.06.em),272                                )273                            },274                            onClick = { onPickSuggestion(suggestion); dropdownOpen = false },275                        )276                    }277                }278            }279        }280        Spacer(Modifier.height(8.dp))281        SaveCancelRow(onSave = onSave, onCancel = onCancel)282    }283}284285/** A BasicTextField with an accent underline drawn beneath it (spec §5 inline edit). */286@Composable287private fun UnderlinedField(288    draft: String,289    onDraftChange: (String) -> Unit,290    transformation: VisualTransformation = VisualTransformation.None,291    singleLine: Boolean = true,292    modifier: Modifier = Modifier,293) {294    val accent = LocalPeachAccent.current295    BasicTextField(296        value = draft,297        onValueChange = onDraftChange,298        singleLine = singleLine,299        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),300        cursorBrush = SolidColor(accent),301        visualTransformation = transformation,302        modifier = modifier303            .padding(bottom = 4.dp)304            .drawBehind {305                drawLine(306                    color = accent,307                    start = Offset(0f, size.height),308                    end = Offset(size.width, size.height),309                    strokeWidth = 1.dp.toPx(),310                )311            },312    )313}314315@Composable316private fun SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {317    val accent = LocalPeachAccent.current318    // Matches CopyButton's icon-button convention (36dp, 6dp rounded, filled319    // background) — these used to be bare "✓ save" / "✗" text at 10sp with a320    // few dp of padding, well under the 48dp touch-target minimum and visibly321    // inconsistent with every other action button on this screen.322    Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {323        Box(324            Modifier325                .size(36.dp)326                .clip(RoundedCornerShape(6.dp))327                .background(peachColors.S2)328                .clickable(onClick = onSave),329            contentAlignment = Alignment.Center,330        ) {331            Icon(Icons.Rounded.Check, contentDescription = "save", tint = accent, modifier = Modifier.size(16.dp))332        }333        Box(334            Modifier335                .size(36.dp)336                .clip(RoundedCornerShape(6.dp))337                .background(peachColors.S2)338                .clickable(onClick = onCancel),339            contentAlignment = Alignment.Center,340        ) {341            Icon(Icons.Rounded.Close, contentDescription = "cancel", tint = peachColors.T2, modifier = Modifier.size(16.dp))342        }343    }344}