1package com.peachpasswords.ui.vault23456789101112131415161718192021222324252627import androidx.compose.animation.core.RepeatMode28import androidx.compose.animation.core.Animatable29import androidx.compose.animation.core.FastOutSlowInEasing30import androidx.compose.animation.core.animateFloat31import androidx.compose.animation.core.infiniteRepeatable32import androidx.compose.animation.core.rememberInfiniteTransition33import androidx.compose.animation.core.tween34import 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.LaunchedEffect54import androidx.compose.runtime.getValue55import androidx.compose.runtime.mutableStateOf56import androidx.compose.runtime.produceState57import androidx.compose.runtime.remember58import androidx.compose.runtime.setValue59import androidx.compose.ui.Alignment60import androidx.compose.ui.Modifier61import androidx.compose.ui.draw.clip62import androidx.compose.ui.draw.drawBehind63import androidx.compose.ui.geometry.CornerRadius64import androidx.compose.ui.geometry.Offset65import androidx.compose.ui.graphics.Brush66import androidx.compose.ui.graphics.Color67import androidx.compose.ui.graphics.SolidColor68import androidx.compose.ui.graphics.drawscope.Stroke69import androidx.compose.ui.graphics.graphicsLayer70import androidx.compose.ui.input.pointer.pointerInput71import androidx.compose.ui.text.input.PasswordVisualTransformation72import androidx.compose.ui.text.input.VisualTransformation73import androidx.compose.ui.unit.dp74import androidx.compose.ui.unit.em75import com.peachpasswords.ui.components.CopyButton76import com.peachpasswords.ui.theme.LocalPeachAccent77import com.peachpasswords.ui.theme.LocalReduceMotion78import com.peachpasswords.ui.theme.peachColors79import com.peachpasswords.ui.theme.PeachType80import com.peachpasswords.vault.TOTPGenerator81import kotlinx.coroutines.isActive82import kotlinx.coroutines.launch8384858687888990919293949596979899100101102@param103@param104@param105@param106107@Composable108fun TotpBandEditable(109 totpField: String,110 copied: Boolean,111 onCopy: () -> Unit,112 onSaveSecret: (String) -> Unit,113) {114 val accent = LocalPeachAccent.current115 val reduceMotion = LocalReduceMotion.current116 val surfaceCard = peachColors.SurfaceCard117 val liquidFraction = remember(totpField) { androidx.compose.runtime.mutableFloatStateOf(1f) }118 val edgeFlash = remember(totpField) { Animatable(0f) }119 LaunchedEffect(totpField, reduceMotion) {120 if (reduceMotion) {121 liquidFraction.floatValue = 1f122 edgeFlash.snapTo(0f)123 } else {124 val periodMillis = TOTPGenerator.PERIOD_SECONDS * 1_000L125 var previousElapsedMillis = -1L126 while (isActive) {127 val elapsedMillis = Math.floorMod(System.currentTimeMillis(), periodMillis)128 liquidFraction.floatValue =129 ((periodMillis - elapsedMillis).toFloat() / periodMillis).coerceIn(0f, 1f)130 if (previousElapsedMillis >= 0L && elapsedMillis < previousElapsedMillis) {131 launch {132 edgeFlash.snapTo(1f)133 edgeFlash.animateTo(134 targetValue = 0f,135 animationSpec = tween(900, easing = FastOutSlowInEasing),136 )137 }138 }139 previousElapsedMillis = elapsedMillis140 kotlinx.coroutines.delay(16L)141 }142 }143 }144145 var editing by remember(totpField) { mutableStateOf(false) }146 val shape = RoundedCornerShape(8.dp)147148 Box(149 Modifier150 .fillMaxWidth()151 .padding(vertical = 10.dp)152 .clip(shape)153 .drawBehind {154 val radius = 8.dp.toPx()155 val baseStroke = 1.dp.toPx()156 val fraction = liquidFraction.floatValue157 val accentWidth = (size.width * fraction).coerceAtLeast(1f)158 drawRoundRect(159 color = surfaceCard,160 cornerRadius = CornerRadius(radius),161 )162 drawRoundRect(163 color = accent.copy(alpha = 0.16f),164 cornerRadius = CornerRadius(radius),165 style = Stroke(width = baseStroke),166 )167 drawRoundRect(168 brush = Brush.horizontalGradient(169 colors = listOf(170 accent.copy(alpha = 0.92f + edgeFlash.value * 0.08f),171 accent.copy(alpha = 0.92f + edgeFlash.value * 0.08f),172 Color.Transparent,173 ),174 startX = 0f,175 endX = accentWidth,176 ),177 cornerRadius = CornerRadius(radius),178 style = Stroke(width = 2.dp.toPx()),179 )180 if (edgeFlash.value > 0f) {181 drawRoundRect(182 color = accent.copy(alpha = edgeFlash.value * 0.16f),183 cornerRadius = CornerRadius(radius),184 style = Stroke(width = 4.dp.toPx()),185 )186 }187 }188 .padding(horizontal = 14.dp, vertical = 10.dp),189 ) {190 if (!editing) {191 TotpView(192 totpField = totpField,193 accent = accent,194 reduceMotion = reduceMotion,195 copied = copied,196 onCopy = onCopy,197 onEnterEdit = { editing = true },198 )199 } else {200 TotpSecretEditor(201 initial = totpField,202 onSave = { newSecret -> onSaveSecret(newSecret); editing = false },203 onCancel = { editing = false },204 )205 }206 }207}208209210211@Suppress("ProduceStateDoesNotAssignValue")212@Composable213private fun TotpView(214 totpField: String,215 accent: androidx.compose.ui.graphics.Color,216 reduceMotion: Boolean,217 copied: Boolean,218 onCopy: () -> Unit,219 onEnterEdit: () -> Unit,220) {221 val code by produceState(TOTPGenerator.generateCode(totpField) ?: "------", totpField) {222 while (true) {223 value = TOTPGenerator.generateCode(totpField) ?: "------"224 kotlinx.coroutines.delay(1000)225 }226 }227 val remaining by produceState(TOTPGenerator.remainingSeconds()) {228 while (true) { value = TOTPGenerator.remainingSeconds(); kotlinx.coroutines.delay(500) }229 }230 val isHot = remaining <= 5231 val pulse = rememberInfiniteTransition(label = "totp-pulse")232 val pulseAlpha by pulse.animateFloat(233 initialValue = 0.55f, targetValue = 1f,234 animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "totp-pulse-alpha",235 )236 val codeAlpha = if (isHot && !reduceMotion) pulseAlpha else 1f237238 Row(verticalAlignment = Alignment.CenterVertically) {239 Column(240 Modifier241 .weight(1f)242 243 .pointerInput(Unit) { detectTapGestures(onDoubleTap = { onEnterEdit() }) },244 ) {245 Row(verticalAlignment = Alignment.CenterVertically) {246 Text(247 "ONE-TIME CODE",248 color = accent.copy(alpha = 0.70f),249 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),250 )251 Spacer(Modifier.width(6.dp))252 Icon(253 Icons.Outlined.Edit,254 contentDescription = "edit 2FA secret",255 tint = peachColors.T2,256 modifier = Modifier257 .size(14.dp)258 .clip(RoundedCornerShape(4.dp))259 .clickable(onClick = onEnterEdit),260 )261 }262 val spaced = if (code.length == 6) "${code.substring(0, 3)} ${code.substring(3)}" else code263 Text(264 spaced,265 color = if (isHot) peachColors.Breach else accent,266 style = PeachType.totpCode,267 modifier = Modifier.graphicsLayer { alpha = codeAlpha },268 )269 }270 Text("${remaining}s", color = peachColors.T2, style = PeachType.totpCountdown)271 Spacer(Modifier.width(12.dp))272 CopyButton(copied, onCopy)273 }274}275276277@Composable278private fun TotpSecretEditor(279 initial: String,280 onSave: (String) -> Unit,281 onCancel: () -> Unit,282) {283 val accent = LocalPeachAccent.current284 var draft by remember { mutableStateOf(initial) }285 var show by remember { mutableStateOf(false) }286 var showError by remember { mutableStateOf(false) }287288 val trimmed = draft.trim()289 val valid = TOTPGenerator.generateCode(trimmed) != null290291 Column(Modifier.fillMaxWidth()) {292 Text(293 "2FA SECRET",294 color = accent.copy(alpha = 0.70f),295 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),296 )297 Spacer(Modifier.height(8.dp))298 Row(verticalAlignment = Alignment.CenterVertically) {299 E6UnderlinedField(300 draft = draft,301 onDraftChange = { draft = it; showError = false },302 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),303 modifier = Modifier.weight(1f),304 )305 Text(306 if (show) "hide" else "show", color = accent, style = PeachType.labelMono,307 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),308 )309 }310 if (showError) {311 Spacer(Modifier.height(6.dp))312 Text("Invalid 2FA secret", color = peachColors.Breach, style = PeachType.labelMono)313 }314 Spacer(Modifier.height(8.dp))315 E6SaveCancelRow(316 onSave = {317 if (valid) onSave(trimmed) else showError = true318 },319 onCancel = { onCancel() },320 )321 }322}323324325@Composable326private fun E6UnderlinedField(327 draft: String,328 onDraftChange: (String) -> Unit,329 transformation: VisualTransformation = VisualTransformation.None,330 modifier: Modifier = Modifier,331) {332 val accent = LocalPeachAccent.current333 BasicTextField(334 value = draft,335 onValueChange = onDraftChange,336 singleLine = true,337 textStyle = PeachType.bodyMono.copy(color = peachColors.T0),338 cursorBrush = SolidColor(accent),339 visualTransformation = transformation,340 modifier = modifier341 .padding(bottom = 4.dp)342 .drawBehind {343 drawLine(344 color = accent,345 start = Offset(0f, size.height),346 end = Offset(size.width, size.height),347 strokeWidth = 1.dp.toPx(),348 )349 },350 )351}352353@Composable354private fun E6SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {355 val accent = LocalPeachAccent.current356 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {357 Text(358 "✓ save", color = accent, style = PeachType.labelMono,359 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp),360 )361 Text(362 "✗", color = peachColors.T2, style = PeachType.labelMono,363 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp),364 )365 }366}