TotpInlineEdit.kt

15.1 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.Animatable29import androidx.compose.animation.core.FastOutSlowInEasing30import androidx.compose.animation.core.animateFloat31import androidx.compose.animation.core.infiniteRepeatable32import androidx.compose.animation.core.rememberInfiniteTransition33import androidx.compose.animation.core.tween34import 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.LaunchedEffect54import androidx.compose.runtime.getValue55import androidx.compose.runtime.mutableStateOf56import androidx.compose.runtime.produceState57import androidx.compose.runtime.remember58import androidx.compose.runtime.setValue59import androidx.compose.ui.Alignment60import androidx.compose.ui.Modifier61import androidx.compose.ui.draw.clip62import androidx.compose.ui.draw.drawBehind63import androidx.compose.ui.geometry.CornerRadius64import androidx.compose.ui.geometry.Offset65import androidx.compose.ui.graphics.Brush66import androidx.compose.ui.graphics.Color67import androidx.compose.ui.graphics.SolidColor68import androidx.compose.ui.graphics.drawscope.Stroke69import androidx.compose.ui.graphics.graphicsLayer70import androidx.compose.ui.input.pointer.pointerInput71import androidx.compose.ui.text.input.PasswordVisualTransformation72import androidx.compose.ui.text.input.VisualTransformation73import androidx.compose.ui.unit.dp74import androidx.compose.ui.unit.em75import com.peachpasswords.ui.components.CopyButton76import com.peachpasswords.ui.theme.LocalPeachAccent77import com.peachpasswords.ui.theme.LocalReduceMotion78import com.peachpasswords.ui.theme.peachColors79import com.peachpasswords.ui.theme.PeachType80import com.peachpasswords.vault.TOTPGenerator81import kotlinx.coroutines.isActive82import kotlinx.coroutines.launch8384/**85 * Accent-tinted TOTP band with double-tap-to-edit of the underlying 2FA secret.86 *87 * View mode is a liquid countdown card: the accent outline drains smoothly around88 * the 30-second period, then gently refills with a subtle edge-only flash. A 14dp89 * pencil sits beside the code, and a double-tap anywhere on the code/band edits it.90 *91 * Edit mode shows a masked [BasicTextField] (eye toggle to reveal) seeded with the92 * raw `totp` field (a base32 secret or an `otpauth://` URI), labelled "2FA SECRET",93 * over a ✓ save / ✗ cancel row. The draft is validated with94 * [TOTPGenerator.generateCode]; an empty / unparseable secret shows95 * "Invalid 2FA secret" in breach color and blocks save.96 *97 * On a valid save [onSaveSecret] is invoked with the trimmed new secret — the caller98 * persists via `vm.updateEntry(entry.copy(totp = newValue))`. Because [totpField]99 * flows back in from the entry StateFlow, the band regenerates live with no extra100 * wiring.101 *102 * @param totpField the current raw `totp` value (base32 secret or otpauth URI).103 * @param copied    copy-latch for this entry's TOTP (drives the copy button feedback).104 * @param onCopy    copies the live code (NOT the secret) — wire to `vm::copyTotp`.105 * @param onSaveSecret called with the trimmed, validated new secret on ✓ save.106 */107@Composable108fun TotpBandEditable(109    totpField: String,110    copied: Boolean,111    onCopy: () -> Unit,112    onSaveSecret: (String) -> Unit,113) {114    val accent = LocalPeachAccent.current115    val reduceMotion = LocalReduceMotion.current116    val surfaceCard = peachColors.SurfaceCard117    val liquidFraction = remember(totpField) { androidx.compose.runtime.mutableFloatStateOf(1f) }118    val edgeFlash = remember(totpField) { Animatable(0f) }119    LaunchedEffect(totpField, reduceMotion) {120        if (reduceMotion) {121            liquidFraction.floatValue = 1f122            edgeFlash.snapTo(0f)123        } else {124            val periodMillis = TOTPGenerator.PERIOD_SECONDS * 1_000L125            var previousElapsedMillis = -1L126            while (isActive) {127                val elapsedMillis = Math.floorMod(System.currentTimeMillis(), periodMillis)128                liquidFraction.floatValue =129                    ((periodMillis - elapsedMillis).toFloat() / periodMillis).coerceIn(0f, 1f)130                if (previousElapsedMillis >= 0L && elapsedMillis < previousElapsedMillis) {131                    launch {132                        edgeFlash.snapTo(1f)133                        edgeFlash.animateTo(134                            targetValue = 0f,135                            animationSpec = tween(900, easing = FastOutSlowInEasing),136                        )137                    }138                }139                previousElapsedMillis = elapsedMillis140                kotlinx.coroutines.delay(16L)141            }142        }143    }144145    var editing by remember(totpField) { mutableStateOf(false) }146    val shape = RoundedCornerShape(8.dp)147148    Box(149        Modifier150            .fillMaxWidth()151            .padding(vertical = 10.dp)152            .clip(shape)153            .drawBehind {154                val radius = 8.dp.toPx()155                val baseStroke = 1.dp.toPx()156                val fraction = liquidFraction.floatValue157                val accentWidth = (size.width * fraction).coerceAtLeast(1f)158                drawRoundRect(159                    color = surfaceCard,160                    cornerRadius = CornerRadius(radius),161                )162                drawRoundRect(163                    color = accent.copy(alpha = 0.16f),164                    cornerRadius = CornerRadius(radius),165                    style = Stroke(width = baseStroke),166                )167                drawRoundRect(168                    brush = Brush.horizontalGradient(169                        colors = listOf(170                            accent.copy(alpha = 0.92f + edgeFlash.value * 0.08f),171                            accent.copy(alpha = 0.92f + edgeFlash.value * 0.08f),172                            Color.Transparent,173                        ),174                        startX = 0f,175                        endX = accentWidth,176                    ),177                    cornerRadius = CornerRadius(radius),178                    style = Stroke(width = 2.dp.toPx()),179                )180                if (edgeFlash.value > 0f) {181                    drawRoundRect(182                        color = accent.copy(alpha = edgeFlash.value * 0.16f),183                        cornerRadius = CornerRadius(radius),184                        style = Stroke(width = 4.dp.toPx()),185                    )186                }187            }188            .padding(horizontal = 14.dp, vertical = 10.dp),189    ) {190        if (!editing) {191            TotpView(192                totpField = totpField,193                accent = accent,194                reduceMotion = reduceMotion,195                copied = copied,196                onCopy = onCopy,197                onEnterEdit = { editing = true },198            )199        } else {200            TotpSecretEditor(201                initial = totpField,202                onSave = { newSecret -> onSaveSecret(newSecret); editing = false },203                onCancel = { editing = false },204            )205        }206    }207}208209/** Read-only band content: code, countdown, pencil, copy — plus double-tap-to-edit. */210// produceState assigns `value` inside a while(true) loop — lint can't trace it statically.211@Suppress("ProduceStateDoesNotAssignValue")212@Composable213private fun TotpView(214    totpField: String,215    accent: androidx.compose.ui.graphics.Color,216    reduceMotion: Boolean,217    copied: Boolean,218    onCopy: () -> Unit,219    onEnterEdit: () -> Unit,220) {221    val code by produceState(TOTPGenerator.generateCode(totpField) ?: "------", totpField) {222        while (true) {223            value = TOTPGenerator.generateCode(totpField) ?: "------"224            kotlinx.coroutines.delay(1000)225        }226    }227    val remaining by produceState(TOTPGenerator.remainingSeconds()) {228        while (true) { value = TOTPGenerator.remainingSeconds(); kotlinx.coroutines.delay(500) }229    }230    val isHot = remaining <= 5231    val pulse = rememberInfiniteTransition(label = "totp-pulse")232    val pulseAlpha by pulse.animateFloat(233        initialValue = 0.55f, targetValue = 1f,234        animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "totp-pulse-alpha",235    )236    val codeAlpha = if (isHot && !reduceMotion) pulseAlpha else 1f237238    Row(verticalAlignment = Alignment.CenterVertically) {239        Column(240            Modifier241                .weight(1f)242                // Double-tap the code area to edit the 2FA secret (E6).243                .pointerInput(Unit) { detectTapGestures(onDoubleTap = { onEnterEdit() }) },244        ) {245            Row(verticalAlignment = Alignment.CenterVertically) {246                Text(247                    "ONE-TIME CODE",248                    color = accent.copy(alpha = 0.70f),249                    style = PeachType.labelMono.copy(letterSpacing = 0.08.em),250                )251                Spacer(Modifier.width(6.dp))252                Icon(253                    Icons.Outlined.Edit,254                    contentDescription = "edit 2FA secret",255                    tint = peachColors.T2,256                    modifier = Modifier257                        .size(14.dp)258                        .clip(RoundedCornerShape(4.dp))259                        .clickable(onClick = onEnterEdit),260                )261            }262            val spaced = if (code.length == 6) "${code.substring(0, 3)} ${code.substring(3)}" else code263            Text(264                spaced,265                color = if (isHot) peachColors.Breach else accent,266                style = PeachType.totpCode,267                modifier = Modifier.graphicsLayer { alpha = codeAlpha },268            )269        }270        Text("${remaining}s", color = peachColors.T2, style = PeachType.totpCountdown)271        Spacer(Modifier.width(12.dp))272        CopyButton(copied, onCopy)273    }274}275276/** Inline 2FA-secret editor: masked underlined field + eye toggle + ✓/✗, with validation. */277@Composable278private fun TotpSecretEditor(279    initial: String,280    onSave: (String) -> Unit,281    onCancel: () -> Unit,282) {283    val accent = LocalPeachAccent.current284    var draft by remember { mutableStateOf(initial) }285    var show by remember { mutableStateOf(false) }286    var showError by remember { mutableStateOf(false) }287288    val trimmed = draft.trim()289    val valid = TOTPGenerator.generateCode(trimmed) != null290291    Column(Modifier.fillMaxWidth()) {292        Text(293            "2FA SECRET",294            color = accent.copy(alpha = 0.70f),295            style = PeachType.labelMono.copy(letterSpacing = 0.08.em),296        )297        Spacer(Modifier.height(8.dp))298        Row(verticalAlignment = Alignment.CenterVertically) {299            E6UnderlinedField(300                draft = draft,301                onDraftChange = { draft = it; showError = false },302                transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),303                modifier = Modifier.weight(1f),304            )305            Text(306                if (show) "hide" else "show", color = accent, style = PeachType.labelMono,307                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),308            )309        }310        if (showError) {311            Spacer(Modifier.height(6.dp))312            Text("Invalid 2FA secret", color = peachColors.Breach, style = PeachType.labelMono)313        }314        Spacer(Modifier.height(8.dp))315        E6SaveCancelRow(316            onSave = {317                if (valid) onSave(trimmed) else showError = true318            },319            onCancel = { onCancel() },320        )321    }322}323324/** BasicTextField with an accent underline (mirrors D6's inline-edit field). */325@Composable326private fun E6UnderlinedField(327    draft: String,328    onDraftChange: (String) -> Unit,329    transformation: VisualTransformation = VisualTransformation.None,330    modifier: Modifier = Modifier,331) {332    val accent = LocalPeachAccent.current333    BasicTextField(334        value = draft,335        onValueChange = onDraftChange,336        singleLine = true,337        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),338        cursorBrush = SolidColor(accent),339        visualTransformation = transformation,340        modifier = modifier341            .padding(bottom = 4.dp)342            .drawBehind {343                drawLine(344                    color = accent,345                    start = Offset(0f, size.height),346                    end = Offset(size.width, size.height),347                    strokeWidth = 1.dp.toPx(),348                )349            },350    )351}352353@Composable354private fun E6SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {355    val accent = LocalPeachAccent.current356    Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {357        Text(358            "✓ save", color = accent, style = PeachType.labelMono,359            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp),360        )361        Text(362            "✗", color = peachColors.T2, style = PeachType.labelMono,363            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp),364        )365    }366}