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.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.theme.LocalPeachAccent48import com.peachpasswords.ui.theme.peachColors49import com.peachpasswords.ui.theme.PeachMotion50import com.peachpasswords.ui.theme.PeachRadius51import com.peachpasswords.ui.theme.PeachType52import com.peachpasswords.ui.theme.ShellBackground5354@Composable55fun EntryEditScreen(56 id: String?,57 onBack: () -> Unit,58 onOpenNote: (String) -> Unit = {},59 vm: EntryEditViewModel = hiltViewModel(),60) {61 val f by vm.form.collectAsState()62 val usernameSuggestions by vm.usernameSuggestions.collectAsState()63 val accent = LocalPeachAccent.current6465 66 val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(67 targetValue = editGhostWatermarkTarget(f.title),68 animationSpec = tween(300),69 label = "edit-watermark",70 )7172 ShellBackground {73 Column(Modifier.fillMaxSize().statusBarsPadding()) {74 Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 18.dp)) {75 val canSave = canSaveEditForm(f)76 Text("cancel", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,77 modifier = Modifier.align(Alignment.CenterStart)78 .clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(8.dp))79 Text(editTitleBarLabel(vm.isNew, f.type), color = peachColors.T1,80 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,81 modifier = Modifier.align(Alignment.Center))82 Text(entryEditPrimaryActionLabel(vm.isNew, f.saving), color = if (canSave && !f.saving) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,83 modifier = Modifier.align(Alignment.CenterEnd)84 .clip(RoundedCornerShape(8.dp)).clickable(enabled = canSave && !f.saving) { vm.save(onBack) }.padding(8.dp))85 }8687 Box(Modifier.fillMaxSize().clipToBounds()) {88 89 90 if (watermarkAlpha > 0f) {91 Text(92 text = ghostWatermarkText(f.title),93 style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),94 color = peachColors.T0.copy(alpha = watermarkAlpha),95 maxLines = 1,96 softWrap = false,97 overflow = androidx.compose.ui.text.style.TextOverflow.Clip,98 modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),99 )100 }101 Column(102 Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),103 ) {104 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {105 listOf("login", "note", "card").forEach { t ->106 EntryTypePill(t, f.type == t) {107 108 109 if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)110 else vm.setType(t)111 }112 }113 }114 Spacer(Modifier.height(18.dp))115116 Field("title", f.title, vm::setTitle)117 if (f.type == "login") {118 Field("url", f.url, vm::setUrl)119 AdditionalUrlsEditor(120 urls = f.additionalUrls,121 onChange = vm::setAdditionalUrl,122 onAdd = vm::addAdditionalUrl,123 onRemove = vm::removeAdditionalUrl,124 )125 IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)126 IdentitySuggestField("email", f.email, usernameSuggestions, vm::setEmail)127 PasswordField(128 value = f.password,129 show = f.showPassword,130 onChange = vm::setPassword,131 onToggleShow = vm::toggleShowPassword,132 onToggleGenerator = vm::toggleGenerator,133 )134 GeneratorDrawer(135 open = f.generatorOpen,136 length = f.genLength,137 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,138 password = f.password,139 copied = f.genCopied,140 onLength = vm::setGenLength,141 onUpper = vm::setGenUpper, onLower = vm::setGenLower,142 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,143 onGenerate = vm::generate,144 onCopy = vm::copyGenerated,145 )146 PasswordHistorySection(147 history = f.history,148 expanded = f.showHistory,149 onToggle = vm::toggleHistory,150 onRestore = vm::restorePassword,151 onCopy = vm::copyHistoryPassword,152 )153 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {154 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)155 }156 }157 if (f.type == "card") {158 CardEditFields(159 cardNumber = f.cardNumber,160 cardholderName = f.cardholderName,161 expiry = f.expiry,162 cvc = f.cvc,163 showCvc = f.showCvc,164 network = f.cardNetwork,165 onCardNumber = vm::setCardNumber,166 onCardholder = vm::setCardholderName,167 onExpiry = vm::setExpiry,168 onCvc = vm::setCvc,169 onToggleShowCvc = vm::toggleShowCvc,170 )171 }172 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {173 Field("notes", f.notes, vm::setNotes, singleLine = false)174 }175 if (f.type == "login") {176 OptionalLoginAddLinks(177 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),178 showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),179 onAddTotp = vm::revealTotp,180 onAddNotes = vm::revealNotes,181 )182 }183 TagChipEditor(184 tags = f.tags,185 allTags = vm.allTags,186 onAdd = vm::addTag,187 onRemove = vm::removeTag,188 )189190 f.error?.let {191 Spacer(Modifier.height(8.dp))192 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)193 }194 Spacer(Modifier.height(40.dp))195 }196 }197 }198 }199}200201@Composable202private fun EntryTypePill(type: String, selected: Boolean, onClick: () -> Unit) {203 val accent = LocalPeachAccent.current204 Box(205 modifier = Modifier206 .clip(RoundedCornerShape(PeachRadius.TypeTab))207 .border(208 1.dp,209 if (selected) accent.copy(alpha = 0.5f) else peachColors.T3,210 RoundedCornerShape(PeachRadius.TypeTab),211 )212 .background(if (selected) accent.copy(alpha = 0.08f) else Color.Transparent)213 .clickable(onClick = onClick)214 .padding(horizontal = 12.dp, vertical = 5.dp),215 ) {216 Text(217 text = type.uppercase(),218 color = if (selected) accent else peachColors.T2,219 fontFamily = PeachType.Mono,220 fontSize = 9.sp,221 letterSpacing = 0.08.em,222 )223 }224}225226227internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {228 !isNew -> "edit"229 type == "note" -> "new note"230 type == "card" -> "new card"231 else -> "new entry"232}233234internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {235 saving -> "saving..."236 isNew -> "create"237 else -> "save"238}239240internal fun canSaveEditForm(form: EditForm): Boolean {241 if (form.title.isBlank()) return false242 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false243 return true244}245246247internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"248249250internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f251252internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =253 type == "login" && (revealed || totp.isNotBlank())254255internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =256 type == "login" && (revealed || notes.isNotBlank())257258internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =259 type == "login" && !shouldShowLoginTotp(type, revealed, totp)260261internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =262 type == "login" && !shouldShowLoginNotes(type, revealed, notes)263264@Composable265private fun OptionalLoginAddLinks(266 showTotpLink: Boolean,267 showNotesLink: Boolean,268 onAddTotp: () -> Unit,269 onAddNotes: () -> Unit,270) {271 if (!showTotpLink && !showNotesLink) return272 Row(273 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),274 horizontalArrangement = Arrangement.spacedBy(14.dp),275 ) {276 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)277 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)278 }279}280281@Composable282private fun AddFieldLink(label: String, onClick: () -> Unit) {283 Text(284 label,285 color = LocalPeachAccent.current,286 fontFamily = PeachType.Mono,287 fontSize = 11.sp,288 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),289 )290}291292@Composable293private fun Field(294 label: String,295 value: String,296 onChange: (String) -> Unit,297 singleLine: Boolean = true,298 transformation: VisualTransformation = VisualTransformation.None,299 trailing: (@Composable () -> Unit)? = null,300) {301 PeachField(302 label = label,303 value = value,304 onChange = onChange,305 singleLine = singleLine,306 mono = true,307 visualTransformation = transformation,308 trailing = trailing,309 )310}311312@Composable313private fun PasswordField(314 value: String,315 show: Boolean,316 onChange: (String) -> Unit,317 onToggleShow: () -> Unit,318 onToggleGenerator: () -> Unit,319) {320 Field(321 label = "password",322 value = value,323 onChange = onChange,324 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),325 trailing = {326 Text(if (show) "hide" else "show", color = LocalPeachAccent.current,327 fontFamily = PeachType.Mono, fontSize = 9.sp,328 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))329 Text("⟳", color = LocalPeachAccent.current, fontSize = 16.sp,330 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))331 },332 )333}334335@Composable336private fun GeneratorDrawer(337 open: Boolean,338 length: Int,339 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,340 password: String,341 copied: Boolean,342 onLength: (Int) -> Unit,343 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,344 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,345 onGenerate: () -> Unit,346 onCopy: () -> Unit,347) {348 val height by animateDpAsState(349 targetValue = if (open) 234.dp else 0.dp,350 animationSpec = tween(320, easing = PeachMotion.Spring),351 label = "gen-drawer",352 )353 Box(354 Modifier.fillMaxWidth().height(height).clipToBounds()355 .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),356 ) {357 GeneratorBody(358 length = length,359 upper = upper, lower = lower, numbers = numbers, symbols = symbols,360 password = password, copied = copied,361 onLength = onLength,362 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,363 onGenerate = onGenerate, onCopy = onCopy,364 )365 }366 if (open) Spacer(Modifier.height(14.dp))367}368369370371372373@Composable374internal fun GeneratorBody(375 length: Int,376 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,377 password: String,378 copied: Boolean,379 onLength: (Int) -> Unit,380 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,381 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,382 onGenerate: () -> Unit,383 onCopy: () -> Unit,384) {385 val accent = LocalPeachAccent.current386 Column(Modifier.fillMaxWidth().padding(16.dp)) {387 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {388 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)389 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)390 }391 Slider(392 value = length.toFloat(),393 onValueChange = { onLength(it.toInt()) },394 valueRange = 8f..128f,395 colors = SliderDefaults.colors(396 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,397 ),398 )399 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {400 TogglePill("A-Z", upper, onUpper)401 TogglePill("a-z", lower, onLower)402 TogglePill("0-9", numbers, onNumbers)403 TogglePill("!@#", symbols, onSymbols)404 }405 Spacer(Modifier.height(12.dp))406 Row(verticalAlignment = Alignment.CenterVertically) {407 Box(408 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))409 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))410 .background(accent.copy(alpha = 0.10f))411 .clickable(onClick = onGenerate).padding(vertical = 12.dp),412 contentAlignment = Alignment.Center,413 ) {414 Text("generate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)415 }416 Spacer(Modifier.width(8.dp))417 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })418 }419 }420}421422@Composable423private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {424 val accent = LocalPeachAccent.current425 Box(426 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))427 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))428 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)429 .clickable { onToggle(!on) }430 .padding(horizontal = 10.dp, vertical = 6.dp),431 ) {432 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)433 }434}435436437