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.theme.LocalPeachAccent49import com.peachpasswords.ui.theme.peachColors50import com.peachpasswords.ui.theme.PeachMotion51import com.peachpasswords.ui.theme.PeachRadius52import com.peachpasswords.ui.theme.PeachType53import com.peachpasswords.ui.theme.ShellBackground5455@Composable56fun EntryEditScreen(57 id: String?,58 onBack: () -> Unit,59 onOpenNote: (String) -> Unit = {},60 vm: EntryEditViewModel = hiltViewModel(),61) {62 val f by vm.form.collectAsState()63 val usernameSuggestions by vm.usernameSuggestions.collectAsState()64 val emailSuggestions by vm.emailSuggestions.collectAsState()65 val accent = LocalPeachAccent.current6667 68 val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(69 targetValue = editGhostWatermarkTarget(f.title),70 animationSpec = tween(300),71 label = "edit-watermark",72 )7374 ShellBackground {75 Column(Modifier.fillMaxSize().statusBarsPadding()) {76 Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 18.dp)) {77 val canSave = canSaveEditForm(f)78 Text("cancel", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,79 modifier = Modifier.align(Alignment.CenterStart)80 .clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(8.dp))81 Text(editTitleBarLabel(vm.isNew, f.type), color = peachColors.T1,82 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,83 modifier = Modifier.align(Alignment.Center))84 Text(entryEditPrimaryActionLabel(vm.isNew, f.saving), color = if (canSave && !f.saving) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,85 modifier = Modifier.align(Alignment.CenterEnd)86 .clip(RoundedCornerShape(8.dp)).clickable(enabled = canSave && !f.saving) { vm.save(onBack) }.padding(8.dp))87 }8889 Box(Modifier.fillMaxSize().clipToBounds()) {90 91 92 if (watermarkAlpha > 0f) {93 Text(94 text = ghostWatermarkText(f.title),95 style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),96 color = peachColors.T0.copy(alpha = watermarkAlpha),97 maxLines = 1,98 softWrap = false,99 overflow = androidx.compose.ui.text.style.TextOverflow.Clip,100 modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),101 )102 }103 Column(104 Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),105 ) {106 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {107 listOf("login", "note", "card").forEach { t ->108 EntryTypePill(t, f.type == t) {109 110 111 if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)112 else vm.setType(t)113 }114 }115 }116 Spacer(Modifier.height(18.dp))117118 Field("title", f.title, vm::setTitle)119 if (f.type == "login") {120 Field("url", f.url, vm::setUrl)121 AdditionalUrlsEditor(122 urls = f.additionalUrls,123 onChange = vm::setAdditionalUrl,124 onAdd = vm::addAdditionalUrl,125 onRemove = vm::removeAdditionalUrl,126 )127 IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)128 IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)129 PasswordField(130 value = f.password,131 show = f.showPassword,132 generatorOpen = f.generatorOpen,133 onChange = vm::setPassword,134 onToggleShow = vm::toggleShowPassword,135 onToggleGenerator = vm::toggleGenerator,136 )137 GeneratorDrawer(138 open = f.generatorOpen,139 length = f.genLength,140 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,141 password = f.password,142 copied = f.genCopied,143 onLength = vm::setGenLength,144 onUpper = vm::setGenUpper, onLower = vm::setGenLower,145 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,146 onGenerate = vm::generate,147 onCopy = vm::copyGenerated,148 onUse = vm::useGeneratedPassword,149 )150 PasswordHistorySection(151 history = f.history,152 expanded = f.showHistory,153 onToggle = vm::toggleHistory,154 onRestore = vm::restorePassword,155 onCopy = vm::copyHistoryPassword,156 )157 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {158 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)159 }160 }161 if (f.type == "card") {162 CardEditFields(163 cardNickname = f.cardNickname,164 cardNumber = f.cardNumber,165 cardholderName = f.cardholderName,166 expiry = f.expiry,167 cvc = f.cvc,168 showCvc = f.showCvc,169 network = f.cardNetwork,170 onCardNickname = vm::setCardNickname,171 onCardNumber = vm::setCardNumber,172 onCardholder = vm::setCardholderName,173 onExpiry = vm::setExpiry,174 onCvc = vm::setCvc,175 onToggleShowCvc = vm::toggleShowCvc,176 )177 }178 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {179 Field("notes", f.notes, vm::setNotes, singleLine = false)180 }181 if (f.type == "login") {182 OptionalLoginAddLinks(183 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),184 showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),185 onAddTotp = vm::revealTotp,186 onAddNotes = vm::revealNotes,187 )188 }189 TagChipEditor(190 tags = f.tags,191 allTags = vm.allTags,192 onAdd = vm::addTag,193 onRemove = vm::removeTag,194 )195196 f.error?.let {197 Spacer(Modifier.height(8.dp))198 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)199 }200 Spacer(Modifier.height(40.dp))201 }202 }203 }204 }205}206207@Composable208private fun EntryTypePill(type: String, selected: Boolean, onClick: () -> Unit) {209 val accent = LocalPeachAccent.current210 Box(211 modifier = Modifier212 .clip(RoundedCornerShape(PeachRadius.TypeTab))213 .border(214 1.dp,215 if (selected) accent.copy(alpha = 0.5f) else peachColors.T3,216 RoundedCornerShape(PeachRadius.TypeTab),217 )218 .background(if (selected) accent.copy(alpha = 0.08f) else Color.Transparent)219 .clickable(onClick = onClick)220 .padding(horizontal = 12.dp, vertical = 5.dp),221 ) {222 Text(223 text = type.uppercase(),224 color = if (selected) accent else peachColors.T2,225 fontFamily = PeachType.Mono,226 fontSize = 9.sp,227 letterSpacing = 0.08.em,228 )229 }230}231232233internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {234 !isNew -> "edit"235 type == "note" -> "new note"236 type == "card" -> "new card"237 else -> "new entry"238}239240internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {241 saving -> "saving..."242 isNew -> "create"243 else -> "save"244}245246internal fun canSaveEditForm(form: EditForm): Boolean {247 if (form.title.isBlank()) return false248 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false249 return true250}251252253internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"254255256internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f257258internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =259 type == "login" && (revealed || totp.isNotBlank())260261internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =262 type == "login" && (revealed || notes.isNotBlank())263264internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =265 type == "login" && !shouldShowLoginTotp(type, revealed, totp)266267internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =268 type == "login" && !shouldShowLoginNotes(type, revealed, notes)269270@Composable271private fun OptionalLoginAddLinks(272 showTotpLink: Boolean,273 showNotesLink: Boolean,274 onAddTotp: () -> Unit,275 onAddNotes: () -> Unit,276) {277 if (!showTotpLink && !showNotesLink) return278 Row(279 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),280 horizontalArrangement = Arrangement.spacedBy(14.dp),281 ) {282 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)283 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)284 }285}286287@Composable288private fun AddFieldLink(label: String, onClick: () -> Unit) {289 Text(290 label,291 color = LocalPeachAccent.current,292 fontFamily = PeachType.Mono,293 fontSize = 11.sp,294 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),295 )296}297298@Composable299private fun Field(300 label: String,301 value: String,302 onChange: (String) -> Unit,303 singleLine: Boolean = true,304 transformation: VisualTransformation = VisualTransformation.None,305 trailing: (@Composable () -> Unit)? = null,306) {307 PeachField(308 label = label,309 value = value,310 onChange = onChange,311 singleLine = singleLine,312 mono = true,313 visualTransformation = transformation,314 trailing = trailing,315 )316}317318@Composable319private fun PasswordField(320 value: String,321 show: Boolean,322 generatorOpen: Boolean,323 onChange: (String) -> Unit,324 onToggleShow: () -> Unit,325 onToggleGenerator: () -> Unit,326) {327 Field(328 label = "password",329 value = value,330 onChange = onChange,331 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),332 trailing = {333 Text(if (show) "hide" else "show", color = LocalPeachAccent.current,334 fontFamily = PeachType.Mono, fontSize = 9.sp,335 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))336 Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,337 fontFamily = PeachType.Mono, fontSize = 9.sp,338 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))339 },340 )341}342343internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"344345@Composable346private fun GeneratorDrawer(347 open: Boolean,348 length: Int,349 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,350 password: String,351 copied: Boolean,352 onLength: (Int) -> Unit,353 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,354 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,355 onGenerate: () -> Unit,356 onCopy: () -> Unit,357 onUse: () -> Unit,358) {359 val height by animateDpAsState(360 targetValue = if (open) 326.dp else 0.dp,361 animationSpec = tween(320, easing = PeachMotion.Spring),362 label = "gen-drawer",363 )364 Box(365 Modifier.fillMaxWidth().height(height).clipToBounds()366 .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),367 ) {368 GeneratorBody(369 length = length,370 upper = upper, lower = lower, numbers = numbers, symbols = symbols,371 password = password, copied = copied,372 onLength = onLength,373 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,374 onGenerate = onGenerate, onCopy = onCopy,375 showOutput = true,376 onUse = onUse,377 )378 }379 if (open) Spacer(Modifier.height(14.dp))380}381382383384385386@Composable387internal fun GeneratorBody(388 length: Int,389 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,390 password: String,391 copied: Boolean,392 onLength: (Int) -> Unit,393 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,394 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,395 onGenerate: () -> Unit,396 onCopy: () -> Unit,397 showOutput: Boolean = false,398 onUse: (() -> Unit)? = null,399) {400 val accent = LocalPeachAccent.current401 Column(Modifier.fillMaxWidth().padding(16.dp)) {402 if (showOutput) {403 Box(404 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))405 .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))406 .background(accent.copy(alpha = 0.08f))407 .padding(horizontal = 12.dp, vertical = 10.dp),408 ) {409 Text(410 text = password.ifEmpty { "tap generate" },411 color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,412 fontFamily = PeachType.Mono,413 fontSize = 11.sp,414 maxLines = 2,415 overflow = TextOverflow.Ellipsis,416 )417 }418 Spacer(Modifier.height(10.dp))419 }420 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {421 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)422 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)423 }424 Slider(425 value = length.toFloat(),426 onValueChange = { onLength(it.toInt()) },427 valueRange = 8f..128f,428 colors = SliderDefaults.colors(429 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,430 ),431 )432 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {433 TogglePill("A-Z", upper, onUpper)434 TogglePill("a-z", lower, onLower)435 TogglePill("0-9", numbers, onNumbers)436 TogglePill("!@#", symbols, onSymbols)437 }438 Spacer(Modifier.height(12.dp))439 Row(verticalAlignment = Alignment.CenterVertically) {440 Box(441 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))442 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))443 .background(accent.copy(alpha = 0.10f))444 .clickable(onClick = onGenerate).padding(vertical = 12.dp),445 contentAlignment = Alignment.Center,446 ) {447 Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)448 }449 Spacer(Modifier.width(8.dp))450 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })451 }452 onUse?.let {453 Spacer(Modifier.height(10.dp))454 Box(455 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))456 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))457 .background(accent.copy(alpha = 0.14f))458 .clickable(enabled = password.isNotEmpty(), onClick = it)459 .padding(vertical = 12.dp),460 contentAlignment = Alignment.Center,461 ) {462 Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)463 }464 }465 }466}467468@Composable469private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {470 val accent = LocalPeachAccent.current471 Box(472 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))473 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))474 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)475 .clickable { onToggle(!on) }476 .padding(horizontal = 10.dp, vertical = 6.dp),477 ) {478 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)479 }480}481482483