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.FontWeight38import androidx.compose.ui.text.input.PasswordVisualTransformation39import androidx.compose.ui.text.input.VisualTransformation40import androidx.compose.ui.text.style.TextOverflow41import androidx.compose.ui.unit.dp42import androidx.compose.ui.unit.em43import androidx.compose.ui.unit.sp44import androidx.compose.runtime.collectAsState45import androidx.hilt.navigation.compose.hiltViewModel46import com.peachpasswords.ui.components.CopyButton47import com.peachpasswords.ui.components.PeachEmptyOrEndState48import com.peachpasswords.ui.components.PeachSegment49import com.peachpasswords.ui.components.PeachSegmentedControl50import com.peachpasswords.ui.components.PeachTopBar51import com.peachpasswords.ui.components.SecureFlagEffect52import com.peachpasswords.ui.theme.LocalPeachAccent53import com.peachpasswords.ui.theme.PeachMotion54import com.peachpasswords.ui.theme.PeachRadius55import com.peachpasswords.ui.theme.PeachSpacing56import com.peachpasswords.ui.theme.PeachType57import com.peachpasswords.ui.theme.ShellBackground58import com.peachpasswords.ui.theme.peachColors5960@Composable61fun EntryEditScreen(62 id: String?,63 onBack: () -> Unit,64 onOpenNote: (String) -> Unit = {},65 vm: EntryEditViewModel = hiltViewModel(),66) {67 SecureFlagEffect()68 val f by vm.form.collectAsState()69 val usernameSuggestions by vm.usernameSuggestions.collectAsState()70 val emailSuggestions by vm.emailSuggestions.collectAsState()7172 73 val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(74 targetValue = editGhostWatermarkTarget(f.title),75 animationSpec = tween(300),76 label = "edit-watermark",77 )7879 ShellBackground {80 Column(81 Modifier82 .fillMaxSize()83 .statusBarsPadding()84 .padding(horizontal = PeachSpacing.xl),85 ) {86 EntryEditTopBar(87 title = editTitleBarLabel(vm.isNew, f.type),88 saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),89 canSave = canSaveEditForm(f) && !f.saving,90 onCancel = onBack,91 onSave = { vm.save(onBack) },92 )9394 Spacer(Modifier.height(PeachSpacing.lg))9596 Box(Modifier.fillMaxSize().clipToBounds()) {97 98 99 if (watermarkAlpha > 0f) {100 Text(101 text = ghostWatermarkText(f.title),102 style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),103 color = peachColors.T0.copy(alpha = watermarkAlpha),104 maxLines = 1,105 softWrap = false,106 overflow = androidx.compose.ui.text.style.TextOverflow.Clip,107 modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),108 )109 }110 Column(111 Modifier.fillMaxSize().verticalScroll(rememberScrollState()),112 ) {113 PeachSegmentedControl(114 segments = listOf(115 PeachSegment("login", "login"),116 PeachSegment("note", "note"),117 PeachSegment("card", "card"),118 ),119 selectedKey = f.type,120 onSelected = { t ->121 122 123 if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)124 else vm.setType(t)125 },126 )127 Spacer(Modifier.height(PeachSpacing.lg))128129 Field("title", f.title, vm::setTitle)130 if (f.type == "login") {131 Field("url", f.url, vm::setUrl)132 AdditionalUrlsEditor(133 urls = f.additionalUrls,134 onChange = vm::setAdditionalUrl,135 onAdd = vm::addAdditionalUrl,136 onRemove = vm::removeAdditionalUrl,137 )138 IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)139 IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)140 PasswordField(141 value = f.password,142 show = f.showPassword,143 generatorOpen = f.generatorOpen,144 onChange = vm::setPassword,145 onToggleShow = vm::toggleShowPassword,146 onToggleGenerator = vm::toggleGenerator,147 )148 GeneratorDrawer(149 open = f.generatorOpen,150 length = f.genLength,151 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,152 password = f.password,153 copied = f.genCopied,154 onLength = vm::setGenLength,155 onUpper = vm::setGenUpper, onLower = vm::setGenLower,156 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,157 onGenerate = vm::generate,158 onCopy = vm::copyGenerated,159 onUse = vm::useGeneratedPassword,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 = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),196 onAddTotp = vm::revealTotp,197 onAddNotes = vm::revealNotes,198 )199 }200 TagChipEditor(201 tags = f.tags,202 allTags = vm.allTags,203 onAdd = vm::addTag,204 onRemove = vm::removeTag,205 )206207 f.error?.let {208 Spacer(Modifier.height(PeachSpacing.sm))209 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)210 }211 PeachEmptyOrEndState(212 message = editEndStateMessage(f.type),213 detail = "Review the fields above, then save from the top bar.",214 modifier = Modifier.padding(top = PeachSpacing.md, bottom = PeachSpacing.xl),215 )216 }217 }218 }219 }220}221222@Composable223private fun EntryEditTopBar(224 title: String,225 saveLabel: String,226 canSave: Boolean,227 onCancel: () -> Unit,228 onSave: () -> Unit,229) {230 val accent = LocalPeachAccent.current231 PeachTopBar(232 title = title,233 leading = {234 Text(235 "cancel",236 color = peachColors.T2,237 fontFamily = PeachType.Mono,238 fontSize = 11.sp,239 modifier = Modifier240 .clip(RoundedCornerShape(8.dp))241 .clickable(onClick = onCancel)242 .padding(PeachSpacing.sm),243 )244 },245 actions = {246 Text(247 saveLabel,248 color = if (canSave) accent else peachColors.T2,249 fontFamily = PeachType.Mono,250 fontSize = 11.sp,251 modifier = Modifier252 .clip(RoundedCornerShape(8.dp))253 .clickable(enabled = canSave, onClick = onSave)254 .padding(PeachSpacing.sm),255 )256 },257 )258}259260261internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {262 !isNew -> "edit"263 type == "note" -> "new note"264 type == "card" -> "new card"265 else -> "new entry"266}267268internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {269 saving -> "saving..."270 isNew -> "create"271 else -> "save"272}273274internal fun canSaveEditForm(form: EditForm): Boolean {275 if (form.title.isBlank()) return false276 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false277 return true278}279280281internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"282283284internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f285286internal fun editEndStateMessage(type: String): String = when (type) {287 "card" -> "card details ready"288 "note" -> "note details ready"289 else -> "login details ready"290}291292internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =293 type == "login" && (revealed || totp.isNotBlank())294295internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =296 type == "login" && (revealed || notes.isNotBlank())297298internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =299 type == "login" && !shouldShowLoginTotp(type, revealed, totp)300301internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =302 type == "login" && !shouldShowLoginNotes(type, revealed, notes)303304@Composable305private fun OptionalLoginAddLinks(306 showTotpLink: Boolean,307 showNotesLink: Boolean,308 onAddTotp: () -> Unit,309 onAddNotes: () -> Unit,310) {311 if (!showTotpLink && !showNotesLink) return312 Row(313 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),314 horizontalArrangement = Arrangement.spacedBy(14.dp),315 ) {316 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)317 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)318 }319}320321@Composable322private fun AddFieldLink(label: String, onClick: () -> Unit) {323 Text(324 label,325 color = LocalPeachAccent.current,326 fontFamily = PeachType.Mono,327 fontSize = 11.sp,328 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),329 )330}331332@Composable333private fun Field(334 label: String,335 value: String,336 onChange: (String) -> Unit,337 singleLine: Boolean = true,338 transformation: VisualTransformation = VisualTransformation.None,339 trailing: (@Composable () -> Unit)? = null,340) {341 PeachField(342 label = label,343 value = value,344 onChange = onChange,345 singleLine = singleLine,346 mono = true,347 visualTransformation = transformation,348 trailing = trailing,349 )350}351352@Composable353private fun PasswordField(354 value: String,355 show: Boolean,356 generatorOpen: Boolean,357 onChange: (String) -> Unit,358 onToggleShow: () -> Unit,359 onToggleGenerator: () -> Unit,360) {361 Field(362 label = "password",363 value = value,364 onChange = onChange,365 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),366 trailing = {367 Text(if (show) "hide" else "show", color = LocalPeachAccent.current,368 fontFamily = PeachType.Mono, fontSize = 9.sp,369 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))370 Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,371 fontFamily = PeachType.Mono, fontSize = 9.sp,372 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))373 },374 )375}376377internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"378379@Composable380private fun GeneratorDrawer(381 open: Boolean,382 length: Int,383 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,384 password: String,385 copied: Boolean,386 onLength: (Int) -> Unit,387 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,388 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,389 onGenerate: () -> Unit,390 onCopy: () -> Unit,391 onUse: () -> Unit,392) {393 val height by animateDpAsState(394 targetValue = if (open) 326.dp else 0.dp,395 animationSpec = tween(320, easing = PeachMotion.Spring),396 label = "gen-drawer",397 )398 Box(399 Modifier.fillMaxWidth().height(height).clipToBounds()400 .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),401 ) {402 GeneratorBody(403 length = length,404 upper = upper, lower = lower, numbers = numbers, symbols = symbols,405 password = password, copied = copied,406 onLength = onLength,407 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,408 onGenerate = onGenerate, onCopy = onCopy,409 showOutput = true,410 onUse = onUse,411 )412 }413 if (open) Spacer(Modifier.height(14.dp))414}415416417418419420@Composable421internal fun GeneratorBody(422 length: Int,423 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,424 password: String,425 copied: Boolean,426 onLength: (Int) -> Unit,427 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,428 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,429 onGenerate: () -> Unit,430 onCopy: () -> Unit,431 showOutput: Boolean = false,432 onUse: (() -> Unit)? = null,433) {434 val accent = LocalPeachAccent.current435 Column(Modifier.fillMaxWidth().padding(16.dp)) {436 if (showOutput) {437 Box(438 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))439 .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))440 .background(accent.copy(alpha = 0.08f))441 .padding(horizontal = 12.dp, vertical = 10.dp),442 ) {443 Text(444 text = password.ifEmpty { "tap generate" },445 color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,446 fontFamily = PeachType.Mono,447 fontSize = 11.sp,448 maxLines = 2,449 overflow = TextOverflow.Ellipsis,450 )451 }452 Spacer(Modifier.height(10.dp))453 }454 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {455 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)456 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)457 }458 Slider(459 value = length.toFloat(),460 onValueChange = { onLength(it.toInt()) },461 valueRange = 8f..128f,462 colors = SliderDefaults.colors(463 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,464 ),465 )466 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {467 TogglePill("A-Z", upper, onUpper)468 TogglePill("a-z", lower, onLower)469 TogglePill("0-9", numbers, onNumbers)470 TogglePill("!@#", symbols, onSymbols)471 }472 Spacer(Modifier.height(12.dp))473 Row(verticalAlignment = Alignment.CenterVertically) {474 Box(475 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))476 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))477 .background(accent.copy(alpha = 0.10f))478 .clickable(onClick = onGenerate).padding(vertical = 12.dp),479 contentAlignment = Alignment.Center,480 ) {481 Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)482 }483 Spacer(Modifier.width(8.dp))484 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })485 }486 onUse?.let {487 Spacer(Modifier.height(10.dp))488 Box(489 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))490 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))491 .background(accent.copy(alpha = 0.14f))492 .clickable(enabled = password.isNotEmpty(), onClick = it)493 .padding(vertical = 12.dp),494 contentAlignment = Alignment.Center,495 ) {496 Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)497 }498 }499 }500}501502@Composable503private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {504 val accent = LocalPeachAccent.current505 Box(506 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))507 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))508 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)509 .clickable { onToggle(!on) }510 .padding(horizontal = 10.dp, vertical = 6.dp),511 ) {512 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)513 }514}515516517