TotpInlineEdit.kt

12.3 KB · raw

1package com.peachpasswords.ui.vault23/*4 * E6 — TOTP double-tap to edit the 2FA secret, inline in EntryDetailScreen.5 *6 * Self-contained drop-in composable. D6 / D7 / E6 all touch EntryDetailScreen.kt,7 * so this stays isolated with merge anchors documented in INTEGRATION.md.8 *9 * Provides:10 *   - TotpBandEditable : a TOTP band that double-taps (or taps a 14dp pencil) into11 *                        an inline 2FA-secret editor — masked BasicTextField with an12 *                        eye toggle and a ✓ save / ✗ cancel row (mirrors the D613 *                        inline-edit visual pattern). On save it calls back with the14 *                        new secret; live regeneration happens automatically because15 *                        the band reads the field flowing in from the VM/entry.16 *17 * It deliberately does NOT reuse the private FieldShell / CopyButton helpers in18 * EntryDetailScreen.kt by name in a way that would force them to change visibility —19 * the band has its own accent-tinted chrome (matching the existing TotpBand), and a20 * local copy button. The only EntryDetailScreen helper it depends on at merge time21 * is the existing `CopyButton`, which already lives in the same file; if the22 * orchestrator instead drops this into a separate file, make `CopyButton` `internal`23 * (see INTEGRATION.md). All private helpers here are E6-prefixed to avoid colliding24 * with D6's identically-shaped UnderlinedField / SaveCancelRow.25 */2627import androidx.compose.animation.core.RepeatMode28import androidx.compose.animation.core.animateFloat29import androidx.compose.animation.core.infiniteRepeatable30import androidx.compose.animation.core.rememberInfiniteTransition31import androidx.compose.animation.core.tween32import androidx.compose.foundation.background33import androidx.compose.foundation.border34import androidx.compose.foundation.clickable35import androidx.compose.foundation.gestures.detectTapGestures36import androidx.compose.foundation.layout.Arrangement37import androidx.compose.foundation.layout.Box38import androidx.compose.foundation.layout.Column39import androidx.compose.foundation.layout.Row40import androidx.compose.foundation.layout.Spacer41import androidx.compose.foundation.layout.fillMaxWidth42import androidx.compose.foundation.layout.height43import androidx.compose.foundation.layout.padding44import androidx.compose.foundation.layout.size45import androidx.compose.foundation.layout.width46import androidx.compose.foundation.shape.RoundedCornerShape47import androidx.compose.foundation.text.BasicTextField48import androidx.compose.material.icons.Icons49import androidx.compose.material.icons.outlined.Edit50import androidx.compose.material3.Icon51import androidx.compose.material3.Text52import androidx.compose.runtime.Composable53import androidx.compose.runtime.getValue54import androidx.compose.runtime.mutableStateOf55import androidx.compose.runtime.produceState56import androidx.compose.runtime.remember57import androidx.compose.runtime.setValue58import androidx.compose.ui.Alignment59import androidx.compose.ui.Modifier60import androidx.compose.ui.draw.clip61import androidx.compose.ui.draw.drawBehind62import androidx.compose.ui.geometry.Offset63import androidx.compose.ui.graphics.SolidColor64import androidx.compose.ui.graphics.graphicsLayer65import androidx.compose.ui.input.pointer.pointerInput66import androidx.compose.ui.text.input.PasswordVisualTransformation67import androidx.compose.ui.text.input.VisualTransformation68import androidx.compose.ui.unit.dp69import androidx.compose.ui.unit.em70import com.peachpasswords.ui.components.CopyButton71import com.peachpasswords.ui.components.TOTPRing72import com.peachpasswords.ui.theme.LocalPeachAccent73import com.peachpasswords.ui.theme.LocalReduceMotion74import com.peachpasswords.ui.theme.peachColors75import com.peachpasswords.ui.theme.PeachType76import com.peachpasswords.vault.TOTPGenerator7778/**79 * Accent-tinted TOTP band with double-tap-to-edit of the underlying 2FA secret.80 *81 * View mode is visually identical to the existing private `TotpBand`: large code,82 * mono countdown, sweep ring, copy button. A 14dp pencil sits beside the code, and83 * a double-tap anywhere on the code/band enters edit mode.84 *85 * Edit mode shows a masked [BasicTextField] (eye toggle to reveal) seeded with the86 * raw `totp` field (a base32 secret or an `otpauth://` URI), labelled "2FA SECRET",87 * over a ✓ save / ✗ cancel row. The draft is validated with88 * [TOTPGenerator.generateCode]; an empty / unparseable secret shows89 * "Invalid 2FA secret" in breach color and blocks save.90 *91 * On a valid save [onSaveSecret] is invoked with the trimmed new secret — the caller92 * persists via `vm.updateEntry(entry.copy(totp = newValue))`. Because [totpField]93 * flows back in from the entry StateFlow, the band regenerates live with no extra94 * wiring.95 *96 * @param totpField the current raw `totp` value (base32 secret or otpauth URI).97 * @param copied    copy-latch for this entry's TOTP (drives the copy button feedback).98 * @param onCopy    copies the live code (NOT the secret) — wire to `vm::copyTotp`.99 * @param onSaveSecret called with the trimmed, validated new secret on ✓ save.100 */101@Composable102fun TotpBandEditable(103    totpField: String,104    copied: Boolean,105    onCopy: () -> Unit,106    onSaveSecret: (String) -> Unit,107) {108    val accent = LocalPeachAccent.current109    val reduceMotion = LocalReduceMotion.current110111    var editing by remember(totpField) { mutableStateOf(false) }112113    Box(114        Modifier115            .fillMaxWidth()116            .padding(vertical = 10.dp)117            .clip(RoundedCornerShape(8.dp))118            .background(accent.copy(alpha = 0.08f))119            .border(1.dp, accent.copy(alpha = 0.20f), RoundedCornerShape(8.dp))120            .padding(horizontal = 14.dp, vertical = 10.dp),121    ) {122        if (!editing) {123            TotpView(124                totpField = totpField,125                accent = accent,126                reduceMotion = reduceMotion,127                copied = copied,128                onCopy = onCopy,129                onEnterEdit = { editing = true },130            )131        } else {132            TotpSecretEditor(133                initial = totpField,134                onSave = { newSecret -> onSaveSecret(newSecret); editing = false },135                onCancel = { editing = false },136            )137        }138    }139}140141/** Read-only band content: code, countdown, ring, pencil, copy — plus double-tap-to-edit. */142// produceState assigns `value` inside a while(true) loop — lint can't trace it statically.143@Suppress("ProduceStateDoesNotAssignValue")144@Composable145private fun TotpView(146    totpField: String,147    accent: androidx.compose.ui.graphics.Color,148    reduceMotion: Boolean,149    copied: Boolean,150    onCopy: () -> Unit,151    onEnterEdit: () -> Unit,152) {153    val code by produceState(TOTPGenerator.generateCode(totpField) ?: "------", totpField) {154        while (true) {155            value = TOTPGenerator.generateCode(totpField) ?: "------"156            kotlinx.coroutines.delay(1000)157        }158    }159    val remaining by produceState(TOTPGenerator.remainingSeconds()) {160        while (true) { value = TOTPGenerator.remainingSeconds(); kotlinx.coroutines.delay(500) }161    }162    val isHot = remaining <= 5163    val pulse = rememberInfiniteTransition(label = "totp-pulse")164    val pulseAlpha by pulse.animateFloat(165        initialValue = 0.55f, targetValue = 1f,166        animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "totp-pulse-alpha",167    )168    val codeAlpha = if (isHot && !reduceMotion) pulseAlpha else 1f169170    Row(verticalAlignment = Alignment.CenterVertically) {171        Column(172            Modifier173                .weight(1f)174                // Double-tap the code area to edit the 2FA secret (E6).175                .pointerInput(Unit) { detectTapGestures(onDoubleTap = { onEnterEdit() }) },176        ) {177            Row(verticalAlignment = Alignment.CenterVertically) {178                Text(179                    "ONE-TIME CODE",180                    color = accent.copy(alpha = 0.70f),181                    style = PeachType.labelMono.copy(letterSpacing = 0.08.em),182                )183                Spacer(Modifier.width(6.dp))184                Icon(185                    Icons.Outlined.Edit,186                    contentDescription = "edit 2FA secret",187                    tint = peachColors.T2,188                    modifier = Modifier189                        .size(14.dp)190                        .clip(RoundedCornerShape(4.dp))191                        .clickable(onClick = onEnterEdit),192                )193            }194            val spaced = if (code.length == 6) "${code.substring(0, 3)} ${code.substring(3)}" else code195            Text(196                spaced,197                color = if (isHot) peachColors.Breach else accent,198                style = PeachType.totpCode,199                modifier = Modifier.graphicsLayer { alpha = codeAlpha },200            )201        }202        Text("${remaining}s", color = peachColors.T2, style = PeachType.totpCountdown)203        Spacer(Modifier.width(12.dp))204        TOTPRing(size = 28.dp)205        Spacer(Modifier.width(8.dp))206        CopyButton(copied, onCopy)207    }208}209210/** Inline 2FA-secret editor: masked underlined field + eye toggle + ✓/✗, with validation. */211@Composable212private fun TotpSecretEditor(213    initial: String,214    onSave: (String) -> Unit,215    onCancel: () -> Unit,216) {217    val accent = LocalPeachAccent.current218    var draft by remember { mutableStateOf(initial) }219    var show by remember { mutableStateOf(false) }220    var showError by remember { mutableStateOf(false) }221222    val trimmed = draft.trim()223    val valid = TOTPGenerator.generateCode(trimmed) != null224225    Column(Modifier.fillMaxWidth()) {226        Text(227            "2FA SECRET",228            color = accent.copy(alpha = 0.70f),229            style = PeachType.labelMono.copy(letterSpacing = 0.08.em),230        )231        Spacer(Modifier.height(8.dp))232        Row(verticalAlignment = Alignment.CenterVertically) {233            E6UnderlinedField(234                draft = draft,235                onDraftChange = { draft = it; showError = false },236                transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),237                modifier = Modifier.weight(1f),238            )239            Text(240                if (show) "hide" else "show", color = accent, style = PeachType.labelMono,241                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),242            )243        }244        if (showError) {245            Spacer(Modifier.height(6.dp))246            Text("Invalid 2FA secret", color = peachColors.Breach, style = PeachType.labelMono)247        }248        Spacer(Modifier.height(8.dp))249        E6SaveCancelRow(250            onSave = {251                if (valid) onSave(trimmed) else showError = true252            },253            onCancel = { onCancel() },254        )255    }256}257258/** BasicTextField with an accent underline (mirrors D6's inline-edit field). */259@Composable260private fun E6UnderlinedField(261    draft: String,262    onDraftChange: (String) -> Unit,263    transformation: VisualTransformation = VisualTransformation.None,264    modifier: Modifier = Modifier,265) {266    val accent = LocalPeachAccent.current267    BasicTextField(268        value = draft,269        onValueChange = onDraftChange,270        singleLine = true,271        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),272        cursorBrush = SolidColor(accent),273        visualTransformation = transformation,274        modifier = modifier275            .padding(bottom = 4.dp)276            .drawBehind {277                drawLine(278                    color = accent,279                    start = Offset(0f, size.height),280                    end = Offset(size.width, size.height),281                    strokeWidth = 1.dp.toPx(),282                )283            },284    )285}286287@Composable288private fun E6SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {289    val accent = LocalPeachAccent.current290    Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {291        Text(292            "✓ save", color = accent, style = PeachType.labelMono,293            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp),294        )295        Text(296            "✗", color = peachColors.T2, style = PeachType.labelMono,297            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp),298        )299    }300}