EditableFieldRow.kt

11.9 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.rounded.Close40import androidx.compose.material3.DropdownMenu41import androidx.compose.material3.DropdownMenuItem42import androidx.compose.material3.Icon43import androidx.compose.material3.Text44import androidx.compose.runtime.Composable45import androidx.compose.runtime.getValue46import androidx.compose.runtime.mutableStateOf47import androidx.compose.runtime.remember48import androidx.compose.runtime.setValue49import androidx.compose.ui.Alignment50import androidx.compose.ui.Modifier51import androidx.compose.ui.draw.clip52import androidx.compose.ui.draw.drawBehind53import androidx.compose.ui.geometry.Offset54import androidx.compose.ui.graphics.SolidColor55import androidx.compose.ui.platform.LocalContext56import androidx.compose.ui.text.input.PasswordVisualTransformation57import androidx.compose.ui.text.input.VisualTransformation58import androidx.compose.ui.unit.dp59import androidx.compose.ui.unit.em60import androidx.fragment.app.FragmentActivity61import com.peachpasswords.ui.components.CopyButton62import com.peachpasswords.ui.lock.Biometrics63import com.peachpasswords.ui.theme.LocalPeachAccent64import com.peachpasswords.ui.theme.peachColors65import com.peachpasswords.ui.theme.PeachType6667/**68 * A field row that toggles between read-only display and inline edit.69 *70 * View mode: value text + copy button (unchanged look from the existing FieldRow).71 * Edit mode: an underlined BasicTextField with ✓ save / ✗ cancel beneath it, and72 * — when [suggestions] is non-empty — a DropdownMenu of prior values to tap-fill.73 *74 * @param field   logical field key passed to [onSave] (e.g. "username", "email", "notes").75 * @param onSave  called with the trimmed draft when the user taps ✓; the caller76 *                (EntryDetailViewModel.updateField) persists it.77 * @param suggestions identity-suggest values for USERNAME/EMAIL; empty disables the dropdown.78 * @param mono    mono body style for the value (false = serif body, used by notes).79 */80@Composable81fun EditableFieldRow(82    label: String,83    field: String,84    value: String,85    copied: Boolean,86    onCopy: () -> Unit,87    onSave: (String) -> Unit,88    suggestions: List<String> = emptyList(),89    mono: Boolean = true,90    singleLine: Boolean = true,91    placeholder: String = "",92) {93    var editing by remember(value) { mutableStateOf(false) }94    var draft by remember(value) { mutableStateOf(value) }9596    FieldShell(label) {97        if (!editing) {98            val isEmpty = value.isBlank()99            Text(100                if (isEmpty) placeholder else value,101                color = if (isEmpty) peachColors.T2 else peachColors.T0,102                style = if (mono) PeachType.bodyMono else PeachType.body,103                modifier = Modifier104                    .weight(1f)105                    .clickable { draft = value; editing = true },106            )107            if (!isEmpty) CopyButton(copied, onCopy)108        } else {109            InlineEditor(110                draft = draft,111                onDraftChange = { draft = it },112                onSave = { onSave(draft.trim()); editing = false },113                onCancel = { draft = value; editing = false },114                suggestions = suggestions,115                onPickSuggestion = { draft = it },116                singleLine = singleLine,117                modifier = Modifier.weight(1f),118            )119        }120    }121}122123/**124 * Password variant of [EditableFieldRow]. Entering edit mode is gated behind a125 * biometric (or device-credential) prompt, mirroring the existing reveal flow in126 * PasswordRow. The draft starts masked; a show/hide toggle controls visibility.127 *128 * The gate runs only on the view→edit transition. Once authenticated the field is129 * editable with the cleartext draft available.130 */131@Composable132fun EditablePasswordRow(133    value: String,134    copied: Boolean,135    entryTitle: String,136    onCopy: () -> Unit,137    onSave: (String) -> Unit,138    onGenerate: () -> Unit,139) {140    val accent = LocalPeachAccent.current141    val context = LocalContext.current142    var editing by remember(value) { mutableStateOf(false) }143    var draft by remember(value) { mutableStateOf(value) }144    var show by remember { mutableStateOf(false) }145146    fun beginEdit() {147        val activity = context as? FragmentActivity148        if (activity != null && Biometrics.isAvailable(context)) {149            Biometrics.authenticate(150                activity,151                title = "Unlock Peach",152                subtitle = entryTitle,153                onSuccess = { draft = value; show = false; editing = true },154            )155        } else {156            // No biometrics: fall through (same fallback the reveal flow uses).157            draft = value; show = false; editing = true158        }159    }160161    FieldShell("password") {162        if (!editing) {163            Text(164                text = "•".repeat(value.length.coerceIn(8, 16)),165                color = peachColors.T0,166                style = PeachType.bodyMono,167                modifier = Modifier.weight(1f).clickable { beginEdit() },168            )169            Text(170                "edit", color = accent, style = PeachType.labelMono,171                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { beginEdit() }.padding(8.dp),172            )173            CopyButton(copied, onCopy)174            WrenchButton(onClick = onGenerate)175        } else {176            Column(Modifier.weight(1f)) {177                Row(verticalAlignment = Alignment.CenterVertically) {178                    UnderlinedField(179                        draft = draft,180                        onDraftChange = { draft = it },181                        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),182                        singleLine = true,183                        modifier = Modifier.weight(1f),184                    )185                    Text(186                        if (show) "hide" else "show", color = accent, style = PeachType.labelMono,187                        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),188                    )189                }190                Spacer(Modifier.height(8.dp))191                SaveCancelRow(192                    onSave = { onSave(draft); editing = false },193                    onCancel = { draft = value; editing = false },194                )195            }196        }197    }198}199200/** Underlined editor + suggestion dropdown + save/cancel — the shared edit body. */201@Composable202private fun InlineEditor(203    draft: String,204    onDraftChange: (String) -> Unit,205    onSave: () -> Unit,206    onCancel: () -> Unit,207    suggestions: List<String>,208    onPickSuggestion: (String) -> Unit,209    singleLine: Boolean,210    modifier: Modifier = Modifier,211) {212    var dropdownOpen by remember { mutableStateOf(suggestions.isNotEmpty()) }213214    Column(modifier) {215        Box {216            UnderlinedField(217                draft = draft,218                onDraftChange = onDraftChange,219                singleLine = singleLine,220                modifier = Modifier.fillMaxWidth(),221            )222            if (suggestions.isNotEmpty()) {223                DropdownMenu(224                    expanded = dropdownOpen,225                    onDismissRequest = { dropdownOpen = false },226                ) {227                    suggestions.take(4).forEach { suggestion ->228                        DropdownMenuItem(229                            text = {230                                Text(231                                    suggestion,232                                    color = peachColors.T1,233                                    style = PeachType.labelMono.copy(letterSpacing = 0.06.em),234                                )235                            },236                            onClick = { onPickSuggestion(suggestion); dropdownOpen = false },237                        )238                    }239                }240            }241        }242        Spacer(Modifier.height(8.dp))243        SaveCancelRow(onSave = onSave, onCancel = onCancel)244    }245}246247/** A BasicTextField with an accent underline drawn beneath it (spec §5 inline edit). */248@Composable249private fun UnderlinedField(250    draft: String,251    onDraftChange: (String) -> Unit,252    transformation: VisualTransformation = VisualTransformation.None,253    singleLine: Boolean = true,254    modifier: Modifier = Modifier,255) {256    val accent = LocalPeachAccent.current257    BasicTextField(258        value = draft,259        onValueChange = onDraftChange,260        singleLine = singleLine,261        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),262        cursorBrush = SolidColor(accent),263        visualTransformation = transformation,264        modifier = modifier265            .padding(bottom = 4.dp)266            .drawBehind {267                drawLine(268                    color = accent,269                    start = Offset(0f, size.height),270                    end = Offset(size.width, size.height),271                    strokeWidth = 1.dp.toPx(),272                )273            },274    )275}276277@Composable278private fun SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {279    val accent = LocalPeachAccent.current280    // Matches CopyButton's icon-button convention (36dp, 6dp rounded, filled281    // background) — these used to be bare "✓ save" / "✗" text at 10sp with a282    // few dp of padding, well under the 48dp touch-target minimum and visibly283    // inconsistent with every other action button on this screen.284    Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {285        Box(286            Modifier287                .size(36.dp)288                .clip(RoundedCornerShape(6.dp))289                .background(peachColors.S2)290                .clickable(onClick = onSave),291            contentAlignment = Alignment.Center,292        ) {293            Icon(Icons.Rounded.Check, contentDescription = "save", tint = accent, modifier = Modifier.size(16.dp))294        }295        Box(296            Modifier297                .size(36.dp)298                .clip(RoundedCornerShape(6.dp))299                .background(peachColors.S2)300                .clickable(onClick = onCancel),301            contentAlignment = Alignment.Center,302        ) {303            Icon(Icons.Rounded.Close, contentDescription = "cancel", tint = peachColors.T2, modifier = Modifier.size(16.dp))304        }305    }306}