1package com.peachpasswords.ui.vault23import androidx.compose.foundation.BorderStroke4import androidx.compose.animation.core.animateDpAsState5import androidx.compose.animation.core.tween6import androidx.compose.foundation.background7import androidx.compose.foundation.border8import androidx.compose.foundation.clickable9import androidx.compose.foundation.layout.Arrangement10import androidx.compose.foundation.layout.Box11import androidx.compose.foundation.layout.Column12import androidx.compose.foundation.layout.Row13import androidx.compose.foundation.layout.Spacer14import androidx.compose.foundation.layout.fillMaxSize15import androidx.compose.foundation.layout.fillMaxWidth16import androidx.compose.foundation.layout.height17import androidx.compose.foundation.layout.offset18import androidx.compose.foundation.layout.navigationBarsPadding19import androidx.compose.foundation.layout.padding20import androidx.compose.foundation.layout.statusBarsPadding21import androidx.compose.foundation.layout.width22import androidx.compose.foundation.rememberScrollState23import androidx.compose.foundation.shape.RoundedCornerShape24import androidx.compose.foundation.verticalScroll25import androidx.compose.material3.Slider26import androidx.compose.material3.SliderDefaults27import androidx.compose.material3.Surface28import androidx.compose.material3.Text29import androidx.compose.runtime.Composable30import androidx.compose.runtime.getValue31import androidx.compose.runtime.mutableStateOf32import androidx.compose.runtime.remember33import androidx.compose.runtime.setValue34import androidx.compose.ui.Alignment35import androidx.compose.ui.Modifier36import androidx.compose.ui.draw.clip37import androidx.compose.ui.draw.clipToBounds38import androidx.compose.ui.graphics.Color39import androidx.compose.ui.text.TextStyle40import androidx.compose.ui.text.font.FontWeight41import androidx.compose.ui.text.input.PasswordVisualTransformation42import androidx.compose.ui.text.input.VisualTransformation43import androidx.compose.ui.text.style.TextAlign44import androidx.compose.ui.text.style.TextOverflow45import androidx.compose.ui.unit.dp46import androidx.compose.ui.unit.em47import androidx.compose.ui.unit.sp48import androidx.compose.runtime.collectAsState49import androidx.hilt.navigation.compose.hiltViewModel50import com.peachpasswords.ui.components.CopyButton51import com.peachpasswords.ui.components.PeachEmptyOrEndState52import com.peachpasswords.ui.components.PeachSegment53import com.peachpasswords.ui.components.PeachSegmentedControl54import com.peachpasswords.ui.components.SecureFlagEffect55import com.peachpasswords.ui.theme.LocalPeachAccent56import com.peachpasswords.ui.theme.PeachMotion57import com.peachpasswords.ui.theme.PeachRadius58import com.peachpasswords.ui.theme.PeachSpacing59import com.peachpasswords.ui.theme.PeachType60import com.peachpasswords.ui.theme.ShellBackground61import com.peachpasswords.ui.theme.peachColors6263@Composable64fun EntryEditScreen(65 id: String?,66 onBack: () -> Unit,67 onHome: () -> Unit,68 vm: EntryEditViewModel = hiltViewModel(),69) {70 SecureFlagEffect()7172 val f by vm.form.collectAsState()73 val usernameSuggestions by vm.usernameSuggestions.collectAsState()74 val emailSuggestions by vm.emailSuggestions.collectAsState()7576 77 val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(78 targetValue = editGhostWatermarkTarget(f.title),79 animationSpec = tween(300),80 label = "edit-watermark",81 )8283 ShellBackground {84 Column(85 Modifier86 .fillMaxSize()87 .statusBarsPadding()88 .padding(horizontal = PeachSpacing.xl),89 ) {90 PeachVaultIdentity(editTitleBarLabel(vm.isNew, f.type), onHome = onHome)9192 Spacer(Modifier.height(PeachSpacing.lg))9394 Box(Modifier.weight(1f).fillMaxWidth().clipToBounds()) {95 96 97 if (watermarkAlpha > 0f) {98 Text(99 text = ghostWatermarkText(f.title),100 style = PeachType.ghostMark.copy(fontSize = 140.sp),101 color = peachColors.T0.copy(alpha = watermarkAlpha),102 maxLines = 1,103 softWrap = false,104 overflow = androidx.compose.ui.text.style.TextOverflow.Clip,105 modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),106 )107 }108 Column(109 Modifier.fillMaxSize().verticalScroll(rememberScrollState()),110 ) {111 PeachSegmentedControl(112 segments = entryEditTypeSegments(),113 selectedKey = f.type,114 onSelected = vm::setType,115 )116 Spacer(Modifier.height(PeachSpacing.lg))117118 Field("title", f.title, vm::setTitle)119 if (f.type == "login") {120 PeachFieldStack {121 Field("url", f.url, vm::setUrl, joined = true, divider = true)122 AdditionalUrlsEditor(123 urls = f.additionalUrls,124 onChange = vm::setAdditionalUrl,125 onAdd = vm::addAdditionalUrl,126 onRemove = vm::removeAdditionalUrl,127 embedded = true,128 )129 IdentitySuggestField(130 "username", f.username, usernameSuggestions, vm::setUsername,131 joined = true,132 divider = true,133 )134 IdentitySuggestField(135 "email", f.email, emailSuggestions, vm::setEmail,136 joined = true,137 divider = true,138 )139 PasswordField(140 value = f.password,141 show = f.showPassword,142 generatorOpen = f.generatorOpen,143 onChange = vm::setPassword,144 onToggleShow = vm::toggleShowPassword,145 onToggleGenerator = vm::toggleGenerator,146 joined = true,147 divider = true,148 )149 GeneratorDrawer(150 open = f.generatorOpen,151 length = f.genLength,152 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,153 password = f.password,154 copied = f.genCopied,155 onLength = vm::setGenLength,156 onUpper = vm::setGenUpper, onLower = vm::setGenLower,157 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,158 onGenerate = vm::generate,159 onCopy = vm::copyGenerated,160 onUse = vm::useGeneratedPassword,161 embedded = true,162 )163 PasswordHistorySection(164 history = f.history,165 expanded = f.showHistory,166 onToggle = vm::toggleHistory,167 onRestore = vm::restorePassword,168 onCopy = vm::copyHistoryPassword,169 embedded = true,170 )171 }172 OptionalLoginAddLinks(173 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),174 showNotesLink = shouldShowLoginNotesLink(175 isNew = vm.isNew,176 type = f.type,177 revealed = f.showNotes,178 notes = f.notes,179 ),180 onAddTotp = vm::revealTotp,181 onAddNotes = vm::revealNotes,182 )183 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {184 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)185 }186 }187 if (f.type == "card") {188 CardEditFields(189 cardNickname = f.cardNickname,190 cardNumber = f.cardNumber,191 cardholderName = f.cardholderName,192 expiry = f.expiry,193 cvc = f.cvc,194 showCvc = f.showCvc,195 network = f.cardNetwork,196 onCardNickname = vm::setCardNickname,197 onCardNumber = vm::setCardNumber,198 onCardholder = vm::setCardholderName,199 onExpiry = vm::setExpiry,200 onCvc = vm::setCvc,201 onToggleShowCvc = vm::toggleShowCvc,202 )203 }204 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {205 Field("notes", f.notes, vm::setNotes, singleLine = false)206 }207 if (f.type == "login") {208 CanaryEditSection(209 entryId = id ?: "new",210 modifiedAt = f.canary?.lastPinged ?: 0L,211 canary = f.canary,212 isPro = vm.isPro,213 message = f.canaryMessage,214 testing = f.canaryTesting,215 onSave = vm::setCanary,216 onTest = vm::testCanary,217 )218 }219 TagChipEditor(220 tags = f.tags,221 allTags = vm.allTags,222 onAdd = vm::addTag,223 onRemove = vm::removeTag,224 )225226 f.error?.let {227 Spacer(Modifier.height(PeachSpacing.sm))228 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)229 }230 Spacer(Modifier.height(PeachSpacing.xl))231 }232 }233 EntryEditBottomActions(234 saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),235 canSave = canSaveEditForm(f) && !f.saving,236 onCancel = onBack,237 onSave = { vm.save(onBack) },238 )239 }240 }241}242243@Composable244private fun EntryEditBottomActions(245 saveLabel: String,246 canSave: Boolean,247 onCancel: () -> Unit,248 onSave: () -> Unit,249) {250 val accent = LocalPeachAccent.current251 Row(252 Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),253 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),254 ) {255 Surface(256 modifier = Modifier.weight(1f).clickable(onClick = onCancel),257 color = peachColors.S1,258 shape = RoundedCornerShape(PeachRadius.StackCard),259 border = BorderStroke(1.dp, peachColors.T3),260 ) {261 Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)262 }263 Surface(264 modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),265 color = if (canSave) accent else peachColors.S2,266 shape = RoundedCornerShape(PeachRadius.StackCard),267 ) {268 Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)269 }270 }271}272273274internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {275 !isNew -> "edit"276 type == "note" -> "new note"277 type == "card" -> "new card"278 else -> "new entry"279}280281internal fun entryEditTypeSegments(): List<PeachSegment> = listOf(282 PeachSegment("login", "login"),283 PeachSegment("card", "card"),284)285286internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {287 saving -> "saving..."288 isNew -> "create"289 else -> "save"290}291292internal fun canSaveEditForm(form: EditForm): Boolean {293 if (form.title.isBlank()) return false294 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false295 return true296}297298299internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f300301internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =302 type == "login" && (revealed || totp.isNotBlank())303304internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =305 type == "login" && (revealed || notes.isNotBlank())306307internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =308 type == "login" && !shouldShowLoginTotp(type, revealed, totp)309310internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =311 type == "login" && !shouldShowLoginNotes(type, revealed, notes)312313internal fun shouldShowLoginNotesLink(isNew: Boolean, type: String, revealed: Boolean, notes: String): Boolean =314 !isNew && shouldShowAddNotesLink(type, revealed, notes)315316@Composable317private fun OptionalLoginAddLinks(318 showTotpLink: Boolean,319 showNotesLink: Boolean,320 onAddTotp: () -> Unit,321 onAddNotes: () -> Unit,322) {323 if (!showTotpLink && !showNotesLink) return324 Row(325 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),326 horizontalArrangement = Arrangement.spacedBy(14.dp),327 ) {328 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)329 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)330 }331}332333@Composable334private fun AddFieldLink(label: String, onClick: () -> Unit) {335 Text(336 label,337 color = LocalPeachAccent.current,338 fontFamily = PeachType.Mono,339 fontSize = 11.sp,340 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),341 )342}343344@Composable345private fun Field(346 label: String,347 value: String,348 onChange: (String) -> Unit,349 singleLine: Boolean = true,350 transformation: VisualTransformation = VisualTransformation.None,351 trailing: (@Composable () -> Unit)? = null,352 joined: Boolean = false,353 divider: Boolean = false,354) {355 PeachField(356 label = label,357 value = value,358 onChange = onChange,359 singleLine = singleLine,360 mono = true,361 visualTransformation = transformation,362 trailing = trailing,363 joined = joined,364 divider = divider,365 )366}367368@Composable369private fun PasswordField(370 value: String,371 show: Boolean,372 generatorOpen: Boolean,373 onChange: (String) -> Unit,374 onToggleShow: () -> Unit,375 onToggleGenerator: () -> Unit,376 joined: Boolean = false,377 divider: Boolean = false,378) {379 Field(380 label = "password",381 value = value,382 onChange = onChange,383 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),384 joined = joined,385 divider = divider,386 trailing = {387 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {388 PasswordAction(if (show) "hide" else "show", onToggleShow)389 PasswordAction(generatorToggleLabel(generatorOpen), onToggleGenerator)390 }391 },392 )393}394395@Composable396private fun PasswordAction(label: String, onClick: () -> Unit) {397 val accent = LocalPeachAccent.current398 Text(399 label,400 color = accent,401 fontFamily = PeachType.Mono,402 fontSize = 9.sp,403 modifier = Modifier404 .clip(RoundedCornerShape(6.dp))405 .background(accent.copy(alpha = 0.08f))406 .clickable(onClick = onClick)407 .padding(horizontal = 7.dp, vertical = 5.dp),408 )409}410411internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"412413@Composable414private fun GeneratorDrawer(415 open: Boolean,416 length: Int,417 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,418 password: String,419 copied: Boolean,420 onLength: (Int) -> Unit,421 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,422 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,423 onGenerate: () -> Unit,424 onCopy: () -> Unit,425 onUse: () -> Unit,426 embedded: Boolean = false,427) {428 val height by animateDpAsState(429 targetValue = if (open) 326.dp else 0.dp,430 animationSpec = tween(320, easing = PeachMotion.Spring),431 label = "gen-drawer",432 )433 Box(434 Modifier.fillMaxWidth().height(height).clipToBounds()435 .clip(RoundedCornerShape(12.dp))436 .background(peachColors.S2)437 .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp)),438 ) {439 GeneratorBody(440 length = length,441 upper = upper, lower = lower, numbers = numbers, symbols = symbols,442 password = password, copied = copied,443 onLength = onLength,444 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,445 onGenerate = onGenerate, onCopy = onCopy,446 showOutput = true,447 onUse = onUse,448 )449 }450 if (open) Spacer(Modifier.height(if (embedded) 8.dp else 14.dp))451}452453454455456457@Composable458internal fun GeneratorBody(459 length: Int,460 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,461 password: String,462 copied: Boolean,463 onLength: (Int) -> Unit,464 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,465 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,466 onGenerate: () -> Unit,467 onCopy: () -> Unit,468 showOutput: Boolean = false,469 onUse: (() -> Unit)? = null,470) {471 val accent = LocalPeachAccent.current472 Column(Modifier.fillMaxWidth().padding(16.dp)) {473 if (showOutput) {474 Box(475 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))476 .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))477 .background(accent.copy(alpha = 0.08f))478 .padding(horizontal = 12.dp, vertical = 10.dp),479 ) {480 Text(481 text = password.ifEmpty { "tap generate" },482 color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,483 fontFamily = PeachType.Mono,484 fontSize = 11.sp,485 maxLines = 2,486 overflow = TextOverflow.Ellipsis,487 )488 }489 Spacer(Modifier.height(10.dp))490 }491 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {492 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)493 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)494 }495 Slider(496 value = length.toFloat(),497 onValueChange = { onLength(it.toInt()) },498 valueRange = 8f..128f,499 colors = SliderDefaults.colors(500 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,501 ),502 )503 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {504 TogglePill("A-Z", upper, onUpper)505 TogglePill("a-z", lower, onLower)506 TogglePill("0-9", numbers, onNumbers)507 TogglePill("!@#", symbols, onSymbols)508 }509 Spacer(Modifier.height(12.dp))510 Row(verticalAlignment = Alignment.CenterVertically) {511 Box(512 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))513 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))514 .background(accent.copy(alpha = 0.10f))515 .clickable(onClick = onGenerate).padding(vertical = 12.dp),516 contentAlignment = Alignment.Center,517 ) {518 Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)519 }520 Spacer(Modifier.width(8.dp))521 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })522 }523 onUse?.let {524 Spacer(Modifier.height(10.dp))525 Box(526 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))527 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))528 .background(accent.copy(alpha = 0.14f))529 .clickable(enabled = password.isNotEmpty(), onClick = it)530 .padding(vertical = 12.dp),531 contentAlignment = Alignment.Center,532 ) {533 Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)534 }535 }536 }537}538539@Composable540private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {541 val accent = LocalPeachAccent.current542 Box(543 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))544 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))545 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)546 .clickable { onToggle(!on) }547 .padding(horizontal = 10.dp, vertical = 6.dp),548 ) {549 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)550 }551}552553554