TotpInlineEdit.kt

16.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.Animatable29import androidx.compose.animation.core.FastOutSlowInEasing30import androidx.compose.animation.core.LinearEasing31import androidx.compose.animation.core.animateFloat32import androidx.compose.animation.core.infiniteRepeatable33import androidx.compose.animation.core.rememberInfiniteTransition34import androidx.compose.animation.core.tween35import androidx.compose.foundation.clickable36import androidx.compose.foundation.gestures.detectTapGestures37import androidx.compose.foundation.layout.Arrangement38import androidx.compose.foundation.layout.Box39import androidx.compose.foundation.layout.Column40import androidx.compose.foundation.layout.Row41import androidx.compose.foundation.layout.Spacer42import androidx.compose.foundation.layout.fillMaxWidth43import androidx.compose.foundation.layout.height44import androidx.compose.foundation.layout.padding45import androidx.compose.foundation.layout.size46import androidx.compose.foundation.layout.width47import androidx.compose.foundation.shape.RoundedCornerShape48import androidx.compose.foundation.text.BasicTextField49import androidx.compose.material.icons.Icons50import androidx.compose.material.icons.outlined.Edit51import androidx.compose.material3.Icon52import androidx.compose.material3.Text53import androidx.compose.runtime.Composable54import androidx.compose.runtime.LaunchedEffect55import androidx.compose.runtime.getValue56import androidx.compose.runtime.mutableStateOf57import androidx.compose.runtime.produceState58import androidx.compose.runtime.remember59import androidx.compose.runtime.setValue60import androidx.compose.ui.Alignment61import androidx.compose.ui.Modifier62import androidx.compose.ui.draw.clip63import androidx.compose.ui.draw.drawBehind64import androidx.compose.ui.graphics.drawscope.drawIntoCanvas65import androidx.compose.ui.geometry.CornerRadius66import androidx.compose.ui.geometry.Offset67import androidx.compose.ui.geometry.Rect68import androidx.compose.ui.geometry.RoundRect69import androidx.compose.ui.graphics.Path70import androidx.compose.ui.graphics.PathEffect71import androidx.compose.ui.graphics.Paint72import androidx.compose.ui.graphics.PaintingStyle73import androidx.compose.ui.graphics.SolidColor74import androidx.compose.ui.graphics.StrokeCap75import androidx.compose.ui.graphics.drawscope.Stroke76import androidx.compose.ui.graphics.graphicsLayer77import androidx.compose.ui.input.pointer.pointerInput78import androidx.compose.ui.text.input.PasswordVisualTransformation79import androidx.compose.ui.text.input.VisualTransformation80import androidx.compose.ui.unit.dp81import androidx.compose.ui.unit.em82import com.peachpasswords.ui.components.CopyButton83import com.peachpasswords.ui.theme.LocalPeachAccent84import com.peachpasswords.ui.theme.LocalReduceMotion85import com.peachpasswords.ui.theme.peachColors86import com.peachpasswords.ui.theme.PeachType87import com.peachpasswords.vault.TOTPGenerator88import kotlinx.coroutines.coroutineScope89import kotlinx.coroutines.isActive90import kotlinx.coroutines.launch9192/**93 * Accent-tinted TOTP band with double-tap-to-edit of the underlying 2FA secret.94 *95 * View mode is a liquid countdown card: the accent outline drains smoothly around96 * the 30-second period, then gently refills with a subtle edge-only flash. A 14dp97 * pencil sits beside the code, and a double-tap anywhere on the code/band edits it.98 *99 * Edit mode shows a masked [BasicTextField] (eye toggle to reveal) seeded with the100 * raw `totp` field (a base32 secret or an `otpauth://` URI), labelled "2FA SECRET",101 * over a ✓ save / ✗ cancel row. The draft is validated with102 * [TOTPGenerator.generateCode]; an empty / unparseable secret shows103 * "Invalid 2FA secret" in breach color and blocks save.104 *105 * On a valid save [onSaveSecret] is invoked with the trimmed new secret — the caller106 * persists via `vm.updateEntry(entry.copy(totp = newValue))`. Because [totpField]107 * flows back in from the entry StateFlow, the band regenerates live with no extra108 * wiring.109 *110 * @param totpField the current raw `totp` value (base32 secret or otpauth URI).111 * @param copied    copy-latch for this entry's TOTP (drives the copy button feedback).112 * @param onCopy    copies the live code (NOT the secret) — wire to `vm::copyTotp`.113 * @param onSaveSecret called with the trimmed, validated new secret on ✓ save.114 */115@Composable116fun TotpBandEditable(117    totpField: String,118    copied: Boolean,119    onCopy: () -> Unit,120    onSaveSecret: (String) -> Unit,121) {122    val accent = LocalPeachAccent.current123    val reduceMotion = LocalReduceMotion.current124    val surfaceCard = peachColors.SurfaceCard125    val liquidFraction = remember(totpField) { Animatable(1f) }126    val edgeFlash = remember(totpField) { Animatable(0f) }127    LaunchedEffect(totpField, reduceMotion) {128        if (reduceMotion) {129            liquidFraction.snapTo(1f)130            edgeFlash.snapTo(0f)131        } else {132            val periodMillis = TOTPGenerator.PERIOD_SECONDS * 1_000L133            while (isActive) {134                val elapsedMillis = System.currentTimeMillis() % periodMillis135                val remainingMillis = periodMillis - elapsedMillis136                liquidFraction.snapTo(remainingMillis.toFloat() / periodMillis)137                liquidFraction.animateTo(138                    targetValue = 0f,139                    animationSpec = tween(140                        durationMillis = remainingMillis.toInt(),141                        easing = LinearEasing,142                    ),143                )144                edgeFlash.snapTo(1f)145                coroutineScope {146                    launch {147                        liquidFraction.animateTo(148                            targetValue = 1f,149                            animationSpec = tween(700, easing = FastOutSlowInEasing),150                        )151                    }152                    launch {153                        edgeFlash.animateTo(154                            targetValue = 0f,155                            animationSpec = tween(900, easing = FastOutSlowInEasing),156                        )157                    }158                }159            }160        }161    }162163    var editing by remember(totpField) { mutableStateOf(false) }164    val shape = RoundedCornerShape(8.dp)165166    Box(167        Modifier168            .fillMaxWidth()169            .padding(vertical = 10.dp)170            .clip(shape)171            .drawBehind {172                val radius = 8.dp.toPx()173                val perimeter = 2f * (size.width - radius * 2f) +174                    2f * (size.height - radius * 2f) +175                    (2f * Math.PI.toFloat() * radius)176                val baseStroke = 1.dp.toPx()177                val ringPath = Path().apply {178                    addRoundRect(RoundRect(Rect(Offset.Zero, size), radius, radius))179                }180                drawRoundRect(181                    color = surfaceCard,182                    cornerRadius = CornerRadius(radius),183                )184                drawRoundRect(185                    color = accent.copy(alpha = 0.16f),186                    cornerRadius = CornerRadius(radius),187                    style = Stroke(width = baseStroke),188                )189                drawIntoCanvas { canvas ->190                    canvas.drawPath(191                        ringPath,192                        Paint().apply {193                            color = accent.copy(alpha = 0.74f + edgeFlash.value * 0.12f)194                            style = PaintingStyle.Stroke195                            strokeWidth = 1.5.dp.toPx()196                            strokeCap = StrokeCap.Round197                            pathEffect = PathEffect.dashPathEffect(198                                floatArrayOf((perimeter * liquidFraction.value).coerceAtLeast(0.1f), perimeter),199                            )200                        },201                    )202                }203                if (edgeFlash.value > 0f) {204                    drawIntoCanvas { canvas ->205                        canvas.drawPath(206                            ringPath,207                            Paint().apply {208                                color = accent.copy(alpha = edgeFlash.value * 0.16f)209                                style = PaintingStyle.Stroke210                                strokeWidth = 4.dp.toPx()211                            },212                        )213                    }214                }215            }216            .padding(horizontal = 14.dp, vertical = 10.dp),217    ) {218        if (!editing) {219            TotpView(220                totpField = totpField,221                accent = accent,222                reduceMotion = reduceMotion,223                copied = copied,224                onCopy = onCopy,225                onEnterEdit = { editing = true },226            )227        } else {228            TotpSecretEditor(229                initial = totpField,230                onSave = { newSecret -> onSaveSecret(newSecret); editing = false },231                onCancel = { editing = false },232            )233        }234    }235}236237/** Read-only band content: code, countdown, pencil, copy — plus double-tap-to-edit. */238// produceState assigns `value` inside a while(true) loop — lint can't trace it statically.239@Suppress("ProduceStateDoesNotAssignValue")240@Composable241private fun TotpView(242    totpField: String,243    accent: androidx.compose.ui.graphics.Color,244    reduceMotion: Boolean,245    copied: Boolean,246    onCopy: () -> Unit,247    onEnterEdit: () -> Unit,248) {249    val code by produceState(TOTPGenerator.generateCode(totpField) ?: "------", totpField) {250        while (true) {251            value = TOTPGenerator.generateCode(totpField) ?: "------"252            kotlinx.coroutines.delay(1000)253        }254    }255    val remaining by produceState(TOTPGenerator.remainingSeconds()) {256        while (true) { value = TOTPGenerator.remainingSeconds(); kotlinx.coroutines.delay(500) }257    }258    val isHot = remaining <= 5259    val pulse = rememberInfiniteTransition(label = "totp-pulse")260    val pulseAlpha by pulse.animateFloat(261        initialValue = 0.55f, targetValue = 1f,262        animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "totp-pulse-alpha",263    )264    val codeAlpha = if (isHot && !reduceMotion) pulseAlpha else 1f265266    Row(verticalAlignment = Alignment.CenterVertically) {267        Column(268            Modifier269                .weight(1f)270                // Double-tap the code area to edit the 2FA secret (E6).271                .pointerInput(Unit) { detectTapGestures(onDoubleTap = { onEnterEdit() }) },272        ) {273            Row(verticalAlignment = Alignment.CenterVertically) {274                Text(275                    "ONE-TIME CODE",276                    color = accent.copy(alpha = 0.70f),277                    style = PeachType.labelMono.copy(letterSpacing = 0.08.em),278                )279                Spacer(Modifier.width(6.dp))280                Icon(281                    Icons.Outlined.Edit,282                    contentDescription = "edit 2FA secret",283                    tint = peachColors.T2,284                    modifier = Modifier285                        .size(14.dp)286                        .clip(RoundedCornerShape(4.dp))287                        .clickable(onClick = onEnterEdit),288                )289            }290            val spaced = if (code.length == 6) "${code.substring(0, 3)} ${code.substring(3)}" else code291            Text(292                spaced,293                color = if (isHot) peachColors.Breach else accent,294                style = PeachType.totpCode,295                modifier = Modifier.graphicsLayer { alpha = codeAlpha },296            )297        }298        Text("${remaining}s", color = peachColors.T2, style = PeachType.totpCountdown)299        Spacer(Modifier.width(12.dp))300        CopyButton(copied, onCopy)301    }302}303304/** Inline 2FA-secret editor: masked underlined field + eye toggle + ✓/✗, with validation. */305@Composable306private fun TotpSecretEditor(307    initial: String,308    onSave: (String) -> Unit,309    onCancel: () -> Unit,310) {311    val accent = LocalPeachAccent.current312    var draft by remember { mutableStateOf(initial) }313    var show by remember { mutableStateOf(false) }314    var showError by remember { mutableStateOf(false) }315316    val trimmed = draft.trim()317    val valid = TOTPGenerator.generateCode(trimmed) != null318319    Column(Modifier.fillMaxWidth()) {320        Text(321            "2FA SECRET",322            color = accent.copy(alpha = 0.70f),323            style = PeachType.labelMono.copy(letterSpacing = 0.08.em),324        )325        Spacer(Modifier.height(8.dp))326        Row(verticalAlignment = Alignment.CenterVertically) {327            E6UnderlinedField(328                draft = draft,329                onDraftChange = { draft = it; showError = false },330                transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),331                modifier = Modifier.weight(1f),332            )333            Text(334                if (show) "hide" else "show", color = accent, style = PeachType.labelMono,335                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),336            )337        }338        if (showError) {339            Spacer(Modifier.height(6.dp))340            Text("Invalid 2FA secret", color = peachColors.Breach, style = PeachType.labelMono)341        }342        Spacer(Modifier.height(8.dp))343        E6SaveCancelRow(344            onSave = {345                if (valid) onSave(trimmed) else showError = true346            },347            onCancel = { onCancel() },348        )349    }350}351352/** BasicTextField with an accent underline (mirrors D6's inline-edit field). */353@Composable354private fun E6UnderlinedField(355    draft: String,356    onDraftChange: (String) -> Unit,357    transformation: VisualTransformation = VisualTransformation.None,358    modifier: Modifier = Modifier,359) {360    val accent = LocalPeachAccent.current361    BasicTextField(362        value = draft,363        onValueChange = onDraftChange,364        singleLine = true,365        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),366        cursorBrush = SolidColor(accent),367        visualTransformation = transformation,368        modifier = modifier369            .padding(bottom = 4.dp)370            .drawBehind {371                drawLine(372                    color = accent,373                    start = Offset(0f, size.height),374                    end = Offset(size.width, size.height),375                    strokeWidth = 1.dp.toPx(),376                )377            },378    )379}380381@Composable382private fun E6SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {383    val accent = LocalPeachAccent.current384    Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {385        Text(386            "✓ save", color = accent, style = PeachType.labelMono,387            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp),388        )389        Text(390            "✗", color = peachColors.T2, style = PeachType.labelMono,391            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp),392        )393    }394}