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.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.launch888990919293949596979899100101102103104105106107@param108@param109@param110@param111112@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}232233234235@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 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}299300301@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}347348349@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}