1package com.peachpasswords.ui.vault23import androidx.compose.animation.core.animateDpAsState4import androidx.compose.animation.core.tween5import androidx.compose.foundation.background6import androidx.compose.foundation.border7import androidx.compose.foundation.clickable8import androidx.compose.foundation.layout.Arrangement9import androidx.compose.foundation.layout.Box10import androidx.compose.foundation.layout.Column11import androidx.compose.foundation.layout.Row12import androidx.compose.foundation.layout.Spacer13import androidx.compose.foundation.layout.fillMaxSize14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.height16import androidx.compose.foundation.layout.offset17import androidx.compose.foundation.layout.padding18import androidx.compose.foundation.layout.statusBarsPadding19import androidx.compose.foundation.layout.width20import androidx.compose.foundation.rememberScrollState21import androidx.compose.foundation.shape.RoundedCornerShape22import androidx.compose.foundation.verticalScroll23import androidx.compose.material3.Slider24import androidx.compose.material3.SliderDefaults25import androidx.compose.material3.Text26import androidx.compose.runtime.Composable27import androidx.compose.runtime.getValue28import androidx.compose.runtime.mutableStateOf29import androidx.compose.runtime.remember30import androidx.compose.runtime.setValue31import androidx.compose.ui.Alignment32import androidx.compose.ui.Modifier33import androidx.compose.ui.draw.clip34import androidx.compose.ui.draw.clipToBounds35import androidx.compose.ui.graphics.Color36import androidx.compose.ui.text.TextStyle37import androidx.compose.ui.text.font.FontStyle38import androidx.compose.ui.text.font.FontWeight39import androidx.compose.ui.text.input.PasswordVisualTransformation40import androidx.compose.ui.text.input.VisualTransformation41import androidx.compose.ui.text.style.TextOverflow42import androidx.compose.ui.unit.dp43import androidx.compose.ui.unit.em44import androidx.compose.ui.unit.sp45import androidx.compose.runtime.collectAsState46import androidx.hilt.navigation.compose.hiltViewModel47import com.peachpasswords.ui.components.CopyButton48import com.peachpasswords.ui.components.SecureFlagEffect49import com.peachpasswords.ui.theme.LocalPeachAccent50import com.peachpasswords.ui.theme.peachColors51import com.peachpasswords.ui.theme.PeachMotion52import com.peachpasswords.ui.theme.PeachRadius53import com.peachpasswords.ui.theme.PeachType54import com.peachpasswords.ui.theme.ShellBackground5556@Composable57fun EntryEditScreen(58 id: String?,59 onBack: () -> Unit,60 onOpenNote: (String) -> Unit = {},61 vm: EntryEditViewModel = hiltViewModel(),62) {63 SecureFlagEffect()64 val f by vm.form.collectAsState()65 val usernameSuggestions by vm.usernameSuggestions.collectAsState()66 val emailSuggestions by vm.emailSuggestions.collectAsState()67 val accent = LocalPeachAccent.current6869 70 val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(71 targetValue = editGhostWatermarkTarget(f.title),72 animationSpec = tween(300),73 label = "edit-watermark",74 )7576 ShellBackground {77 Column(Modifier.fillMaxSize().statusBarsPadding()) {78 Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 18.dp)) {79 val canSave = canSaveEditForm(f)80 Text("cancel", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,81 modifier = Modifier.align(Alignment.CenterStart)82 .clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(8.dp))83 Text(editTitleBarLabel(vm.isNew, f.type), color = peachColors.T1,84 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,85 modifier = Modifier.align(Alignment.Center))86 Text(entryEditPrimaryActionLabel(vm.isNew, f.saving), color = if (canSave && !f.saving) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,87 modifier = Modifier.align(Alignment.CenterEnd)88 .clip(RoundedCornerShape(8.dp)).clickable(enabled = canSave && !f.saving) { vm.save(onBack) }.padding(8.dp))89 }9091 Box(Modifier.fillMaxSize().clipToBounds()) {92 93 94 if (watermarkAlpha > 0f) {95 Text(96 text = ghostWatermarkText(f.title),97 style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),98 color = peachColors.T0.copy(alpha = watermarkAlpha),99 maxLines = 1,100 softWrap = false,101 overflow = androidx.compose.ui.text.style.TextOverflow.Clip,102 modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),103 )104 }105 Column(106 Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),107 ) {108 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {109 listOf("login", "note", "card").forEach { t ->110 EntryTypePill(t, f.type == t) {111 112 113 if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)114 else vm.setType(t)115 }116 }117 }118 Spacer(Modifier.height(18.dp))119120 Field("title", f.title, vm::setTitle)121 if (f.type == "login") {122 Field("url", f.url, vm::setUrl)123 AdditionalUrlsEditor(124 urls = f.additionalUrls,125 onChange = vm::setAdditionalUrl,126 onAdd = vm::addAdditionalUrl,127 onRemove = vm::removeAdditionalUrl,128 )129 IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)130 IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)131 PasswordField(132 value = f.password,133 show = f.showPassword,134 generatorOpen = f.generatorOpen,135 onChange = vm::setPassword,136 onToggleShow = vm::toggleShowPassword,137 onToggleGenerator = vm::toggleGenerator,138 )139 GeneratorDrawer(140 open = f.generatorOpen,141 length = f.genLength,142 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,143 password = f.password,144 copied = f.genCopied,145 onLength = vm::setGenLength,146 onUpper = vm::setGenUpper, onLower = vm::setGenLower,147 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,148 onGenerate = vm::generate,149 onCopy = vm::copyGenerated,150 onUse = vm::useGeneratedPassword,151 )152 PasswordHistorySection(153 history = f.history,154 expanded = f.showHistory,155 onToggle = vm::toggleHistory,156 onRestore = vm::restorePassword,157 onCopy = vm::copyHistoryPassword,158 )159 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {160 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)161 }162 }163 if (f.type == "card") {164 CardEditFields(165 cardNickname = f.cardNickname,166 cardNumber = f.cardNumber,167 cardholderName = f.cardholderName,168 expiry = f.expiry,169 cvc = f.cvc,170 showCvc = f.showCvc,171 network = f.cardNetwork,172 onCardNickname = vm::setCardNickname,173 onCardNumber = vm::setCardNumber,174 onCardholder = vm::setCardholderName,175 onExpiry = vm::setExpiry,176 onCvc = vm::setCvc,177 onToggleShowCvc = vm::toggleShowCvc,178 )179 }180 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {181 Field("notes", f.notes, vm::setNotes, singleLine = false)182 }183 if (f.type == "login") {184 OptionalLoginAddLinks(185 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),186 showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),187 onAddTotp = vm::revealTotp,188 onAddNotes = vm::revealNotes,189 )190 }191 TagChipEditor(192 tags = f.tags,193 allTags = vm.allTags,194 onAdd = vm::addTag,195 onRemove = vm::removeTag,196 )197198 f.error?.let {199 Spacer(Modifier.height(8.dp))200 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)201 }202 Spacer(Modifier.height(40.dp))203 }204 }205 }206 }207}208209@Composable210private fun EntryTypePill(type: String, selected: Boolean, onClick: () -> Unit) {211 val accent = LocalPeachAccent.current212 Box(213 modifier = Modifier214 .clip(RoundedCornerShape(PeachRadius.TypeTab))215 .border(216 1.dp,217 if (selected) accent.copy(alpha = 0.5f) else peachColors.T3,218 RoundedCornerShape(PeachRadius.TypeTab),219 )220 .background(if (selected) accent.copy(alpha = 0.08f) else Color.Transparent)221 .clickable(onClick = onClick)222 .padding(horizontal = 12.dp, vertical = 5.dp),223 ) {224 Text(225 text = type.uppercase(),226 color = if (selected) accent else peachColors.T2,227 fontFamily = PeachType.Mono,228 fontSize = 9.sp,229 letterSpacing = 0.08.em,230 )231 }232}233234235internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {236 !isNew -> "edit"237 type == "note" -> "new note"238 type == "card" -> "new card"239 else -> "new entry"240}241242internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {243 saving -> "saving..."244 isNew -> "create"245 else -> "save"246}247248internal fun canSaveEditForm(form: EditForm): Boolean {249 if (form.title.isBlank()) return false250 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false251 return true252}253254255internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"256257258internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f259260internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =261 type == "login" && (revealed || totp.isNotBlank())262263internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =264 type == "login" && (revealed || notes.isNotBlank())265266internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =267 type == "login" && !shouldShowLoginTotp(type, revealed, totp)268269internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =270 type == "login" && !shouldShowLoginNotes(type, revealed, notes)271272@Composable273private fun OptionalLoginAddLinks(274 showTotpLink: Boolean,275 showNotesLink: Boolean,276 onAddTotp: () -> Unit,277 onAddNotes: () -> Unit,278) {279 if (!showTotpLink && !showNotesLink) return280 Row(281 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),282 horizontalArrangement = Arrangement.spacedBy(14.dp),283 ) {284 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)285 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)286 }287}288289@Composable290private fun AddFieldLink(label: String, onClick: () -> Unit) {291 Text(292 label,293 color = LocalPeachAccent.current,294 fontFamily = PeachType.Mono,295 fontSize = 11.sp,296 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),297 )298}299300@Composable301private fun Field(302 label: String,303 value: String,304 onChange: (String) -> Unit,305 singleLine: Boolean = true,306 transformation: VisualTransformation = VisualTransformation.None,307 trailing: (@Composable () -> Unit)? = null,308) {309 PeachField(310 label = label,311 value = value,312 onChange = onChange,313 singleLine = singleLine,314 mono = true,315 visualTransformation = transformation,316 trailing = trailing,317 )318}319320@Composable321private fun PasswordField(322 value: String,323 show: Boolean,324 generatorOpen: Boolean,325 onChange: (String) -> Unit,326 onToggleShow: () -> Unit,327 onToggleGenerator: () -> Unit,328) {329 Field(330 label = "password",331 value = value,332 onChange = onChange,333 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),334 trailing = {335 Text(if (show) "hide" else "show", color = LocalPeachAccent.current,336 fontFamily = PeachType.Mono, fontSize = 9.sp,337 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))338 Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,339 fontFamily = PeachType.Mono, fontSize = 9.sp,340 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))341 },342 )343}344345internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"346347@Composable348private fun GeneratorDrawer(349 open: Boolean,350 length: Int,351 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,352 password: String,353 copied: Boolean,354 onLength: (Int) -> Unit,355 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,356 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,357 onGenerate: () -> Unit,358 onCopy: () -> Unit,359 onUse: () -> Unit,360) {361 val height by animateDpAsState(362 targetValue = if (open) 326.dp else 0.dp,363 animationSpec = tween(320, easing = PeachMotion.Spring),364 label = "gen-drawer",365 )366 Box(367 Modifier.fillMaxWidth().height(height).clipToBounds()368 .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),369 ) {370 GeneratorBody(371 length = length,372 upper = upper, lower = lower, numbers = numbers, symbols = symbols,373 password = password, copied = copied,374 onLength = onLength,375 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,376 onGenerate = onGenerate, onCopy = onCopy,377 showOutput = true,378 onUse = onUse,379 )380 }381 if (open) Spacer(Modifier.height(14.dp))382}383384385386387388@Composable389internal fun GeneratorBody(390 length: Int,391 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,392 password: String,393 copied: Boolean,394 onLength: (Int) -> Unit,395 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,396 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,397 onGenerate: () -> Unit,398 onCopy: () -> Unit,399 showOutput: Boolean = false,400 onUse: (() -> Unit)? = null,401) {402 val accent = LocalPeachAccent.current403 Column(Modifier.fillMaxWidth().padding(16.dp)) {404 if (showOutput) {405 Box(406 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))407 .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))408 .background(accent.copy(alpha = 0.08f))409 .padding(horizontal = 12.dp, vertical = 10.dp),410 ) {411 Text(412 text = password.ifEmpty { "tap generate" },413 color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,414 fontFamily = PeachType.Mono,415 fontSize = 11.sp,416 maxLines = 2,417 overflow = TextOverflow.Ellipsis,418 )419 }420 Spacer(Modifier.height(10.dp))421 }422 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {423 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)424 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)425 }426 Slider(427 value = length.toFloat(),428 onValueChange = { onLength(it.toInt()) },429 valueRange = 8f..128f,430 colors = SliderDefaults.colors(431 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,432 ),433 )434 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {435 TogglePill("A-Z", upper, onUpper)436 TogglePill("a-z", lower, onLower)437 TogglePill("0-9", numbers, onNumbers)438 TogglePill("!@#", symbols, onSymbols)439 }440 Spacer(Modifier.height(12.dp))441 Row(verticalAlignment = Alignment.CenterVertically) {442 Box(443 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))444 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))445 .background(accent.copy(alpha = 0.10f))446 .clickable(onClick = onGenerate).padding(vertical = 12.dp),447 contentAlignment = Alignment.Center,448 ) {449 Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)450 }451 Spacer(Modifier.width(8.dp))452 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })453 }454 onUse?.let {455 Spacer(Modifier.height(10.dp))456 Box(457 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))458 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))459 .background(accent.copy(alpha = 0.14f))460 .clickable(enabled = password.isNotEmpty(), onClick = it)461 .padding(vertical = 12.dp),462 contentAlignment = Alignment.Center,463 ) {464 Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)465 }466 }467 }468}469470@Composable471private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {472 val accent = LocalPeachAccent.current473 Box(474 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))475 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))476 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)477 .clickable { onToggle(!on) }478 .padding(horizontal = 10.dp, vertical = 6.dp),479 ) {480 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)481 }482}483484485