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.theme.LocalPeachAccent52import com.peachpasswords.ui.theme.PeachMotion53import com.peachpasswords.ui.theme.PeachRadius54import com.peachpasswords.ui.theme.PeachSpacing55import com.peachpasswords.ui.theme.PeachType56import com.peachpasswords.ui.theme.ShellBackground57import com.peachpasswords.ui.theme.peachColors5859@Composable60fun EntryEditScreen(61 id: String?,62 onBack: () -> Unit,63 onOpenNote: (String) -> Unit = {},64 vm: EntryEditViewModel = hiltViewModel(),65) {66 val f by vm.form.collectAsState()67 val usernameSuggestions by vm.usernameSuggestions.collectAsState()68 val emailSuggestions by vm.emailSuggestions.collectAsState()6970 71 val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(72 targetValue = editGhostWatermarkTarget(f.title),73 animationSpec = tween(300),74 label = "edit-watermark",75 )7677 ShellBackground {78 Column(79 Modifier80 .fillMaxSize()81 .statusBarsPadding()82 .padding(horizontal = PeachSpacing.xl),83 ) {84 EntryEditTopBar(85 title = editTitleBarLabel(vm.isNew, f.type),86 saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),87 canSave = canSaveEditForm(f) && !f.saving,88 onCancel = onBack,89 onSave = { vm.save(onBack) },90 )9192 Spacer(Modifier.height(PeachSpacing.lg))9394 Box(Modifier.fillMaxSize().clipToBounds()) {95 96 97 if (watermarkAlpha > 0f) {98 Text(99 text = ghostWatermarkText(f.title),100 style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),101 color = peachColors.T0.copy(alpha = watermarkAlpha),102 maxLines = 1,103 softWrap = false,104 overflow = androidx.compose.ui.text.style.TextOverflow.Clip,105 modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),106 )107 }108 Column(109 Modifier.fillMaxSize().verticalScroll(rememberScrollState()),110 ) {111 PeachSegmentedControl(112 segments = listOf(113 PeachSegment("login", "login"),114 PeachSegment("note", "note"),115 PeachSegment("card", "card"),116 ),117 selectedKey = f.type,118 onSelected = { t ->119 120 121 if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)122 else vm.setType(t)123 },124 )125 Spacer(Modifier.height(PeachSpacing.lg))126127 Field("title", f.title, vm::setTitle)128 if (f.type == "login") {129 Field("url", f.url, vm::setUrl)130 AdditionalUrlsEditor(131 urls = f.additionalUrls,132 onChange = vm::setAdditionalUrl,133 onAdd = vm::addAdditionalUrl,134 onRemove = vm::removeAdditionalUrl,135 )136 IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)137 IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)138 PasswordField(139 value = f.password,140 show = f.showPassword,141 generatorOpen = f.generatorOpen,142 onChange = vm::setPassword,143 onToggleShow = vm::toggleShowPassword,144 onToggleGenerator = vm::toggleGenerator,145 )146 GeneratorDrawer(147 open = f.generatorOpen,148 length = f.genLength,149 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,150 password = f.password,151 copied = f.genCopied,152 onLength = vm::setGenLength,153 onUpper = vm::setGenUpper, onLower = vm::setGenLower,154 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,155 onGenerate = vm::generate,156 onCopy = vm::copyGenerated,157 onUse = vm::useGeneratedPassword,158 )159 PasswordHistorySection(160 history = f.history,161 expanded = f.showHistory,162 onToggle = vm::toggleHistory,163 onRestore = vm::restorePassword,164 onCopy = vm::copyHistoryPassword,165 )166 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {167 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)168 }169 }170 if (f.type == "card") {171 CardEditFields(172 cardNickname = f.cardNickname,173 cardNumber = f.cardNumber,174 cardholderName = f.cardholderName,175 expiry = f.expiry,176 cvc = f.cvc,177 showCvc = f.showCvc,178 network = f.cardNetwork,179 onCardNickname = vm::setCardNickname,180 onCardNumber = vm::setCardNumber,181 onCardholder = vm::setCardholderName,182 onExpiry = vm::setExpiry,183 onCvc = vm::setCvc,184 onToggleShowCvc = vm::toggleShowCvc,185 )186 }187 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {188 Field("notes", f.notes, vm::setNotes, singleLine = false)189 }190 if (f.type == "login") {191 OptionalLoginAddLinks(192 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),193 showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),194 onAddTotp = vm::revealTotp,195 onAddNotes = vm::revealNotes,196 )197 }198 TagChipEditor(199 tags = f.tags,200 allTags = vm.allTags,201 onAdd = vm::addTag,202 onRemove = vm::removeTag,203 )204205 f.error?.let {206 Spacer(Modifier.height(PeachSpacing.sm))207 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)208 }209 Spacer(Modifier.height(PeachSpacing.xl))210 }211 }212 }213 }214}215216@Composable217private fun EntryEditTopBar(218 title: String,219 saveLabel: String,220 canSave: Boolean,221 onCancel: () -> Unit,222 onSave: () -> Unit,223) {224 val accent = LocalPeachAccent.current225 PeachTopBar(226 title = title,227 leading = {228 Text(229 "cancel",230 color = peachColors.T2,231 fontFamily = PeachType.Mono,232 fontSize = 11.sp,233 modifier = Modifier234 .clip(RoundedCornerShape(8.dp))235 .clickable(onClick = onCancel)236 .padding(PeachSpacing.sm),237 )238 },239 actions = {240 Text(241 saveLabel,242 color = if (canSave) accent else peachColors.T2,243 fontFamily = PeachType.Mono,244 fontSize = 11.sp,245 modifier = Modifier246 .clip(RoundedCornerShape(8.dp))247 .clickable(enabled = canSave, onClick = onSave)248 .padding(PeachSpacing.sm),249 )250 },251 )252}253254255internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {256 !isNew -> "edit"257 type == "note" -> "new note"258 type == "card" -> "new card"259 else -> "new entry"260}261262internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {263 saving -> "saving..."264 isNew -> "create"265 else -> "save"266}267268internal fun canSaveEditForm(form: EditForm): Boolean {269 if (form.title.isBlank()) return false270 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false271 return true272}273274275internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"276277278internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f279280internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =281 type == "login" && (revealed || totp.isNotBlank())282283internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =284 type == "login" && (revealed || notes.isNotBlank())285286internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =287 type == "login" && !shouldShowLoginTotp(type, revealed, totp)288289internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =290 type == "login" && !shouldShowLoginNotes(type, revealed, notes)291292@Composable293private fun OptionalLoginAddLinks(294 showTotpLink: Boolean,295 showNotesLink: Boolean,296 onAddTotp: () -> Unit,297 onAddNotes: () -> Unit,298) {299 if (!showTotpLink && !showNotesLink) return300 Row(301 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),302 horizontalArrangement = Arrangement.spacedBy(14.dp),303 ) {304 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)305 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)306 }307}308309@Composable310private fun AddFieldLink(label: String, onClick: () -> Unit) {311 Text(312 label,313 color = LocalPeachAccent.current,314 fontFamily = PeachType.Mono,315 fontSize = 11.sp,316 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),317 )318}319320@Composable321private fun Field(322 label: String,323 value: String,324 onChange: (String) -> Unit,325 singleLine: Boolean = true,326 transformation: VisualTransformation = VisualTransformation.None,327 trailing: (@Composable () -> Unit)? = null,328) {329 PeachField(330 label = label,331 value = value,332 onChange = onChange,333 singleLine = singleLine,334 mono = true,335 visualTransformation = transformation,336 trailing = trailing,337 )338}339340@Composable341private fun PasswordField(342 value: String,343 show: Boolean,344 generatorOpen: Boolean,345 onChange: (String) -> Unit,346 onToggleShow: () -> Unit,347 onToggleGenerator: () -> Unit,348) {349 Field(350 label = "password",351 value = value,352 onChange = onChange,353 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),354 trailing = {355 Text(if (show) "hide" else "show", color = LocalPeachAccent.current,356 fontFamily = PeachType.Mono, fontSize = 9.sp,357 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))358 Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,359 fontFamily = PeachType.Mono, fontSize = 9.sp,360 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))361 },362 )363}364365internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"366367@Composable368private fun GeneratorDrawer(369 open: Boolean,370 length: Int,371 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,372 password: String,373 copied: Boolean,374 onLength: (Int) -> Unit,375 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,376 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,377 onGenerate: () -> Unit,378 onCopy: () -> Unit,379 onUse: () -> Unit,380) {381 val height by animateDpAsState(382 targetValue = if (open) 326.dp else 0.dp,383 animationSpec = tween(320, easing = PeachMotion.Spring),384 label = "gen-drawer",385 )386 Box(387 Modifier.fillMaxWidth().height(height).clipToBounds()388 .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),389 ) {390 GeneratorBody(391 length = length,392 upper = upper, lower = lower, numbers = numbers, symbols = symbols,393 password = password, copied = copied,394 onLength = onLength,395 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,396 onGenerate = onGenerate, onCopy = onCopy,397 showOutput = true,398 onUse = onUse,399 )400 }401 if (open) Spacer(Modifier.height(14.dp))402}403404405406407408@Composable409internal fun GeneratorBody(410 length: Int,411 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,412 password: String,413 copied: Boolean,414 onLength: (Int) -> Unit,415 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,416 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,417 onGenerate: () -> Unit,418 onCopy: () -> Unit,419 showOutput: Boolean = false,420 onUse: (() -> Unit)? = null,421) {422 val accent = LocalPeachAccent.current423 Column(Modifier.fillMaxWidth().padding(16.dp)) {424 if (showOutput) {425 Box(426 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))427 .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))428 .background(accent.copy(alpha = 0.08f))429 .padding(horizontal = 12.dp, vertical = 10.dp),430 ) {431 Text(432 text = password.ifEmpty { "tap generate" },433 color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,434 fontFamily = PeachType.Mono,435 fontSize = 11.sp,436 maxLines = 2,437 overflow = TextOverflow.Ellipsis,438 )439 }440 Spacer(Modifier.height(10.dp))441 }442 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {443 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)444 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)445 }446 Slider(447 value = length.toFloat(),448 onValueChange = { onLength(it.toInt()) },449 valueRange = 8f..128f,450 colors = SliderDefaults.colors(451 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,452 ),453 )454 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {455 TogglePill("A-Z", upper, onUpper)456 TogglePill("a-z", lower, onLower)457 TogglePill("0-9", numbers, onNumbers)458 TogglePill("!@#", symbols, onSymbols)459 }460 Spacer(Modifier.height(12.dp))461 Row(verticalAlignment = Alignment.CenterVertically) {462 Box(463 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))464 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))465 .background(accent.copy(alpha = 0.10f))466 .clickable(onClick = onGenerate).padding(vertical = 12.dp),467 contentAlignment = Alignment.Center,468 ) {469 Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)470 }471 Spacer(Modifier.width(8.dp))472 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })473 }474 onUse?.let {475 Spacer(Modifier.height(10.dp))476 Box(477 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))478 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))479 .background(accent.copy(alpha = 0.14f))480 .clickable(enabled = password.isNotEmpty(), onClick = it)481 .padding(vertical = 12.dp),482 contentAlignment = Alignment.Center,483 ) {484 Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)485 }486 }487 }488}489490@Composable491private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {492 val accent = LocalPeachAccent.current493 Box(494 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))495 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))496 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)497 .clickable { onToggle(!on) }498 .padding(horizontal = 10.dp, vertical = 6.dp),499 ) {500 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)501 }502}503504505