package com.peachpasswords.ui.vault /* * E6 — TOTP double-tap to edit the 2FA secret, inline in EntryDetailScreen. * * Self-contained drop-in composable. D6 / D7 / E6 all touch EntryDetailScreen.kt, * so this stays isolated with merge anchors documented in INTEGRATION.md. * * Provides: * - TotpBandEditable : a TOTP band that double-taps (or taps a 14dp pencil) into * an inline 2FA-secret editor — masked BasicTextField with an * eye toggle and a ✓ save / ✗ cancel row (mirrors the D6 * inline-edit visual pattern). On save it calls back with the * new secret; live regeneration happens automatically because * the band reads the field flowing in from the VM/entry. * * It deliberately does NOT reuse the private FieldShell / CopyButton helpers in * EntryDetailScreen.kt by name in a way that would force them to change visibility — * the band has its own accent-tinted chrome (matching the existing TotpBand), and a * local copy button. The only EntryDetailScreen helper it depends on at merge time * is the existing `CopyButton`, which already lives in the same file; if the * orchestrator instead drops this into a separate file, make `CopyButton` `internal` * (see INTEGRATION.md). All private helpers here are E6-prefixed to avoid colliding * with D6's identically-shaped UnderlinedField / SaveCancelRow. */ import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.PaintingStyle import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import com.peachpasswords.ui.components.CopyButton import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.LocalReduceMotion import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.vault.TOTPGenerator import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** * Accent-tinted TOTP band with double-tap-to-edit of the underlying 2FA secret. * * View mode is a liquid countdown card: the accent outline drains smoothly around * the 30-second period, then gently refills with a subtle edge-only flash. A 14dp * pencil sits beside the code, and a double-tap anywhere on the code/band edits it. * * Edit mode shows a masked [BasicTextField] (eye toggle to reveal) seeded with the * raw `totp` field (a base32 secret or an `otpauth://` URI), labelled "2FA SECRET", * over a ✓ save / ✗ cancel row. The draft is validated with * [TOTPGenerator.generateCode]; an empty / unparseable secret shows * "Invalid 2FA secret" in breach color and blocks save. * * On a valid save [onSaveSecret] is invoked with the trimmed new secret — the caller * persists via `vm.updateEntry(entry.copy(totp = newValue))`. Because [totpField] * flows back in from the entry StateFlow, the band regenerates live with no extra * wiring. * * @param totpField the current raw `totp` value (base32 secret or otpauth URI). * @param copied copy-latch for this entry's TOTP (drives the copy button feedback). * @param onCopy copies the live code (NOT the secret) — wire to `vm::copyTotp`. * @param onSaveSecret called with the trimmed, validated new secret on ✓ save. */ @Composable fun TotpBandEditable( totpField: String, copied: Boolean, onCopy: () -> Unit, onSaveSecret: (String) -> Unit, ) { val accent = LocalPeachAccent.current val reduceMotion = LocalReduceMotion.current val surfaceCard = peachColors.SurfaceCard val liquidFraction = remember(totpField) { Animatable(1f) } val edgeFlash = remember(totpField) { Animatable(0f) } LaunchedEffect(totpField, reduceMotion) { if (reduceMotion) { liquidFraction.snapTo(1f) edgeFlash.snapTo(0f) } else { val periodMillis = TOTPGenerator.PERIOD_SECONDS * 1_000L while (isActive) { val elapsedMillis = System.currentTimeMillis() % periodMillis val remainingMillis = periodMillis - elapsedMillis liquidFraction.snapTo(remainingMillis.toFloat() / periodMillis) liquidFraction.animateTo( targetValue = 0f, animationSpec = tween( durationMillis = remainingMillis.toInt(), easing = LinearEasing, ), ) edgeFlash.snapTo(1f) coroutineScope { launch { liquidFraction.animateTo( targetValue = 1f, animationSpec = tween(700, easing = FastOutSlowInEasing), ) } launch { edgeFlash.animateTo( targetValue = 0f, animationSpec = tween(900, easing = FastOutSlowInEasing), ) } } } } } var editing by remember(totpField) { mutableStateOf(false) } val shape = RoundedCornerShape(8.dp) Box( Modifier .fillMaxWidth() .padding(vertical = 10.dp) .clip(shape) .drawBehind { val radius = 8.dp.toPx() val perimeter = 2f * (size.width - radius * 2f) + 2f * (size.height - radius * 2f) + (2f * Math.PI.toFloat() * radius) val baseStroke = 1.dp.toPx() val ringPath = Path().apply { addRoundRect(RoundRect(Rect(Offset.Zero, size), radius, radius)) } drawRoundRect( color = surfaceCard, cornerRadius = CornerRadius(radius), ) drawRoundRect( color = accent.copy(alpha = 0.16f), cornerRadius = CornerRadius(radius), style = Stroke(width = baseStroke), ) drawIntoCanvas { canvas -> canvas.drawPath( ringPath, Paint().apply { color = accent.copy(alpha = 0.74f + edgeFlash.value * 0.12f) style = PaintingStyle.Stroke strokeWidth = 1.5.dp.toPx() strokeCap = StrokeCap.Round pathEffect = PathEffect.dashPathEffect( floatArrayOf((perimeter * liquidFraction.value).coerceAtLeast(0.1f), perimeter), ) }, ) } if (edgeFlash.value > 0f) { drawIntoCanvas { canvas -> canvas.drawPath( ringPath, Paint().apply { color = accent.copy(alpha = edgeFlash.value * 0.16f) style = PaintingStyle.Stroke strokeWidth = 4.dp.toPx() }, ) } } } .padding(horizontal = 14.dp, vertical = 10.dp), ) { if (!editing) { TotpView( totpField = totpField, accent = accent, reduceMotion = reduceMotion, copied = copied, onCopy = onCopy, onEnterEdit = { editing = true }, ) } else { TotpSecretEditor( initial = totpField, onSave = { newSecret -> onSaveSecret(newSecret); editing = false }, onCancel = { editing = false }, ) } } } /** Read-only band content: code, countdown, pencil, copy — plus double-tap-to-edit. */ // produceState assigns `value` inside a while(true) loop — lint can't trace it statically. @Suppress("ProduceStateDoesNotAssignValue") @Composable private fun TotpView( totpField: String, accent: androidx.compose.ui.graphics.Color, reduceMotion: Boolean, copied: Boolean, onCopy: () -> Unit, onEnterEdit: () -> Unit, ) { val code by produceState(TOTPGenerator.generateCode(totpField) ?: "------", totpField) { while (true) { value = TOTPGenerator.generateCode(totpField) ?: "------" kotlinx.coroutines.delay(1000) } } val remaining by produceState(TOTPGenerator.remainingSeconds()) { while (true) { value = TOTPGenerator.remainingSeconds(); kotlinx.coroutines.delay(500) } } val isHot = remaining <= 5 val pulse = rememberInfiniteTransition(label = "totp-pulse") val pulseAlpha by pulse.animateFloat( initialValue = 0.55f, targetValue = 1f, animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "totp-pulse-alpha", ) val codeAlpha = if (isHot && !reduceMotion) pulseAlpha else 1f Row(verticalAlignment = Alignment.CenterVertically) { Column( Modifier .weight(1f) // Double-tap the code area to edit the 2FA secret (E6). .pointerInput(Unit) { detectTapGestures(onDoubleTap = { onEnterEdit() }) }, ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( "ONE-TIME CODE", color = accent.copy(alpha = 0.70f), style = PeachType.labelMono.copy(letterSpacing = 0.08.em), ) Spacer(Modifier.width(6.dp)) Icon( Icons.Outlined.Edit, contentDescription = "edit 2FA secret", tint = peachColors.T2, modifier = Modifier .size(14.dp) .clip(RoundedCornerShape(4.dp)) .clickable(onClick = onEnterEdit), ) } val spaced = if (code.length == 6) "${code.substring(0, 3)} ${code.substring(3)}" else code Text( spaced, color = if (isHot) peachColors.Breach else accent, style = PeachType.totpCode, modifier = Modifier.graphicsLayer { alpha = codeAlpha }, ) } Text("${remaining}s", color = peachColors.T2, style = PeachType.totpCountdown) Spacer(Modifier.width(12.dp)) CopyButton(copied, onCopy) } } /** Inline 2FA-secret editor: masked underlined field + eye toggle + ✓/✗, with validation. */ @Composable private fun TotpSecretEditor( initial: String, onSave: (String) -> Unit, onCancel: () -> Unit, ) { val accent = LocalPeachAccent.current var draft by remember { mutableStateOf(initial) } var show by remember { mutableStateOf(false) } var showError by remember { mutableStateOf(false) } val trimmed = draft.trim() val valid = TOTPGenerator.generateCode(trimmed) != null Column(Modifier.fillMaxWidth()) { Text( "2FA SECRET", color = accent.copy(alpha = 0.70f), style = PeachType.labelMono.copy(letterSpacing = 0.08.em), ) Spacer(Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { E6UnderlinedField( draft = draft, onDraftChange = { draft = it; showError = false }, transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(), modifier = Modifier.weight(1f), ) Text( if (show) "hide" else "show", color = accent, style = PeachType.labelMono, modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp), ) } if (showError) { Spacer(Modifier.height(6.dp)) Text("Invalid 2FA secret", color = peachColors.Breach, style = PeachType.labelMono) } Spacer(Modifier.height(8.dp)) E6SaveCancelRow( onSave = { if (valid) onSave(trimmed) else showError = true }, onCancel = { onCancel() }, ) } } /** BasicTextField with an accent underline (mirrors D6's inline-edit field). */ @Composable private fun E6UnderlinedField( draft: String, onDraftChange: (String) -> Unit, transformation: VisualTransformation = VisualTransformation.None, modifier: Modifier = Modifier, ) { val accent = LocalPeachAccent.current BasicTextField( value = draft, onValueChange = onDraftChange, singleLine = true, textStyle = PeachType.bodyMono.copy(color = peachColors.T0), cursorBrush = SolidColor(accent), visualTransformation = transformation, modifier = modifier .padding(bottom = 4.dp) .drawBehind { drawLine( color = accent, start = Offset(0f, size.height), end = Offset(size.width, size.height), strokeWidth = 1.dp.toPx(), ) }, ) } @Composable private fun E6SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) { val accent = LocalPeachAccent.current Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { Text( "✓ save", color = accent, style = PeachType.labelMono, modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp), ) Text( "✗", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp), ) } }