1package com.peachpasswords.ui.vault23456789101112131415161718192021222324252627import androidx.compose.animation.core.RepeatMode28import androidx.compose.animation.core.animateFloat29import androidx.compose.animation.core.infiniteRepeatable30import androidx.compose.animation.core.rememberInfiniteTransition31import androidx.compose.animation.core.tween32import androidx.compose.foundation.background33import 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.graphics.SolidColor64import androidx.compose.ui.graphics.graphicsLayer65import androidx.compose.ui.input.pointer.pointerInput66import androidx.compose.ui.text.input.PasswordVisualTransformation67import androidx.compose.ui.text.input.VisualTransformation68import androidx.compose.ui.unit.dp69import androidx.compose.ui.unit.em70import com.peachpasswords.ui.components.CopyButton71import com.peachpasswords.ui.components.TOTPRing72import 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@Composable102fun TotpBandEditable(103 totpField: String,104 copied: Boolean,105 onCopy: () -> Unit,106 onSaveSecret: (String) -> Unit,107) {108 val accent = LocalPeachAccent.current109 val reduceMotion = LocalReduceMotion.current110111 var editing by remember(totpField) { mutableStateOf(false) }112113 Box(114 Modifier115 .fillMaxWidth()116 .padding(vertical = 10.dp)117 .clip(RoundedCornerShape(8.dp))118 .background(accent.copy(alpha = 0.08f))119 .border(1.dp, accent.copy(alpha = 0.20f), RoundedCornerShape(8.dp))120 .padding(horizontal = 14.dp, vertical = 10.dp),121 ) {122 if (!editing) {123 TotpView(124 totpField = totpField,125 accent = accent,126 reduceMotion = reduceMotion,127 copied = copied,128 onCopy = onCopy,129 onEnterEdit = { editing = true },130 )131 } else {132 TotpSecretEditor(133 initial = totpField,134 onSave = { newSecret -> onSaveSecret(newSecret); editing = false },135 onCancel = { editing = false },136 )137 }138 }139}140141142143@Suppress("ProduceStateDoesNotAssignValue")144@Composable145private fun TotpView(146 totpField: String,147 accent: androidx.compose.ui.graphics.Color,148 reduceMotion: Boolean,149 copied: Boolean,150 onCopy: () -> Unit,151 onEnterEdit: () -> Unit,152) {153 val code by produceState(TOTPGenerator.generateCode(totpField) ?: "------", totpField) {154 while (true) {155 value = TOTPGenerator.generateCode(totpField) ?: "------"156 kotlinx.coroutines.delay(1000)157 }158 }159 val remaining by produceState(TOTPGenerator.remainingSeconds()) {160 while (true) { value = TOTPGenerator.remainingSeconds(); kotlinx.coroutines.delay(500) }161 }162 val isHot = remaining <= 5163 val pulse = rememberInfiniteTransition(label = "totp-pulse")164 val pulseAlpha by pulse.animateFloat(165 initialValue = 0.55f, targetValue = 1f,166 animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "totp-pulse-alpha",167 )168 val codeAlpha = if (isHot && !reduceMotion) pulseAlpha else 1f169170 Row(verticalAlignment = Alignment.CenterVertically) {171 Column(172 Modifier173 .weight(1f)174 175 .pointerInput(Unit) { detectTapGestures(onDoubleTap = { onEnterEdit() }) },176 ) {177 Row(verticalAlignment = Alignment.CenterVertically) {178 Text(179 "ONE-TIME CODE",180 color = accent.copy(alpha = 0.70f),181 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),182 )183 Spacer(Modifier.width(6.dp))184 Icon(185 Icons.Outlined.Edit,186 contentDescription = "edit 2FA secret",187 tint = peachColors.T2,188 modifier = Modifier189 .size(14.dp)190 .clip(RoundedCornerShape(4.dp))191 .clickable(onClick = onEnterEdit),192 )193 }194 val spaced = if (code.length == 6) "${code.substring(0, 3)} ${code.substring(3)}" else code195 Text(196 spaced,197 color = if (isHot) peachColors.Breach else accent,198 style = PeachType.totpCode,199 modifier = Modifier.graphicsLayer { alpha = codeAlpha },200 )201 }202 Text("${remaining}s", color = peachColors.T2, style = PeachType.totpCountdown)203 Spacer(Modifier.width(12.dp))204 TOTPRing(size = 28.dp)205 Spacer(Modifier.width(8.dp))206 CopyButton(copied, onCopy)207 }208}209210211@Composable212private fun TotpSecretEditor(213 initial: String,214 onSave: (String) -> Unit,215 onCancel: () -> Unit,216) {217 val accent = LocalPeachAccent.current218 var draft by remember { mutableStateOf(initial) }219 var show by remember { mutableStateOf(false) }220 var showError by remember { mutableStateOf(false) }221222 val trimmed = draft.trim()223 val valid = TOTPGenerator.generateCode(trimmed) != null224225 Column(Modifier.fillMaxWidth()) {226 Text(227 "2FA SECRET",228 color = accent.copy(alpha = 0.70f),229 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),230 )231 Spacer(Modifier.height(8.dp))232 Row(verticalAlignment = Alignment.CenterVertically) {233 E6UnderlinedField(234 draft = draft,235 onDraftChange = { draft = it; showError = false },236 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),237 modifier = Modifier.weight(1f),238 )239 Text(240 if (show) "hide" else "show", color = accent, style = PeachType.labelMono,241 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { show = !show }.padding(6.dp),242 )243 }244 if (showError) {245 Spacer(Modifier.height(6.dp))246 Text("Invalid 2FA secret", color = peachColors.Breach, style = PeachType.labelMono)247 }248 Spacer(Modifier.height(8.dp))249 E6SaveCancelRow(250 onSave = {251 if (valid) onSave(trimmed) else showError = true252 },253 onCancel = { onCancel() },254 )255 }256}257258259@Composable260private fun E6UnderlinedField(261 draft: String,262 onDraftChange: (String) -> Unit,263 transformation: VisualTransformation = VisualTransformation.None,264 modifier: Modifier = Modifier,265) {266 val accent = LocalPeachAccent.current267 BasicTextField(268 value = draft,269 onValueChange = onDraftChange,270 singleLine = true,271 textStyle = PeachType.bodyMono.copy(color = peachColors.T0),272 cursorBrush = SolidColor(accent),273 visualTransformation = transformation,274 modifier = modifier275 .padding(bottom = 4.dp)276 .drawBehind {277 drawLine(278 color = accent,279 start = Offset(0f, size.height),280 end = Offset(size.width, size.height),281 strokeWidth = 1.dp.toPx(),282 )283 },284 )285}286287@Composable288private fun E6SaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {289 val accent = LocalPeachAccent.current290 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {291 Text(292 "✓ save", color = accent, style = PeachType.labelMono,293 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp),294 )295 Text(296 "✗", color = peachColors.T2, style = PeachType.labelMono,297 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp),298 )299 }300}