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 mode = f.genMode,169 length = f.genLength,170 wordCount = f.genWordCount,171 separator = f.genSeparator,172 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,173 password = f.password,174 copied = f.genCopied,175 onMode = vm::setGenMode,176 onLength = vm::setGenLength,177 onWordCount = vm::setGenWordCount,178 onSeparator = vm::setGenSeparator,179 onUpper = vm::setGenUpper, onLower = vm::setGenLower,180 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,181 onGenerate = vm::generate,182 onCopy = vm::copyGenerated,183 onUse = vm::useGeneratedPassword,184 )185 OptionalLoginAddLinks(186 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),187 showNotesLink = shouldShowLoginNotesLink(188 isNew = vm.isNew,189 type = f.type,190 revealed = f.showNotes,191 notes = f.notes,192 ),193 onAddTotp = vm::revealTotp,194 onAddNotes = vm::revealNotes,195 )196 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {197 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)198 }199 }200 if (f.type == "card") {201 CardEditFields(202 cardNickname = f.cardNickname,203 cardNumber = f.cardNumber,204 cardholderName = f.cardholderName,205 expiry = f.expiry,206 cvc = f.cvc,207 showCvc = f.showCvc,208 network = f.cardNetwork,209 onCardNickname = vm::setCardNickname,210 onCardNumber = vm::setCardNumber,211 onCardholder = vm::setCardholderName,212 onExpiry = vm::setExpiry,213 onCvc = vm::setCvc,214 onToggleShowCvc = vm::toggleShowCvc,215 )216 }217 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {218 Field("notes", f.notes, vm::setNotes, singleLine = false)219 }220 if (f.type == "login") {221 CanaryEditSection(222 entryId = id ?: "new",223 modifiedAt = f.canary?.lastPinged ?: 0L,224 canary = f.canary,225 isPro = vm.isPro,226 message = f.canaryMessage,227 testing = f.canaryTesting,228 onSave = vm::setCanary,229 onTest = vm::testCanary,230 )231 }232 TagChipEditor(233 tags = f.tags,234 allTags = vm.allTags,235 onAdd = vm::addTag,236 onRemove = vm::removeTag,237 )238239 f.error?.let {240 Spacer(Modifier.height(PeachSpacing.sm))241 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)242 }243 Spacer(Modifier.height(PeachSpacing.xl))244 }245 }246 EntryEditBottomActions(247 saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),248 canSave = canSaveEditForm(f) && !f.saving,249 onCancel = onBack,250 onSave = { vm.save(onBack) },251 )252 }253 }254}255256@Composable257private fun EntryEditBottomActions(258 saveLabel: String,259 canSave: Boolean,260 onCancel: () -> Unit,261 onSave: () -> Unit,262) {263 val accent = LocalPeachAccent.current264 Row(265 Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),266 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),267 ) {268 Surface(269 modifier = Modifier.weight(1f).clickable(onClick = onCancel),270 color = peachColors.S1,271 shape = RoundedCornerShape(PeachRadius.StackCard),272 border = BorderStroke(1.dp, peachColors.T3),273 ) {274 Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)275 }276 Surface(277 modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),278 color = if (canSave) accent else peachColors.S2,279 shape = RoundedCornerShape(PeachRadius.StackCard),280 ) {281 Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)282 }283 }284}285286287internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {288 !isNew -> "edit"289 type == "note" -> "new note"290 type == "card" -> "new card"291 else -> "new entry"292}293294internal fun entryEditTypeSegments(): List<PeachSegment> = listOf(295 PeachSegment("login", "login"),296 PeachSegment("card", "card"),297)298299internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {300 saving -> "saving..."301 isNew -> "create"302 else -> "save"303}304305internal fun canSaveEditForm(form: EditForm): Boolean {306 if (form.title.isBlank()) return false307 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false308 return true309}310311312internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f313314internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =315 type == "login" && (revealed || totp.isNotBlank())316317internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =318 type == "login" && (revealed || notes.isNotBlank())319320internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =321 type == "login" && !shouldShowLoginTotp(type, revealed, totp)322323internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =324 type == "login" && !shouldShowLoginNotes(type, revealed, notes)325326internal fun shouldShowLoginNotesLink(isNew: Boolean, type: String, revealed: Boolean, notes: String): Boolean =327 !isNew && shouldShowAddNotesLink(type, revealed, notes)328329@Composable330private fun OptionalLoginAddLinks(331 showTotpLink: Boolean,332 showNotesLink: Boolean,333 onAddTotp: () -> Unit,334 onAddNotes: () -> Unit,335) {336 if (!showTotpLink && !showNotesLink) return337 PeachFieldStack {338 Text(339 "OPTIONAL FIELDS",340 color = peachColors.T2,341 fontFamily = PeachType.Mono,342 fontSize = 8.sp,343 letterSpacing = 0.08.em,344 modifier = Modifier.padding(start = PeachSpacing.lg, top = PeachSpacing.md),345 )346 Row(347 Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm),348 horizontalArrangement = Arrangement.spacedBy(14.dp),349 ) {350 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)351 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)352 }353 }354}355356@Composable357private fun AddFieldLink(label: String, onClick: () -> Unit) {358 Text(359 label,360 color = LocalPeachAccent.current,361 fontFamily = PeachType.Mono,362 fontSize = 11.sp,363 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),364 )365}366367@Composable368private fun Field(369 label: String,370 value: String,371 onChange: (String) -> Unit,372 singleLine: Boolean = true,373 transformation: VisualTransformation = VisualTransformation.None,374 trailing: (@Composable () -> Unit)? = null,375 joined: Boolean = false,376 divider: Boolean = false,377) {378 PeachField(379 label = label,380 value = value,381 onChange = onChange,382 singleLine = singleLine,383 mono = true,384 visualTransformation = transformation,385 trailing = trailing,386 joined = joined,387 divider = divider,388 )389}390391@Composable392private fun PasswordField(393 value: String,394 show: Boolean,395 generatorOpen: Boolean,396 onChange: (String) -> Unit,397 onToggleShow: () -> Unit,398 onToggleGenerator: () -> Unit,399 joined: Boolean = false,400 divider: Boolean = false,401) {402 Field(403 label = "password",404 value = value,405 onChange = onChange,406 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),407 joined = joined,408 divider = divider,409 trailing = {410 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {411 PasswordAction(if (show) "hide" else "show", onToggleShow)412 PasswordAction(generatorToggleLabel(generatorOpen), onToggleGenerator)413 }414 },415 )416}417418@Composable419private fun PasswordAction(label: String, onClick: () -> Unit) {420 val accent = LocalPeachAccent.current421 Text(422 label,423 color = accent,424 fontFamily = PeachType.Mono,425 fontSize = 9.sp,426 modifier = Modifier427 .clip(RoundedCornerShape(6.dp))428 .background(accent.copy(alpha = 0.08f))429 .clickable(onClick = onClick)430 .padding(horizontal = 7.dp, vertical = 5.dp),431 )432}433434internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"435436@Composable437private fun GeneratorDrawer(438 open: Boolean,439 mode: GeneratorMode,440 length: Int,441 wordCount: Int,442 separator: String,443 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,444 password: String,445 copied: Boolean,446 onMode: (GeneratorMode) -> Unit,447 onLength: (Int) -> Unit,448 onWordCount: (Int) -> Unit,449 onSeparator: (String) -> Unit,450 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,451 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,452 onGenerate: () -> Unit,453 onCopy: () -> Unit,454 onUse: () -> Unit,455) {456 val height by animateDpAsState(457 targetValue = if (open) 370.dp else 0.dp,458 animationSpec = tween(320, easing = PeachMotion.Spring),459 label = "gen-drawer",460 )461 Box(462 Modifier.fillMaxWidth().height(height).clipToBounds()463 .clip(RoundedCornerShape(12.dp))464 .background(peachColors.S2)465 .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp)),466 ) {467 GeneratorBody(468 mode = mode,469 length = length,470 wordCount = wordCount,471 separator = separator,472 upper = upper, lower = lower, numbers = numbers, symbols = symbols,473 password = password, copied = copied,474 onMode = onMode,475 onLength = onLength,476 onWordCount = onWordCount,477 onSeparator = onSeparator,478 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,479 onGenerate = onGenerate, onCopy = onCopy,480 showOutput = true,481 onUse = onUse,482 )483 }484 if (open) Spacer(Modifier.height(14.dp))485}486487488489490491@Composable492internal fun GeneratorBody(493 mode: GeneratorMode,494 length: Int,495 wordCount: Int,496 separator: String,497 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,498 password: String,499 copied: Boolean,500 onMode: (GeneratorMode) -> Unit,501 onLength: (Int) -> Unit,502 onWordCount: (Int) -> Unit,503 onSeparator: (String) -> Unit,504 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,505 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,506 onGenerate: () -> Unit,507 onCopy: () -> Unit,508 showOutput: Boolean = false,509 onUse: (() -> Unit)? = null,510) {511 val accent = LocalPeachAccent.current512 Column(Modifier.fillMaxWidth().padding(16.dp)) {513 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {514 ChoicePill("password", mode == GeneratorMode.Password) { onMode(GeneratorMode.Password) }515 ChoicePill("passphrase", mode == GeneratorMode.Passphrase) { onMode(GeneratorMode.Passphrase) }516 }517 Spacer(Modifier.height(10.dp))518 if (showOutput) {519 Box(520 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))521 .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))522 .background(accent.copy(alpha = 0.08f))523 .padding(horizontal = 12.dp, vertical = 10.dp),524 ) {525 Text(526 text = password.ifEmpty { "tap generate" },527 color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,528 fontFamily = PeachType.Mono,529 fontSize = 11.sp,530 maxLines = 2,531 overflow = TextOverflow.Ellipsis,532 )533 }534 Spacer(Modifier.height(10.dp))535 }536 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {537 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)538 Text(539 if (mode == GeneratorMode.Passphrase) "word count $wordCount" else "length $length",540 color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp,541 )542 }543 Slider(544 value = if (mode == GeneratorMode.Passphrase) wordCount.toFloat() else length.toFloat(),545 onValueChange = {546 if (mode == GeneratorMode.Passphrase) onWordCount(it.toInt()) else onLength(it.toInt())547 },548 valueRange = if (mode == GeneratorMode.Passphrase) 4f..12f else 8f..256f,549 colors = SliderDefaults.colors(550 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,551 ),552 )553 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {554 if (mode == GeneratorMode.Passphrase) {555 listOf("-", " ", ".", "_").forEach { value ->556 ChoicePill(if (value == " ") "space" else value, separator == value) { onSeparator(value) }557 }558 } else {559 TogglePill("A-Z", upper, onUpper)560 TogglePill("a-z", lower, onLower)561 TogglePill("0-9", numbers, onNumbers)562 TogglePill("!@#", symbols, onSymbols)563 }564 }565 Spacer(Modifier.height(12.dp))566 Row(verticalAlignment = Alignment.CenterVertically) {567 Box(568 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))569 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))570 .background(accent.copy(alpha = 0.10f))571 .clickable(onClick = onGenerate).padding(vertical = 12.dp),572 contentAlignment = Alignment.Center,573 ) {574 Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)575 }576 Spacer(Modifier.width(8.dp))577 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })578 }579 onUse?.let {580 Spacer(Modifier.height(10.dp))581 Box(582 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))583 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))584 .background(accent.copy(alpha = 0.14f))585 .clickable(enabled = password.isNotEmpty(), onClick = it)586 .padding(vertical = 12.dp),587 contentAlignment = Alignment.Center,588 ) {589 Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)590 }591 }592 }593}594595@Composable596private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {597 ChoicePill(label, on) { onToggle(!on) }598}599600@Composable601private fun ChoicePill(label: String, selected: Boolean, onClick: () -> Unit) {602 val accent = LocalPeachAccent.current603 Box(604 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))605 .border(1.dp, if (selected) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))606 .background(if (selected) accent.copy(alpha = 0.10f) else Color.Transparent)607 .clickable(onClick = onClick)608 .padding(horizontal = 10.dp, vertical = 6.dp),609 ) {610 Text(label, color = if (selected) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)611 }612}613614615