TotpInlineEdit.kt

16.0 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.geometry.CornerRadius65import androidx.compose.ui.geometry.Offset66import androidx.compose.ui.geometry.Rect67import androidx.compose.ui.geometry.RoundRect68import androidx.compose.ui.graphics.Path69import androidx.compose.ui.graphics.Brush70import androidx.compose.ui.graphics.Color71import androidx.compose.ui.graphics.SolidColor72import androidx.compose.ui.graphics.drawscope.Stroke73import androidx.compose.ui.graphics.graphicsLayer74import androidx.compose.ui.input.pointer.pointerInput75import androidx.compose.ui.text.input.PasswordVisualTransformation76import androidx.compose.ui.text.input.VisualTransformation77import androidx.compose.ui.unit.dp78import androidx.compose.ui.unit.em79import com.peachpasswords.ui.components.CopyButton80import com.peachpasswords.ui.theme.LocalPeachAccent81import com.peachpasswords.ui.theme.LocalReduceMotion82import com.peachpasswords.ui.theme.peachColors83import com.peachpasswords.ui.theme.PeachType84import com.peachpasswords.vault.TOTPGenerator85import kotlinx.coroutines.coroutineScope86import kotlinx.coroutines.isActive87import kotlinx.coroutines.launch8889/**90 * Accent-tinted TOTP band with double-tap-to-edit of the underlying 2FA secret.91 *92 * View mode is a liquid countdown card: the accent outline drains smoothly around93 * the 30-second period, then gently refills with a subtle edge-only flash. A 14dp94 * pencil sits beside the code, and a double-tap anywhere on the code/band edits it.95 *96 * Edit mode shows a masked [BasicTextField] (eye toggle to reveal) seeded with the97 * raw `totp` field (a base32 secret or an `otpauth://` URI), labelled "2FA SECRET",98 * over a ✓ save / ✗ cancel row. The draft is validated with99 * [TOTPGenerator.generateCode]; an empty / unparseable secret shows100 * "Invalid 2FA secret" in breach color and blocks save.101 *102 * On a valid save [onSaveSecret] is invoked with the trimmed new secret — the caller103 * persists via `vm.updateEntry(entry.copy(totp = newValue))`. Because [totpField]104 * flows back in from the entry StateFlow, the band regenerates live with no extra105 * wiring.106 *107 * @param totpField the current raw `totp` value (base32 secret or otpauth URI).108 * @param copied    copy-latch for this entry's TOTP (drives the copy button feedback).109 * @param onCopy    copies the live code (NOT the secret) — wire to `vm::copyTotp`.110 * @param onSaveSecret called with the trimmed, validated new secret on ✓ save.111 */112@Composable113fun TotpBandEditable(114    totpField: String,115    copied: Boolean,116    onCopy: () -> Unit,117    onSaveSecret: (String) -> Unit,118) {119    val accent = LocalPeachAccent.current120    val reduceMotion = LocalReduceMotion.current121    val surfaceCard = peachColors.SurfaceCard122    val liquidFraction = remember(totpField) { Animatable(1f) }123    val edgeFlash = remember(totpField) { Animatable(0f) }124    LaunchedEffect(totpField, reduceMotion) {125        if (reduceMotion) {126            liquidFraction.snapTo(1f)127            edgeFlash.snapTo(0f)128        } else {129            val periodMillis = TOTPGenerator.PERIOD_SECONDS * 1_000L130            while (isActive) {131                val elapsedMillis = System.currentTimeMillis() % periodMillis132                val remainingMillis = periodMillis - elapsedMillis133                liquidFraction.snapTo(remainingMillis.toFloat() / periodMillis)134                liquidFraction.animateTo(135                    targetValue = 0f,136                    animationSpec = tween(137                        durationMillis = remainingMillis.toInt(),138                        easing = LinearEasing,139                    ),140                )141                edgeFlash.snapTo(1f)142                coroutineScope {143                    launch {144                        liquidFraction.animateTo(145                            targetValue = 1f,146                            animationSpec = tween(700, easing = FastOutSlowInEasing),147                        )148                    }149                    launch {150                        edgeFlash.animateTo(151                            targetValue = 0f,152                            animationSpec = tween(900, easing = FastOutSlowInEasing),153                        )154                    }155                }156            }157        }158    }159160    var editing by remember(totpField) { mutableStateOf(false) }161    val shape = RoundedCornerShape(8.dp)162163    Box(164        Modifier165            .fillMaxWidth()166            .padding(vertical = 10.dp)167            .clip(shape)168            .drawBehind {169                val radius = 8.dp.toPx()170                val baseStroke = 1.dp.toPx()171                val ringPath = Path().apply {172                    addRoundRect(RoundRect(Rect(Offset.Zero, size), radius, radius))173                }174                val accentWidth = (size.width * liquidFraction.value).coerceAtLeast(1f)175                drawRoundRect(176                    color = surfaceCard,177                    cornerRadius = CornerRadius(radius),178                )179                drawRoundRect(180                    color = accent.copy(alpha = 0.16f),181                    cornerRadius = CornerRadius(radius),182                    style = Stroke(width = baseStroke),183                )184                drawRoundRect(185                    color = accent.copy(alpha = 0.74f * liquidFraction.value + edgeFlash.value * 0.12f),186                    cornerRadius = CornerRadius(radius),187                    style = Stroke(width = 1.5.dp.toPx()),188                )189                drawPath(190                    ringPath,191                    brush = Brush.horizontalGradient(192                        colorStops = arrayOf(193                            0f to accent.copy(alpha = 0.74f + edgeFlash.value * 0.12f),194                            0.82f to accent.copy(alpha = 0.74f + edgeFlash.value * 0.12f),195                            1f to Color.Transparent,196                        ),197                        startX = 0f,198                        endX = accentWidth,199                    ),200                    style = Stroke(201                        width = 1.5.dp.toPx(),202                    ),203                )204                if (edgeFlash.value > 0f) {205                    drawPath(206                        ringPath,207                        color = accent.copy(alpha = edgeFlash.value * 0.16f),208                        style = Stroke(width = 4.dp.toPx()),209                    )210                }211            }212            .padding(horizontal = 14.dp, vertical = 10.dp),213    ) {214        if (!editing) {215            TotpView(216                totpField = totpField,217                accent = accent,218                reduceMotion = reduceMotion,219                copied = copied,220                onCopy = onCopy,221                onEnterEdit = { editing = true },222            )223        } else {224            TotpSecretEditor(225                initial = totpField,226                onSave = { newSecret -> onSaveSecret(newSecret); editing = false },227                onCancel = { editing = false },228            )229        }230    }231}232233/** Read-only band content: code, countdown, pencil, copy — plus double-tap-to-edit. */234// produceState assigns `value` inside a while(true) loop — lint can't trace it statically.235@Suppress("ProduceStateDoesNotAssignValue")236@Composable237private fun TotpView(238    totpField: String,239    accent: androidx.compose.ui.graphics.Color,240    reduceMotion: Boolean,241    copied: Boolean,242    onCopy: () -> Unit,243    onEnterEdit: () -> Unit,244) {245    val code by produceState(TOTPGenerator.generateCode(totpField) ?: "------", totpField) {246        while (true) {247            value = TOTPGenerator.generateCode(totpField) ?: "------"248            kotlinx.coroutines.delay(1000)249        }250    }251    val remaining by produceState(TOTPGenerator.remainingSeconds()) {252        while (true) { value = TOTPGenerator.remainingSeconds(); kotlinx.coroutines.delay(500) }253    }254    val isHot = remaining <= 5255    val pulse = rememberInfiniteTransition(label = "totp-pulse")256    val pulseAlpha by pulse.animateFloat(257        initialValue = 0.55f, targetValue = 1f,258        animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "totp-pulse-alpha",259    )260    val codeAlpha = if (isHot && !reduceMotion) pulseAlpha else 1f261262    Row(verticalAlignment = Alignment.CenterVertically) {263        Column(264            Modifier265                .weight(1f)266                // Double-tap the code area to edit the 2FA secret (E6).267                .pointerInput(Unit) { detectTapGestures(onDoubleTap = { onEnterEdit() }) },268        ) {269            Row(verticalAlignment = Alignment.CenterVertically) {270                Text(271                    "ONE-TIME CODE",272                    color = accent.copy(alpha = 0.70f),273                    style = PeachType.labelMono.copy(letterSpacing = 0.08.em),274                )275                Spacer(Modifier.width(6.dp))276                Icon(277                    Icons.Outlined.Edit,278                    contentDescription = "edit 2FA secret",279                    tint = peachColors.T2,280                    modifier = Modifier281                        .size(14.dp)282                        .clip(RoundedCornerShape(4.dp))283                        .clickable(onClick = onEnterEdit),284                )285            }286            val spaced = if (code.length == 6) "${code.substring(0, 3)} ${code.substring(3)}" else code287            Text(288                spaced,289                color = if (isHot) peachColors.Breach else accent,290                style = PeachType.totpCode,291                modifier = Modifier.graphicsLayer { alpha = codeAlpha },292            )293        }294        Text("${remaining}s", color = peachColors.T2, style = PeachType.totpCountdown)295        Spacer(Modifier.width(12.dp))296        CopyButton(copied, onCopy)297    }298}299300/** Inline 2FA-secret editor: masked underlined field + eye toggle + ✓/✗, with validation. */301@Composable302private fun TotpSecretEditor(303    initial: String,304    onSave: (String) -> Unit,305    onCancel: () -> Unit,306) {307    val accent = LocalPeachAccent.current308    var draft by remember { mutableStateOf(initial) }309    var show by remember { mutableStateOf(false) }310    var showError by remember { mutableStateOf(false) }311312    val trimmed = draft.trim()313    val valid = TOTPGenerator.generateCode(trimmed) != null314315    Column(Modifier.fillMaxWidth()) {316        Text(317            "2FA SECRET",318            color = accent.copy(alpha = 0.70f),319            style = PeachType.labelMono.copy(letterSpacing = 0.08.em),320        )321        Spacer(Modifier.height(8.dp))322        Row(verticalAlignment = Alignment.CenterVertically) {323            E6UnderlinedField(324                draft = draft,325                onDraftChange = { draft = it; showError = false },326                transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),327                modifier = Modifier.weight(1f),328            )329            Text(330                if (show) "hide" else "show", color = accent, style = PeachType.labelMono,331                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),332            )333        }334        if (showError) {335            Spacer(Modifier.height(6.dp))336            Text("Invalid 2FA secret", color = peachColors.Breach, style = PeachType.labelMono)337        }338        Spacer(Modifier.height(8.dp))339        E6SaveCancelRow(340            onSave = {341                if (valid) onSave(trimmed) else showError = true342            },343            onCancel = { onCancel() },344        )345    }346}347348/** BasicTextField with an accent underline (mirrors D6's inline-edit field). */349@Composable350private fun E6UnderlinedField(351    draft: String,352    onDraftChange: (String) -> Unit,353    transformation: VisualTransformation = VisualTransformation.None,354    modifier: Modifier = Modifier,355) {356    val accent = LocalPeachAccent.current357    BasicTextField(358        value = draft,359        onValueChange = onDraftChange,360        singleLine = true,361        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),362        cursorBrush = SolidColor(accent),363        visualTransformation = transformation,364        modifier = modifier365            .padding(bottom = 4.dp)366            .drawBehind {367                drawLine(368                    color = accent,369                    start = Offset(0f, size.height),370                    end = Offset(size.width, size.height),371                    strokeWidth = 1.dp.toPx(),372                )373            },374    )375}376377@Composable378private fun E6SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {379    val accent = LocalPeachAccent.current380    Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {381        Text(382            "✓ save", color = accent, style = PeachType.labelMono,383            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp),384        )385        Text(386            "✗", color = peachColors.T2, style = PeachType.labelMono,387            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp),388        )389    }390}