1package com.peachpasswords.ui.vault23456789101112131415161718192021222324252627import 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.launch919293949596979899100101102103104105106107108109110@param111@param112@param113@param114115@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}236237238239@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 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}303304305@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}351352353@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}