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 Text("cancel", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,76 modifier = Modifier.align(Alignment.CenterStart)77 .clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(8.dp))78 Text(editTitleBarLabel(vm.isNew, f.type), color = peachColors.T1,79 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,80 modifier = Modifier.align(Alignment.Center))81 Text("save", color = accent, fontFamily = PeachType.Mono, fontSize = 11.sp,82 modifier = Modifier.align(Alignment.CenterEnd)83 .clip(RoundedCornerShape(8.dp)).clickable { vm.save(onBack) }.padding(8.dp))84 }8586 Box(Modifier.fillMaxSize().clipToBounds()) {87 88 89 if (watermarkAlpha > 0f) {90 Text(91 text = ghostWatermarkText(f.title),92 style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),93 color = peachColors.T0.copy(alpha = watermarkAlpha),94 maxLines = 1,95 softWrap = false,96 overflow = androidx.compose.ui.text.style.TextOverflow.Clip,97 modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),98 )99 }100 Column(101 Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),102 ) {103 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {104 listOf("login", "note", "card").forEach { t ->105 EntryTypePill(t, f.type == t) {106 107 108 if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)109 else vm.setType(t)110 }111 }112 }113 Spacer(Modifier.height(18.dp))114115 Field("title", f.title, vm::setTitle)116 if (f.type == "login") {117 Field("url", f.url, vm::setUrl)118 AdditionalUrlsEditor(119 urls = f.additionalUrls,120 onChange = vm::setAdditionalUrl,121 onAdd = vm::addAdditionalUrl,122 onRemove = vm::removeAdditionalUrl,123 )124 IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)125 IdentitySuggestField("email", f.email, usernameSuggestions, vm::setEmail)126 PasswordField(127 value = f.password,128 show = f.showPassword,129 onChange = vm::setPassword,130 onToggleShow = vm::toggleShowPassword,131 onToggleGenerator = vm::toggleGenerator,132 )133 GeneratorDrawer(134 open = f.generatorOpen,135 length = f.genLength,136 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,137 password = f.password,138 copied = f.genCopied,139 onLength = vm::setGenLength,140 onUpper = vm::setGenUpper, onLower = vm::setGenLower,141 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,142 onGenerate = vm::generate,143 onCopy = vm::copyGenerated,144 )145 PasswordHistorySection(146 history = f.history,147 expanded = f.showHistory,148 onToggle = vm::toggleHistory,149 onRestore = vm::restorePassword,150 onCopy = vm::copyHistoryPassword,151 )152 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {153 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)154 }155 }156 if (f.type == "card") {157 CardEditFields(158 cardNumber = f.cardNumber,159 cardholderName = f.cardholderName,160 expiry = f.expiry,161 cvc = f.cvc,162 showCvc = f.showCvc,163 network = f.cardNetwork,164 onCardNumber = vm::setCardNumber,165 onCardholder = vm::setCardholderName,166 onExpiry = vm::setExpiry,167 onCvc = vm::setCvc,168 onToggleShowCvc = vm::toggleShowCvc,169 )170 }171 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {172 Field("notes", f.notes, vm::setNotes, singleLine = false)173 }174 if (f.type == "login") {175 OptionalLoginAddLinks(176 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),177 showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),178 onAddTotp = vm::revealTotp,179 onAddNotes = vm::revealNotes,180 )181 }182 TagChipEditor(183 tags = f.tags,184 allTags = vm.allTags,185 onAdd = vm::addTag,186 onRemove = vm::removeTag,187 )188189 f.error?.let {190 Spacer(Modifier.height(8.dp))191 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)192 }193 Spacer(Modifier.height(40.dp))194 }195 }196 }197 }198}199200@Composable201private fun EntryTypePill(type: String, selected: Boolean, onClick: () -> Unit) {202 val accent = LocalPeachAccent.current203 Box(204 modifier = Modifier205 .clip(RoundedCornerShape(PeachRadius.TypeTab))206 .border(207 1.dp,208 if (selected) accent.copy(alpha = 0.5f) else peachColors.T3,209 RoundedCornerShape(PeachRadius.TypeTab),210 )211 .background(if (selected) accent.copy(alpha = 0.08f) else Color.Transparent)212 .clickable(onClick = onClick)213 .padding(horizontal = 12.dp, vertical = 5.dp),214 ) {215 Text(216 text = type.uppercase(),217 color = if (selected) accent else peachColors.T2,218 fontFamily = PeachType.Mono,219 fontSize = 9.sp,220 letterSpacing = 0.08.em,221 )222 }223}224225226internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {227 !isNew -> "edit"228 type == "note" -> "new note"229 type == "card" -> "new card"230 else -> "new entry"231}232233234internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"235236237internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f238239internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =240 type == "login" && (revealed || totp.isNotBlank())241242internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =243 type == "login" && (revealed || notes.isNotBlank())244245internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =246 type == "login" && !shouldShowLoginTotp(type, revealed, totp)247248internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =249 type == "login" && !shouldShowLoginNotes(type, revealed, notes)250251@Composable252private fun OptionalLoginAddLinks(253 showTotpLink: Boolean,254 showNotesLink: Boolean,255 onAddTotp: () -> Unit,256 onAddNotes: () -> Unit,257) {258 if (!showTotpLink && !showNotesLink) return259 Row(260 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),261 horizontalArrangement = Arrangement.spacedBy(14.dp),262 ) {263 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)264 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)265 }266}267268@Composable269private fun AddFieldLink(label: String, onClick: () -> Unit) {270 Text(271 label,272 color = LocalPeachAccent.current,273 fontFamily = PeachType.Mono,274 fontSize = 11.sp,275 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),276 )277}278279@Composable280private fun Field(281 label: String,282 value: String,283 onChange: (String) -> Unit,284 singleLine: Boolean = true,285 transformation: VisualTransformation = VisualTransformation.None,286 trailing: (@Composable () -> Unit)? = null,287) {288 PeachField(289 label = label,290 value = value,291 onChange = onChange,292 singleLine = singleLine,293 mono = true,294 visualTransformation = transformation,295 trailing = trailing,296 )297}298299@Composable300private fun PasswordField(301 value: String,302 show: Boolean,303 onChange: (String) -> Unit,304 onToggleShow: () -> Unit,305 onToggleGenerator: () -> Unit,306) {307 Field(308 label = "password",309 value = value,310 onChange = onChange,311 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),312 trailing = {313 Text(if (show) "hide" else "show", color = LocalPeachAccent.current,314 fontFamily = PeachType.Mono, fontSize = 9.sp,315 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))316 Text("⟳", color = LocalPeachAccent.current, fontSize = 16.sp,317 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))318 },319 )320}321322@Composable323private fun GeneratorDrawer(324 open: Boolean,325 length: Int,326 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,327 password: String,328 copied: Boolean,329 onLength: (Int) -> Unit,330 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,331 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,332 onGenerate: () -> Unit,333 onCopy: () -> Unit,334) {335 val height by animateDpAsState(336 targetValue = if (open) 234.dp else 0.dp,337 animationSpec = tween(320, easing = PeachMotion.Spring),338 label = "gen-drawer",339 )340 Box(341 Modifier.fillMaxWidth().height(height).clipToBounds()342 .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),343 ) {344 GeneratorBody(345 length = length,346 upper = upper, lower = lower, numbers = numbers, symbols = symbols,347 password = password, copied = copied,348 onLength = onLength,349 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,350 onGenerate = onGenerate, onCopy = onCopy,351 )352 }353 if (open) Spacer(Modifier.height(14.dp))354}355356357358359360@Composable361internal fun GeneratorBody(362 length: Int,363 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,364 password: String,365 copied: Boolean,366 onLength: (Int) -> Unit,367 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,368 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,369 onGenerate: () -> Unit,370 onCopy: () -> Unit,371) {372 val accent = LocalPeachAccent.current373 Column(Modifier.fillMaxWidth().padding(16.dp)) {374 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {375 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)376 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)377 }378 Slider(379 value = length.toFloat(),380 onValueChange = { onLength(it.toInt()) },381 valueRange = 8f..128f,382 colors = SliderDefaults.colors(383 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,384 ),385 )386 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {387 TogglePill("A-Z", upper, onUpper)388 TogglePill("a-z", lower, onLower)389 TogglePill("0-9", numbers, onNumbers)390 TogglePill("!@#", symbols, onSymbols)391 }392 Spacer(Modifier.height(12.dp))393 Row(verticalAlignment = Alignment.CenterVertically) {394 Box(395 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))396 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))397 .background(accent.copy(alpha = 0.10f))398 .clickable(onClick = onGenerate).padding(vertical = 12.dp),399 contentAlignment = Alignment.Center,400 ) {401 Text("generate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)402 }403 Spacer(Modifier.width(8.dp))404 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })405 }406 }407}408409@Composable410private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {411 val accent = LocalPeachAccent.current412 Box(413 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))414 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))415 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)416 .clickable { onToggle(!on) }417 .padding(horizontal = 10.dp, vertical = 6.dp),418 ) {419 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)420 }421}422423424