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(vm.isNew),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 IdentitySuggestField(123 "username", f.username, usernameSuggestions, vm::setUsername,124 joined = true,125 divider = true,126 )127 IdentitySuggestField(128 "email", f.email, emailSuggestions, vm::setEmail,129 joined = true,130 divider = true,131 )132 PasswordField(133 value = f.password,134 show = f.showPassword,135 generatorOpen = f.generatorOpen,136 onChange = vm::setPassword,137 onToggleShow = vm::toggleShowPassword,138 onToggleGenerator = vm::toggleGenerator,139 joined = true,140 )141 }142 GeneratorDrawer(143 open = f.generatorOpen,144 length = f.genLength,145 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,146 password = f.password,147 copied = f.genCopied,148 onLength = vm::setGenLength,149 onUpper = vm::setGenUpper, onLower = vm::setGenLower,150 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,151 onGenerate = vm::generate,152 onCopy = vm::copyGenerated,153 onUse = vm::useGeneratedPassword,154 )155 AdditionalUrlsEditor(156 urls = f.additionalUrls,157 onChange = vm::setAdditionalUrl,158 onAdd = vm::addAdditionalUrl,159 onRemove = vm::removeAdditionalUrl,160 )161 PasswordHistorySection(162 history = f.history,163 expanded = f.showHistory,164 onToggle = vm::toggleHistory,165 onRestore = vm::restorePassword,166 onCopy = vm::copyHistoryPassword,167 )168 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {169 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)170 }171 }172 if (f.type == "card") {173 CardEditFields(174 cardNickname = f.cardNickname,175 cardNumber = f.cardNumber,176 cardholderName = f.cardholderName,177 expiry = f.expiry,178 cvc = f.cvc,179 showCvc = f.showCvc,180 network = f.cardNetwork,181 onCardNickname = vm::setCardNickname,182 onCardNumber = vm::setCardNumber,183 onCardholder = vm::setCardholderName,184 onExpiry = vm::setExpiry,185 onCvc = vm::setCvc,186 onToggleShowCvc = vm::toggleShowCvc,187 )188 }189 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {190 Field("notes", f.notes, vm::setNotes, singleLine = false)191 }192 if (f.type == "login") {193 OptionalLoginAddLinks(194 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),195 showNotesLink = shouldShowLoginNotesLink(196 isNew = vm.isNew,197 type = f.type,198 revealed = f.showNotes,199 notes = f.notes,200 ),201 onAddTotp = vm::revealTotp,202 onAddNotes = vm::revealNotes,203 )204 CanaryEditSection(205 entryId = id ?: "new",206 modifiedAt = f.canary?.lastPinged ?: 0L,207 canary = f.canary,208 isPro = vm.isPro,209 message = f.canaryMessage,210 testing = f.canaryTesting,211 onSave = vm::setCanary,212 onTest = vm::testCanary,213 )214 }215 TagChipEditor(216 tags = f.tags,217 allTags = vm.allTags,218 onAdd = vm::addTag,219 onRemove = vm::removeTag,220 )221222 f.error?.let {223 Spacer(Modifier.height(PeachSpacing.sm))224 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)225 }226 Spacer(Modifier.height(PeachSpacing.xl))227 }228 }229 EntryEditBottomActions(230 saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),231 canSave = canSaveEditForm(f) && !f.saving,232 onCancel = onBack,233 onSave = { vm.save(onBack) },234 )235 }236 }237}238239@Composable240private fun EntryEditBottomActions(241 saveLabel: String,242 canSave: Boolean,243 onCancel: () -> Unit,244 onSave: () -> Unit,245) {246 val accent = LocalPeachAccent.current247 Row(248 Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),249 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),250 ) {251 Surface(252 modifier = Modifier.weight(1f).clickable(onClick = onCancel),253 color = peachColors.S1,254 shape = RoundedCornerShape(PeachRadius.StackCard),255 border = BorderStroke(1.dp, peachColors.T3),256 ) {257 Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)258 }259 Surface(260 modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),261 color = if (canSave) accent else peachColors.S2,262 shape = RoundedCornerShape(PeachRadius.StackCard),263 ) {264 Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)265 }266 }267}268269270internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {271 !isNew -> "edit"272 type == "note" -> "new note"273 type == "card" -> "new card"274 else -> "new entry"275}276277internal fun entryEditTypeSegments(isNew: Boolean): List<PeachSegment> = buildList {278 add(PeachSegment("login", "login"))279 if (!isNew) add(PeachSegment("note", "note"))280 add(PeachSegment("card", "card"))281}282283internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {284 saving -> "saving..."285 isNew -> "create"286 else -> "save"287}288289internal fun canSaveEditForm(form: EditForm): Boolean {290 if (form.title.isBlank()) return false291 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false292 return true293}294295296internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f297298internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =299 type == "login" && (revealed || totp.isNotBlank())300301internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =302 type == "login" && (revealed || notes.isNotBlank())303304internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =305 type == "login" && !shouldShowLoginTotp(type, revealed, totp)306307internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =308 type == "login" && !shouldShowLoginNotes(type, revealed, notes)309310internal fun shouldShowLoginNotesLink(isNew: Boolean, type: String, revealed: Boolean, notes: String): Boolean =311 !isNew && shouldShowAddNotesLink(type, revealed, notes)312313@Composable314private fun OptionalLoginAddLinks(315 showTotpLink: Boolean,316 showNotesLink: Boolean,317 onAddTotp: () -> Unit,318 onAddNotes: () -> Unit,319) {320 if (!showTotpLink && !showNotesLink) return321 Row(322 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),323 horizontalArrangement = Arrangement.spacedBy(14.dp),324 ) {325 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)326 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)327 }328}329330@Composable331private fun AddFieldLink(label: String, onClick: () -> Unit) {332 Text(333 label,334 color = LocalPeachAccent.current,335 fontFamily = PeachType.Mono,336 fontSize = 11.sp,337 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),338 )339}340341@Composable342private fun Field(343 label: String,344 value: String,345 onChange: (String) -> Unit,346 singleLine: Boolean = true,347 transformation: VisualTransformation = VisualTransformation.None,348 trailing: (@Composable () -> Unit)? = null,349 joined: Boolean = false,350 divider: Boolean = false,351) {352 PeachField(353 label = label,354 value = value,355 onChange = onChange,356 singleLine = singleLine,357 mono = true,358 visualTransformation = transformation,359 trailing = trailing,360 joined = joined,361 divider = divider,362 )363}364365@Composable366private fun PasswordField(367 value: String,368 show: Boolean,369 generatorOpen: Boolean,370 onChange: (String) -> Unit,371 onToggleShow: () -> Unit,372 onToggleGenerator: () -> Unit,373 joined: Boolean = false,374) {375 Field(376 label = "password",377 value = value,378 onChange = onChange,379 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),380 joined = joined,381 trailing = {382 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {383 PasswordAction(if (show) "hide" else "show", onToggleShow)384 PasswordAction(generatorToggleLabel(generatorOpen), onToggleGenerator)385 }386 },387 )388}389390@Composable391private fun PasswordAction(label: String, onClick: () -> Unit) {392 val accent = LocalPeachAccent.current393 Text(394 label,395 color = accent,396 fontFamily = PeachType.Mono,397 fontSize = 9.sp,398 modifier = Modifier399 .clip(RoundedCornerShape(6.dp))400 .background(accent.copy(alpha = 0.08f))401 .clickable(onClick = onClick)402 .padding(horizontal = 7.dp, vertical = 5.dp),403 )404}405406internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"407408@Composable409private fun GeneratorDrawer(410 open: Boolean,411 length: Int,412 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,413 password: String,414 copied: Boolean,415 onLength: (Int) -> Unit,416 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,417 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,418 onGenerate: () -> Unit,419 onCopy: () -> Unit,420 onUse: () -> Unit,421) {422 val height by animateDpAsState(423 targetValue = if (open) 326.dp else 0.dp,424 animationSpec = tween(320, easing = PeachMotion.Spring),425 label = "gen-drawer",426 )427 Box(428 Modifier.fillMaxWidth().height(height).clipToBounds()429 .clip(RoundedCornerShape(12.dp))430 .background(peachColors.S2)431 .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp)),432 ) {433 GeneratorBody(434 length = length,435 upper = upper, lower = lower, numbers = numbers, symbols = symbols,436 password = password, copied = copied,437 onLength = onLength,438 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,439 onGenerate = onGenerate, onCopy = onCopy,440 showOutput = true,441 onUse = onUse,442 )443 }444 if (open) Spacer(Modifier.height(14.dp))445}446447448449450451@Composable452internal fun GeneratorBody(453 length: Int,454 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,455 password: String,456 copied: Boolean,457 onLength: (Int) -> Unit,458 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,459 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,460 onGenerate: () -> Unit,461 onCopy: () -> Unit,462 showOutput: Boolean = false,463 onUse: (() -> Unit)? = null,464) {465 val accent = LocalPeachAccent.current466 Column(Modifier.fillMaxWidth().padding(16.dp)) {467 if (showOutput) {468 Box(469 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))470 .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))471 .background(accent.copy(alpha = 0.08f))472 .padding(horizontal = 12.dp, vertical = 10.dp),473 ) {474 Text(475 text = password.ifEmpty { "tap generate" },476 color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,477 fontFamily = PeachType.Mono,478 fontSize = 11.sp,479 maxLines = 2,480 overflow = TextOverflow.Ellipsis,481 )482 }483 Spacer(Modifier.height(10.dp))484 }485 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {486 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)487 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)488 }489 Slider(490 value = length.toFloat(),491 onValueChange = { onLength(it.toInt()) },492 valueRange = 8f..128f,493 colors = SliderDefaults.colors(494 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,495 ),496 )497 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {498 TogglePill("A-Z", upper, onUpper)499 TogglePill("a-z", lower, onLower)500 TogglePill("0-9", numbers, onNumbers)501 TogglePill("!@#", symbols, onSymbols)502 }503 Spacer(Modifier.height(12.dp))504 Row(verticalAlignment = Alignment.CenterVertically) {505 Box(506 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))507 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))508 .background(accent.copy(alpha = 0.10f))509 .clickable(onClick = onGenerate).padding(vertical = 12.dp),510 contentAlignment = Alignment.Center,511 ) {512 Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)513 }514 Spacer(Modifier.width(8.dp))515 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })516 }517 onUse?.let {518 Spacer(Modifier.height(10.dp))519 Box(520 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))521 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))522 .background(accent.copy(alpha = 0.14f))523 .clickable(enabled = password.isNotEmpty(), onClick = it)524 .padding(vertical = 12.dp),525 contentAlignment = Alignment.Center,526 ) {527 Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)528 }529 }530 }531}532533@Composable534private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {535 val accent = LocalPeachAccent.current536 Box(537 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))538 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))539 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)540 .clickable { onToggle(!on) }541 .padding(horizontal = 10.dp, vertical = 6.dp),542 ) {543 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)544 }545}546547548