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(122 "url", f.url, vm::setUrl,123 joined = true,124 divider = true,125 trailing = if (f.additionalUrls.isEmpty()) {126 { AddFieldLink("+ add", vm::addAdditionalUrl) }127 } else {128 null129 },130 )131 if (f.additionalUrls.isNotEmpty()) {132 AdditionalUrlsEditor(133 urls = f.additionalUrls,134 onChange = vm::setAdditionalUrl,135 onAdd = vm::addAdditionalUrl,136 onRemove = vm::removeAdditionalUrl,137 )138 }139 IdentitySuggestField(140 "username", f.username, usernameSuggestions, vm::setUsername,141 joined = true,142 divider = true,143 )144 IdentitySuggestField(145 "email", f.email, emailSuggestions, vm::setEmail,146 joined = true,147 divider = true,148 )149 PasswordField(150 value = f.password,151 show = f.showPassword,152 generatorOpen = f.generatorOpen,153 onChange = vm::setPassword,154 onToggleShow = vm::toggleShowPassword,155 onToggleGenerator = vm::toggleGenerator,156 joined = true,157 )158 PasswordHistorySection(159 history = f.history,160 expanded = f.showHistory,161 onToggle = vm::toggleHistory,162 onRestore = vm::restorePassword,163 onCopy = vm::copyHistoryPassword,164 )165 }166 GeneratorDrawer(167 open = f.generatorOpen,168 length = f.genLength,169 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,170 password = f.password,171 copied = f.genCopied,172 onLength = vm::setGenLength,173 onUpper = vm::setGenUpper, onLower = vm::setGenLower,174 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,175 onGenerate = vm::generate,176 onCopy = vm::copyGenerated,177 onUse = vm::useGeneratedPassword,178 )179 OptionalLoginAddLinks(180 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),181 showNotesLink = shouldShowLoginNotesLink(182 isNew = vm.isNew,183 type = f.type,184 revealed = f.showNotes,185 notes = f.notes,186 ),187 onAddTotp = vm::revealTotp,188 onAddNotes = vm::revealNotes,189 )190 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {191 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)192 }193 }194 if (f.type == "card") {195 CardEditFields(196 cardNickname = f.cardNickname,197 cardNumber = f.cardNumber,198 cardholderName = f.cardholderName,199 expiry = f.expiry,200 cvc = f.cvc,201 showCvc = f.showCvc,202 network = f.cardNetwork,203 onCardNickname = vm::setCardNickname,204 onCardNumber = vm::setCardNumber,205 onCardholder = vm::setCardholderName,206 onExpiry = vm::setExpiry,207 onCvc = vm::setCvc,208 onToggleShowCvc = vm::toggleShowCvc,209 )210 }211 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {212 Field("notes", f.notes, vm::setNotes, singleLine = false)213 }214 if (f.type == "login") {215 CanaryEditSection(216 entryId = id ?: "new",217 modifiedAt = f.canary?.lastPinged ?: 0L,218 canary = f.canary,219 isPro = vm.isPro,220 message = f.canaryMessage,221 testing = f.canaryTesting,222 onSave = vm::setCanary,223 onTest = vm::testCanary,224 )225 }226 TagChipEditor(227 tags = f.tags,228 allTags = vm.allTags,229 onAdd = vm::addTag,230 onRemove = vm::removeTag,231 )232233 f.error?.let {234 Spacer(Modifier.height(PeachSpacing.sm))235 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)236 }237 Spacer(Modifier.height(PeachSpacing.xl))238 }239 }240 EntryEditBottomActions(241 saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),242 canSave = canSaveEditForm(f) && !f.saving,243 onCancel = onBack,244 onSave = { vm.save(onBack) },245 )246 }247 }248}249250@Composable251private fun EntryEditBottomActions(252 saveLabel: String,253 canSave: Boolean,254 onCancel: () -> Unit,255 onSave: () -> Unit,256) {257 val accent = LocalPeachAccent.current258 Row(259 Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),260 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),261 ) {262 Surface(263 modifier = Modifier.weight(1f).clickable(onClick = onCancel),264 color = peachColors.S1,265 shape = RoundedCornerShape(PeachRadius.StackCard),266 border = BorderStroke(1.dp, peachColors.T3),267 ) {268 Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)269 }270 Surface(271 modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),272 color = if (canSave) accent else peachColors.S2,273 shape = RoundedCornerShape(PeachRadius.StackCard),274 ) {275 Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)276 }277 }278}279280281internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {282 !isNew -> "edit"283 type == "note" -> "new note"284 type == "card" -> "new card"285 else -> "new entry"286}287288internal fun entryEditTypeSegments(): List<PeachSegment> = listOf(289 PeachSegment("login", "login"),290 PeachSegment("card", "card"),291)292293internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {294 saving -> "saving..."295 isNew -> "create"296 else -> "save"297}298299internal fun canSaveEditForm(form: EditForm): Boolean {300 if (form.title.isBlank()) return false301 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false302 return true303}304305306internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f307308internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =309 type == "login" && (revealed || totp.isNotBlank())310311internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =312 type == "login" && (revealed || notes.isNotBlank())313314internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =315 type == "login" && !shouldShowLoginTotp(type, revealed, totp)316317internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =318 type == "login" && !shouldShowLoginNotes(type, revealed, notes)319320internal fun shouldShowLoginNotesLink(isNew: Boolean, type: String, revealed: Boolean, notes: String): Boolean =321 !isNew && shouldShowAddNotesLink(type, revealed, notes)322323@Composable324private fun OptionalLoginAddLinks(325 showTotpLink: Boolean,326 showNotesLink: Boolean,327 onAddTotp: () -> Unit,328 onAddNotes: () -> Unit,329) {330 if (!showTotpLink && !showNotesLink) return331 PeachFieldStack {332 Text(333 "OPTIONAL FIELDS",334 color = peachColors.T2,335 fontFamily = PeachType.Mono,336 fontSize = 8.sp,337 letterSpacing = 0.08.em,338 modifier = Modifier.padding(start = PeachSpacing.lg, top = PeachSpacing.md),339 )340 Row(341 Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm),342 horizontalArrangement = Arrangement.spacedBy(14.dp),343 ) {344 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)345 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)346 }347 }348}349350@Composable351private fun AddFieldLink(label: String, onClick: () -> Unit) {352 Text(353 label,354 color = LocalPeachAccent.current,355 fontFamily = PeachType.Mono,356 fontSize = 11.sp,357 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),358 )359}360361@Composable362private fun Field(363 label: String,364 value: String,365 onChange: (String) -> Unit,366 singleLine: Boolean = true,367 transformation: VisualTransformation = VisualTransformation.None,368 trailing: (@Composable () -> Unit)? = null,369 joined: Boolean = false,370 divider: Boolean = false,371) {372 PeachField(373 label = label,374 value = value,375 onChange = onChange,376 singleLine = singleLine,377 mono = true,378 visualTransformation = transformation,379 trailing = trailing,380 joined = joined,381 divider = divider,382 )383}384385@Composable386private fun PasswordField(387 value: String,388 show: Boolean,389 generatorOpen: Boolean,390 onChange: (String) -> Unit,391 onToggleShow: () -> Unit,392 onToggleGenerator: () -> Unit,393 joined: Boolean = false,394 divider: Boolean = false,395) {396 Field(397 label = "password",398 value = value,399 onChange = onChange,400 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),401 joined = joined,402 divider = divider,403 trailing = {404 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {405 PasswordAction(if (show) "hide" else "show", onToggleShow)406 PasswordAction(generatorToggleLabel(generatorOpen), onToggleGenerator)407 }408 },409 )410}411412@Composable413private fun PasswordAction(label: String, onClick: () -> Unit) {414 val accent = LocalPeachAccent.current415 Text(416 label,417 color = accent,418 fontFamily = PeachType.Mono,419 fontSize = 9.sp,420 modifier = Modifier421 .clip(RoundedCornerShape(6.dp))422 .background(accent.copy(alpha = 0.08f))423 .clickable(onClick = onClick)424 .padding(horizontal = 7.dp, vertical = 5.dp),425 )426}427428internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"429430@Composable431private fun GeneratorDrawer(432 open: Boolean,433 length: Int,434 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,435 password: String,436 copied: Boolean,437 onLength: (Int) -> Unit,438 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,439 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,440 onGenerate: () -> Unit,441 onCopy: () -> Unit,442 onUse: () -> Unit,443) {444 val height by animateDpAsState(445 targetValue = if (open) 326.dp else 0.dp,446 animationSpec = tween(320, easing = PeachMotion.Spring),447 label = "gen-drawer",448 )449 Box(450 Modifier.fillMaxWidth().height(height).clipToBounds()451 .clip(RoundedCornerShape(12.dp))452 .background(peachColors.S2)453 .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp)),454 ) {455 GeneratorBody(456 length = length,457 upper = upper, lower = lower, numbers = numbers, symbols = symbols,458 password = password, copied = copied,459 onLength = onLength,460 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,461 onGenerate = onGenerate, onCopy = onCopy,462 showOutput = true,463 onUse = onUse,464 )465 }466 if (open) Spacer(Modifier.height(14.dp))467}468469470471472473@Composable474internal fun GeneratorBody(475 length: Int,476 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,477 password: String,478 copied: Boolean,479 onLength: (Int) -> Unit,480 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,481 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,482 onGenerate: () -> Unit,483 onCopy: () -> Unit,484 showOutput: Boolean = false,485 onUse: (() -> Unit)? = null,486) {487 val accent = LocalPeachAccent.current488 Column(Modifier.fillMaxWidth().padding(16.dp)) {489 if (showOutput) {490 Box(491 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))492 .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))493 .background(accent.copy(alpha = 0.08f))494 .padding(horizontal = 12.dp, vertical = 10.dp),495 ) {496 Text(497 text = password.ifEmpty { "tap generate" },498 color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,499 fontFamily = PeachType.Mono,500 fontSize = 11.sp,501 maxLines = 2,502 overflow = TextOverflow.Ellipsis,503 )504 }505 Spacer(Modifier.height(10.dp))506 }507 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {508 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)509 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)510 }511 Slider(512 value = length.toFloat(),513 onValueChange = { onLength(it.toInt()) },514 valueRange = 8f..128f,515 colors = SliderDefaults.colors(516 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,517 ),518 )519 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {520 TogglePill("A-Z", upper, onUpper)521 TogglePill("a-z", lower, onLower)522 TogglePill("0-9", numbers, onNumbers)523 TogglePill("!@#", symbols, onSymbols)524 }525 Spacer(Modifier.height(12.dp))526 Row(verticalAlignment = Alignment.CenterVertically) {527 Box(528 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))529 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))530 .background(accent.copy(alpha = 0.10f))531 .clickable(onClick = onGenerate).padding(vertical = 12.dp),532 contentAlignment = Alignment.Center,533 ) {534 Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)535 }536 Spacer(Modifier.width(8.dp))537 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })538 }539 onUse?.let {540 Spacer(Modifier.height(10.dp))541 Box(542 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))543 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))544 .background(accent.copy(alpha = 0.14f))545 .clickable(enabled = password.isNotEmpty(), onClick = it)546 .padding(vertical = 12.dp),547 contentAlignment = Alignment.Center,548 ) {549 Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)550 }551 }552 }553}554555@Composable556private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {557 val accent = LocalPeachAccent.current558 Box(559 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))560 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))561 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)562 .clickable { onToggle(!on) }563 .padding(horizontal = 10.dp, vertical = 6.dp),564 ) {565 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)566 }567}568569570