1package com.peachpasswords.ui.vault23456789101112131415161718192021222324252627import androidx.compose.animation.core.RepeatMode28import androidx.compose.animation.core.animateFloat29import androidx.compose.animation.core.animateFloatAsState30import androidx.compose.animation.core.infiniteRepeatable31import androidx.compose.animation.core.rememberInfiniteTransition32import androidx.compose.animation.core.tween33import androidx.compose.foundation.border34import 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.getValue54import androidx.compose.runtime.mutableStateOf55import androidx.compose.runtime.produceState56import androidx.compose.runtime.remember57import androidx.compose.runtime.setValue58import androidx.compose.ui.Alignment59import androidx.compose.ui.Modifier60import androidx.compose.ui.draw.clip61import androidx.compose.ui.draw.drawBehind62import androidx.compose.ui.geometry.Offset63import androidx.compose.ui.geometry.Size64import androidx.compose.ui.graphics.SolidColor65import androidx.compose.ui.graphics.graphicsLayer66import androidx.compose.ui.input.pointer.pointerInput67import androidx.compose.ui.text.input.PasswordVisualTransformation68import androidx.compose.ui.text.input.VisualTransformation69import androidx.compose.ui.unit.dp70import androidx.compose.ui.unit.em71import com.peachpasswords.ui.components.CopyButton72import com.peachpasswords.ui.theme.LocalPeachAccent73import com.peachpasswords.ui.theme.LocalReduceMotion74import com.peachpasswords.ui.theme.peachColors75import com.peachpasswords.ui.theme.PeachType76import com.peachpasswords.vault.TOTPGenerator7778798081828384858687888990919293949596@param97@param98@param99@param100101@Suppress("ProduceStateDoesNotAssignValue")102@Composable103fun TotpBandEditable(104 totpField: String,105 copied: Boolean,106 onCopy: () -> Unit,107 onSaveSecret: (String) -> Unit,108) {109 val accent = LocalPeachAccent.current110 val reduceMotion = LocalReduceMotion.current111 val surfaceCard = peachColors.SurfaceCard112 val liquidFraction by produceState(1f, totpField) {113 while (true) {114 value = 1f - TOTPGenerator.periodProgress()115 kotlinx.coroutines.delay(100)116 }117 }118 val animatedLiquidFraction by animateFloatAsState(119 targetValue = liquidFraction,120 animationSpec = tween(if (liquidFraction > 0.9f) 450 else 120),121 label = "totp-liquid",122 )123124 var editing by remember(totpField) { mutableStateOf(false) }125 val shape = RoundedCornerShape(8.dp)126127 Box(128 Modifier129 .fillMaxWidth()130 .padding(vertical = 10.dp)131 .clip(shape)132 .drawBehind {133 drawRect(surfaceCard)134 drawRect(135 color = accent.copy(alpha = 0.16f),136 size = Size(size.width * animatedLiquidFraction, size.height),137 )138 }139 .border(1.dp, accent.copy(alpha = 0.20f), shape)140 .padding(horizontal = 14.dp, vertical = 10.dp),141 ) {142 if (!editing) {143 TotpView(144 totpField = totpField,145 accent = accent,146 reduceMotion = reduceMotion,147 copied = copied,148 onCopy = onCopy,149 onEnterEdit = { editing = true },150 )151 } else {152 TotpSecretEditor(153 initial = totpField,154 onSave = { newSecret -> onSaveSecret(newSecret); editing = false },155 onCancel = { editing = false },156 )157 }158 }159}160161162163@Suppress("ProduceStateDoesNotAssignValue")164@Composable165private fun TotpView(166 totpField: String,167 accent: androidx.compose.ui.graphics.Color,168 reduceMotion: Boolean,169 copied: Boolean,170 onCopy: () -> Unit,171 onEnterEdit: () -> Unit,172) {173 val code by produceState(TOTPGenerator.generateCode(totpField) ?: "------", totpField) {174 while (true) {175 value = TOTPGenerator.generateCode(totpField) ?: "------"176 kotlinx.coroutines.delay(1000)177 }178 }179 val remaining by produceState(TOTPGenerator.remainingSeconds()) {180 while (true) { value = TOTPGenerator.remainingSeconds(); kotlinx.coroutines.delay(500) }181 }182 val isHot = remaining <= 5183 val pulse = rememberInfiniteTransition(label = "totp-pulse")184 val pulseAlpha by pulse.animateFloat(185 initialValue = 0.55f, targetValue = 1f,186 animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "totp-pulse-alpha",187 )188 val codeAlpha = if (isHot && !reduceMotion) pulseAlpha else 1f189190 Row(verticalAlignment = Alignment.CenterVertically) {191 Column(192 Modifier193 .weight(1f)194 195 .pointerInput(Unit) { detectTapGestures(onDoubleTap = { onEnterEdit() }) },196 ) {197 Row(verticalAlignment = Alignment.CenterVertically) {198 Text(199 "ONE-TIME CODE",200 color = accent.copy(alpha = 0.70f),201 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),202 )203 Spacer(Modifier.width(6.dp))204 Icon(205 Icons.Outlined.Edit,206 contentDescription = "edit 2FA secret",207 tint = peachColors.T2,208 modifier = Modifier209 .size(14.dp)210 .clip(RoundedCornerShape(4.dp))211 .clickable(onClick = onEnterEdit),212 )213 }214 val spaced = if (code.length == 6) "${code.substring(0, 3)} ${code.substring(3)}" else code215 Text(216 spaced,217 color = if (isHot) peachColors.Breach else accent,218 style = PeachType.totpCode,219 modifier = Modifier.graphicsLayer { alpha = codeAlpha },220 )221 }222 Text("${remaining}s", color = peachColors.T2, style = PeachType.totpCountdown)223 Spacer(Modifier.width(12.dp))224 CopyButton(copied, onCopy)225 }226}227228229@Composable230private fun TotpSecretEditor(231 initial: String,232 onSave: (String) -> Unit,233 onCancel: () -> Unit,234) {235 val accent = LocalPeachAccent.current236 var draft by remember { mutableStateOf(initial) }237 var show by remember { mutableStateOf(false) }238 var showError by remember { mutableStateOf(false) }239240 val trimmed = draft.trim()241 val valid = TOTPGenerator.generateCode(trimmed) != null242243 Column(Modifier.fillMaxWidth()) {244 Text(245 "2FA SECRET",246 color = accent.copy(alpha = 0.70f),247 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),248 )249 Spacer(Modifier.height(8.dp))250 Row(verticalAlignment = Alignment.CenterVertically) {251 E6UnderlinedField(252 draft = draft,253 onDraftChange = { draft = it; showError = false },254 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),255 modifier = Modifier.weight(1f),256 )257 Text(258 if (show) "hide" else "show", color = accent, style = PeachType.labelMono,259 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),260 )261 }262 if (showError) {263 Spacer(Modifier.height(6.dp))264 Text("Invalid 2FA secret", color = peachColors.Breach, style = PeachType.labelMono)265 }266 Spacer(Modifier.height(8.dp))267 E6SaveCancelRow(268 onSave = {269 if (valid) onSave(trimmed) else showError = true270 },271 onCancel = { onCancel() },272 )273 }274}275276277@Composable278private fun E6UnderlinedField(279 draft: String,280 onDraftChange: (String) -> Unit,281 transformation: VisualTransformation = VisualTransformation.None,282 modifier: Modifier = Modifier,283) {284 val accent = LocalPeachAccent.current285 BasicTextField(286 value = draft,287 onValueChange = onDraftChange,288 singleLine = true,289 textStyle = PeachType.bodyMono.copy(color = peachColors.T0),290 cursorBrush = SolidColor(accent),291 visualTransformation = transformation,292 modifier = modifier293 .padding(bottom = 4.dp)294 .drawBehind {295 drawLine(296 color = accent,297 start = Offset(0f, size.height),298 end = Offset(size.width, size.height),299 strokeWidth = 1.dp.toPx(),300 )301 },302 )303}304305@Composable306private fun E6SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {307 val accent = LocalPeachAccent.current308 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {309 Text(310 "✓ save", color = accent, style = PeachType.labelMono,311 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp),312 )313 Text(314 "✗", color = peachColors.T2, style = PeachType.labelMono,315 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp),316 )317 }318}