EditableFieldRow.kt

11.0 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.clickable24import androidx.compose.foundation.layout.Arrangement25import androidx.compose.foundation.layout.Box26import androidx.compose.foundation.layout.Column27import androidx.compose.foundation.layout.Row28import androidx.compose.foundation.layout.Spacer29import androidx.compose.foundation.layout.fillMaxWidth30import androidx.compose.foundation.layout.height31import androidx.compose.foundation.layout.padding32import androidx.compose.foundation.layout.width33import androidx.compose.foundation.shape.RoundedCornerShape34import androidx.compose.foundation.text.BasicTextField35import androidx.compose.material3.DropdownMenu36import androidx.compose.material3.DropdownMenuItem37import androidx.compose.material3.Text38import androidx.compose.runtime.Composable39import androidx.compose.runtime.getValue40import androidx.compose.runtime.mutableStateOf41import androidx.compose.runtime.remember42import androidx.compose.runtime.setValue43import androidx.compose.ui.Alignment44import androidx.compose.ui.Modifier45import androidx.compose.ui.draw.clip46import androidx.compose.ui.draw.drawBehind47import androidx.compose.ui.geometry.Offset48import androidx.compose.ui.graphics.SolidColor49import androidx.compose.ui.platform.LocalContext50import androidx.compose.ui.text.input.PasswordVisualTransformation51import androidx.compose.ui.text.input.VisualTransformation52import androidx.compose.ui.unit.dp53import androidx.compose.ui.unit.em54import androidx.fragment.app.FragmentActivity55import com.peachpasswords.ui.components.CopyButton56import com.peachpasswords.ui.lock.Biometrics57import com.peachpasswords.ui.theme.LocalPeachAccent58import com.peachpasswords.ui.theme.peachColors59import com.peachpasswords.ui.theme.PeachType6061/**62 * A field row that toggles between read-only display and inline edit.63 *64 * View mode: value text + copy button (unchanged look from the existing FieldRow).65 * Edit mode: an underlined BasicTextField with ✓ save / ✗ cancel beneath it, and66 * — when [suggestions] is non-empty — a DropdownMenu of prior values to tap-fill.67 *68 * @param field   logical field key passed to [onSave] (e.g. "username", "email", "notes").69 * @param onSave  called with the trimmed draft when the user taps ✓; the caller70 *                (EntryDetailViewModel.updateField) persists it.71 * @param suggestions identity-suggest values for USERNAME/EMAIL; empty disables the dropdown.72 * @param mono    mono body style for the value (false = serif body, used by notes).73 */74@Composable75fun EditableFieldRow(76    label: String,77    field: String,78    value: String,79    copied: Boolean,80    onCopy: () -> Unit,81    onSave: (String) -> Unit,82    suggestions: List<String> = emptyList(),83    mono: Boolean = true,84    singleLine: Boolean = true,85    placeholder: String = "",86) {87    var editing by remember(value) { mutableStateOf(false) }88    var draft by remember(value) { mutableStateOf(value) }8990    FieldShell(label) {91        if (!editing) {92            val isEmpty = value.isBlank()93            Text(94                if (isEmpty) placeholder else value,95                color = if (isEmpty) peachColors.T2 else peachColors.T0,96                style = if (mono) PeachType.bodyMono else PeachType.body,97                modifier = Modifier98                    .weight(1f)99                    .clickable { draft = value; editing = true },100            )101            if (!isEmpty) CopyButton(copied, onCopy)102        } else {103            InlineEditor(104                draft = draft,105                onDraftChange = { draft = it },106                onSave = { onSave(draft.trim()); editing = false },107                onCancel = { draft = value; editing = false },108                suggestions = suggestions,109                onPickSuggestion = { draft = it },110                singleLine = singleLine,111                modifier = Modifier.weight(1f),112            )113        }114    }115}116117/**118 * Password variant of [EditableFieldRow]. Entering edit mode is gated behind a119 * biometric (or device-credential) prompt, mirroring the existing reveal flow in120 * PasswordRow. The draft starts masked; a show/hide toggle controls visibility.121 *122 * The gate runs only on the view→edit transition. Once authenticated the field is123 * editable with the cleartext draft available.124 */125@Composable126fun EditablePasswordRow(127    value: String,128    copied: Boolean,129    entryTitle: String,130    onCopy: () -> Unit,131    onSave: (String) -> Unit,132    onGenerate: () -> Unit,133) {134    val accent = LocalPeachAccent.current135    val context = LocalContext.current136    var editing by remember(value) { mutableStateOf(false) }137    var draft by remember(value) { mutableStateOf(value) }138    var show by remember { mutableStateOf(false) }139140    fun beginEdit() {141        val activity = context as? FragmentActivity142        if (activity != null && Biometrics.isAvailable(context)) {143            Biometrics.authenticate(144                activity,145                title = "Unlock Peach",146                subtitle = entryTitle,147                onSuccess = { draft = value; show = false; editing = true },148            )149        } else {150            // No biometrics: fall through (same fallback the reveal flow uses).151            draft = value; show = false; editing = true152        }153    }154155    FieldShell("password") {156        if (!editing) {157            Text(158                text = "•".repeat(value.length.coerceIn(8, 16)),159                color = peachColors.T0,160                style = PeachType.bodyMono,161                modifier = Modifier.weight(1f).clickable { beginEdit() },162            )163            Text(164                "edit", color = accent, style = PeachType.labelMono,165                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { beginEdit() }.padding(8.dp),166            )167            CopyButton(copied, onCopy)168            WrenchButton(onClick = onGenerate)169        } else {170            Column(Modifier.weight(1f)) {171                Row(verticalAlignment = Alignment.CenterVertically) {172                    UnderlinedField(173                        draft = draft,174                        onDraftChange = { draft = it },175                        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),176                        singleLine = true,177                        modifier = Modifier.weight(1f),178                    )179                    Text(180                        if (show) "hide" else "show", color = accent, style = PeachType.labelMono,181                        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),182                    )183                }184                Spacer(Modifier.height(8.dp))185                SaveCancelRow(186                    onSave = { onSave(draft); editing = false },187                    onCancel = { draft = value; editing = false },188                )189            }190        }191    }192}193194/** Underlined editor + suggestion dropdown + save/cancel — the shared edit body. */195@Composable196private fun InlineEditor(197    draft: String,198    onDraftChange: (String) -> Unit,199    onSave: () -> Unit,200    onCancel: () -> Unit,201    suggestions: List<String>,202    onPickSuggestion: (String) -> Unit,203    singleLine: Boolean,204    modifier: Modifier = Modifier,205) {206    var dropdownOpen by remember { mutableStateOf(suggestions.isNotEmpty()) }207208    Column(modifier) {209        Box {210            UnderlinedField(211                draft = draft,212                onDraftChange = onDraftChange,213                singleLine = singleLine,214                modifier = Modifier.fillMaxWidth(),215            )216            if (suggestions.isNotEmpty()) {217                DropdownMenu(218                    expanded = dropdownOpen,219                    onDismissRequest = { dropdownOpen = false },220                ) {221                    suggestions.take(4).forEach { suggestion ->222                        DropdownMenuItem(223                            text = {224                                Text(225                                    suggestion,226                                    color = peachColors.T1,227                                    style = PeachType.labelMono.copy(letterSpacing = 0.06.em),228                                )229                            },230                            onClick = { onPickSuggestion(suggestion); dropdownOpen = false },231                        )232                    }233                }234            }235        }236        Spacer(Modifier.height(8.dp))237        SaveCancelRow(onSave = onSave, onCancel = onCancel)238    }239}240241/** A BasicTextField with an accent underline drawn beneath it (spec §5 inline edit). */242@Composable243private fun UnderlinedField(244    draft: String,245    onDraftChange: (String) -> Unit,246    transformation: VisualTransformation = VisualTransformation.None,247    singleLine: Boolean = true,248    modifier: Modifier = Modifier,249) {250    val accent = LocalPeachAccent.current251    BasicTextField(252        value = draft,253        onValueChange = onDraftChange,254        singleLine = singleLine,255        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),256        cursorBrush = SolidColor(accent),257        visualTransformation = transformation,258        modifier = modifier259            .padding(bottom = 4.dp)260            .drawBehind {261                drawLine(262                    color = accent,263                    start = Offset(0f, size.height),264                    end = Offset(size.width, size.height),265                    strokeWidth = 1.dp.toPx(),266                )267            },268    )269}270271@Composable272private fun SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {273    val accent = LocalPeachAccent.current274    Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {275        Text(276            "✓ save", color = accent, style = PeachType.labelMono,277            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp),278        )279        Text(280            "✗", color = peachColors.T2, style = PeachType.labelMono,281            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp),282        )283        Spacer(Modifier.width(1.dp))284    }285}