TotpInlineEdit.kt

12.9 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.animateFloatAsState30import androidx.compose.animation.core.infiniteRepeatable31import androidx.compose.animation.core.rememberInfiniteTransition32import androidx.compose.animation.core.tween33import 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.geometry.Size64import androidx.compose.ui.graphics.SolidColor65import androidx.compose.ui.graphics.graphicsLayer66import androidx.compose.ui.input.pointer.pointerInput67import androidx.compose.ui.text.input.PasswordVisualTransformation68import androidx.compose.ui.text.input.VisualTransformation69import androidx.compose.ui.unit.dp70import androidx.compose.ui.unit.em71import com.peachpasswords.ui.components.CopyButton72import 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 a liquid countdown card: the accent background drains right-to-left82 * over the 30-second period and refills left-to-right when the code resets. A 14dp83 * pencil sits beside the code, and a double-tap anywhere on the code/band edits it.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@Suppress("ProduceStateDoesNotAssignValue")102@Composable103fun TotpBandEditable(104    totpField: String,105    copied: Boolean,106    onCopy: () -> Unit,107    onSaveSecret: (String) -> Unit,108) {109    val accent = LocalPeachAccent.current110    val reduceMotion = LocalReduceMotion.current111    val surfaceCard = peachColors.SurfaceCard112    val liquidFraction by produceState(1f, totpField) {113        while (true) {114            value = 1f - TOTPGenerator.periodProgress()115            kotlinx.coroutines.delay(100)116        }117    }118    val animatedLiquidFraction by animateFloatAsState(119        targetValue = liquidFraction,120        animationSpec = tween(if (liquidFraction > 0.9f) 450 else 120),121        label = "totp-liquid",122    )123124    var editing by remember(totpField) { mutableStateOf(false) }125    val shape = RoundedCornerShape(8.dp)126127    Box(128        Modifier129            .fillMaxWidth()130            .padding(vertical = 10.dp)131            .clip(shape)132            .drawBehind {133                drawRect(surfaceCard)134                drawRect(135                    color = accent.copy(alpha = 0.16f),136                    size = Size(size.width * animatedLiquidFraction, size.height),137                )138            }139            .border(1.dp, accent.copy(alpha = 0.20f), shape)140            .padding(horizontal = 14.dp, vertical = 10.dp),141    ) {142        if (!editing) {143            TotpView(144                totpField = totpField,145                accent = accent,146                reduceMotion = reduceMotion,147                copied = copied,148                onCopy = onCopy,149                onEnterEdit = { editing = true },150            )151        } else {152            TotpSecretEditor(153                initial = totpField,154                onSave = { newSecret -> onSaveSecret(newSecret); editing = false },155                onCancel = { editing = false },156            )157        }158    }159}160161/** Read-only band content: code, countdown, pencil, copy — plus double-tap-to-edit. */162// produceState assigns `value` inside a while(true) loop — lint can't trace it statically.163@Suppress("ProduceStateDoesNotAssignValue")164@Composable165private fun TotpView(166    totpField: String,167    accent: androidx.compose.ui.graphics.Color,168    reduceMotion: Boolean,169    copied: Boolean,170    onCopy: () -> Unit,171    onEnterEdit: () -> Unit,172) {173    val code by produceState(TOTPGenerator.generateCode(totpField) ?: "------", totpField) {174        while (true) {175            value = TOTPGenerator.generateCode(totpField) ?: "------"176            kotlinx.coroutines.delay(1000)177        }178    }179    val remaining by produceState(TOTPGenerator.remainingSeconds()) {180        while (true) { value = TOTPGenerator.remainingSeconds(); kotlinx.coroutines.delay(500) }181    }182    val isHot = remaining <= 5183    val pulse = rememberInfiniteTransition(label = "totp-pulse")184    val pulseAlpha by pulse.animateFloat(185        initialValue = 0.55f, targetValue = 1f,186        animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "totp-pulse-alpha",187    )188    val codeAlpha = if (isHot && !reduceMotion) pulseAlpha else 1f189190    Row(verticalAlignment = Alignment.CenterVertically) {191        Column(192            Modifier193                .weight(1f)194                // Double-tap the code area to edit the 2FA secret (E6).195                .pointerInput(Unit) { detectTapGestures(onDoubleTap = { onEnterEdit() }) },196        ) {197            Row(verticalAlignment = Alignment.CenterVertically) {198                Text(199                    "ONE-TIME CODE",200                    color = accent.copy(alpha = 0.70f),201                    style = PeachType.labelMono.copy(letterSpacing = 0.08.em),202                )203                Spacer(Modifier.width(6.dp))204                Icon(205                    Icons.Outlined.Edit,206                    contentDescription = "edit 2FA secret",207                    tint = peachColors.T2,208                    modifier = Modifier209                        .size(14.dp)210                        .clip(RoundedCornerShape(4.dp))211                        .clickable(onClick = onEnterEdit),212                )213            }214            val spaced = if (code.length == 6) "${code.substring(0, 3)} ${code.substring(3)}" else code215            Text(216                spaced,217                color = if (isHot) peachColors.Breach else accent,218                style = PeachType.totpCode,219                modifier = Modifier.graphicsLayer { alpha = codeAlpha },220            )221        }222        Text("${remaining}s", color = peachColors.T2, style = PeachType.totpCountdown)223        Spacer(Modifier.width(12.dp))224        CopyButton(copied, onCopy)225    }226}227228/** Inline 2FA-secret editor: masked underlined field + eye toggle + ✓/✗, with validation. */229@Composable230private fun TotpSecretEditor(231    initial: String,232    onSave: (String) -> Unit,233    onCancel: () -> Unit,234) {235    val accent = LocalPeachAccent.current236    var draft by remember { mutableStateOf(initial) }237    var show by remember { mutableStateOf(false) }238    var showError by remember { mutableStateOf(false) }239240    val trimmed = draft.trim()241    val valid = TOTPGenerator.generateCode(trimmed) != null242243    Column(Modifier.fillMaxWidth()) {244        Text(245            "2FA SECRET",246            color = accent.copy(alpha = 0.70f),247            style = PeachType.labelMono.copy(letterSpacing = 0.08.em),248        )249        Spacer(Modifier.height(8.dp))250        Row(verticalAlignment = Alignment.CenterVertically) {251            E6UnderlinedField(252                draft = draft,253                onDraftChange = { draft = it; showError = false },254                transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),255                modifier = Modifier.weight(1f),256            )257            Text(258                if (show) "hide" else "show", color = accent, style = PeachType.labelMono,259                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),260            )261        }262        if (showError) {263            Spacer(Modifier.height(6.dp))264            Text("Invalid 2FA secret", color = peachColors.Breach, style = PeachType.labelMono)265        }266        Spacer(Modifier.height(8.dp))267        E6SaveCancelRow(268            onSave = {269                if (valid) onSave(trimmed) else showError = true270            },271            onCancel = { onCancel() },272        )273    }274}275276/** BasicTextField with an accent underline (mirrors D6's inline-edit field). */277@Composable278private fun E6UnderlinedField(279    draft: String,280    onDraftChange: (String) -> Unit,281    transformation: VisualTransformation = VisualTransformation.None,282    modifier: Modifier = Modifier,283) {284    val accent = LocalPeachAccent.current285    BasicTextField(286        value = draft,287        onValueChange = onDraftChange,288        singleLine = true,289        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),290        cursorBrush = SolidColor(accent),291        visualTransformation = transformation,292        modifier = modifier293            .padding(bottom = 4.dp)294            .drawBehind {295                drawLine(296                    color = accent,297                    start = Offset(0f, size.height),298                    end = Offset(size.width, size.height),299                    strokeWidth = 1.dp.toPx(),300                )301            },302    )303}304305@Composable306private fun E6SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {307    val accent = LocalPeachAccent.current308    Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {309        Text(310            "✓ save", color = accent, style = PeachType.labelMono,311            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp),312        )313        Text(314            "✗", color = peachColors.T2, style = PeachType.labelMono,315            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp),316        )317    }318}