package com.peachpasswords.ui.vault import androidx.compose.foundation.BorderStroke import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.compose.runtime.collectAsState import androidx.hilt.navigation.compose.hiltViewModel import com.peachpasswords.ui.components.CopyButton import com.peachpasswords.ui.components.PeachEmptyOrEndState import com.peachpasswords.ui.components.PeachSegment import com.peachpasswords.ui.components.PeachSegmentedControl import com.peachpasswords.ui.components.SecureFlagEffect import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.PeachMotion import com.peachpasswords.ui.theme.PeachRadius import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.ui.theme.peachColors @Composable fun EntryEditScreen( id: String?, onBack: () -> Unit, onHome: () -> Unit, vm: EntryEditViewModel = hiltViewModel(), ) { SecureFlagEffect() val f by vm.form.collectAsState() val usernameSuggestions by vm.usernameSuggestions.collectAsState() val emailSuggestions by vm.emailSuggestions.collectAsState() // Ghost watermark fades in (0 → 0.03α over 300ms) once the title has any text. val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState( targetValue = editGhostWatermarkTarget(f.title), animationSpec = tween(300), label = "edit-watermark", ) ShellBackground { Column( Modifier .fillMaxSize() .statusBarsPadding() .padding(horizontal = PeachSpacing.xl), ) { PeachVaultIdentity(editTitleBarLabel(vm.isNew, f.type), onHome = onHome) Spacer(Modifier.height(PeachSpacing.lg)) Box(Modifier.weight(1f).fillMaxWidth().clipToBounds()) { // Ghost Garamond watermark behind the form (matches the detail hero): // the full title, fading in as the user types. if (watermarkAlpha > 0f) { Text( text = ghostWatermarkText(f.title), style = PeachType.ghostMark.copy(fontSize = 140.sp), color = peachColors.T0.copy(alpha = watermarkAlpha), maxLines = 1, softWrap = false, overflow = androidx.compose.ui.text.style.TextOverflow.Clip, modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp), ) } Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()), ) { PeachSegmentedControl( segments = entryEditTypeSegments(), selectedKey = f.type, onSelected = vm::setType, ) Spacer(Modifier.height(PeachSpacing.lg)) Field("title", f.title, vm::setTitle) if (f.type == "login") { PeachFieldStack { Field( "url", f.url, vm::setUrl, joined = true, divider = true, trailing = if (f.additionalUrls.isEmpty()) { { AddFieldLink("+ add", vm::addAdditionalUrl) } } else { null }, ) if (f.additionalUrls.isNotEmpty()) { AdditionalUrlsEditor( urls = f.additionalUrls, onChange = vm::setAdditionalUrl, onAdd = vm::addAdditionalUrl, onRemove = vm::removeAdditionalUrl, ) } IdentitySuggestField( "username", f.username, usernameSuggestions, vm::setUsername, joined = true, divider = true, ) IdentitySuggestField( "email", f.email, emailSuggestions, vm::setEmail, joined = true, divider = true, ) PasswordField( value = f.password, show = f.showPassword, generatorOpen = f.generatorOpen, onChange = vm::setPassword, onToggleShow = vm::toggleShowPassword, onToggleGenerator = vm::toggleGenerator, joined = true, ) } GeneratorDrawer( open = f.generatorOpen, length = f.genLength, upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols, password = f.password, copied = f.genCopied, onLength = vm::setGenLength, onUpper = vm::setGenUpper, onLower = vm::setGenLower, onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols, onGenerate = vm::generate, onCopy = vm::copyGenerated, onUse = vm::useGeneratedPassword, ) PasswordHistorySection( history = f.history, expanded = f.showHistory, onToggle = vm::toggleHistory, onRestore = vm::restorePassword, onCopy = vm::copyHistoryPassword, ) OptionalLoginAddLinks( showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp), showNotesLink = shouldShowLoginNotesLink( isNew = vm.isNew, type = f.type, revealed = f.showNotes, notes = f.notes, ), onAddTotp = vm::revealTotp, onAddNotes = vm::revealNotes, ) if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) { Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp) } } if (f.type == "card") { CardEditFields( cardNickname = f.cardNickname, cardNumber = f.cardNumber, cardholderName = f.cardholderName, expiry = f.expiry, cvc = f.cvc, showCvc = f.showCvc, network = f.cardNetwork, onCardNickname = vm::setCardNickname, onCardNumber = vm::setCardNumber, onCardholder = vm::setCardholderName, onExpiry = vm::setExpiry, onCvc = vm::setCvc, onToggleShowCvc = vm::toggleShowCvc, ) } if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) { Field("notes", f.notes, vm::setNotes, singleLine = false) } if (f.type == "login") { CanaryEditSection( entryId = id ?: "new", modifiedAt = f.canary?.lastPinged ?: 0L, canary = f.canary, isPro = vm.isPro, message = f.canaryMessage, testing = f.canaryTesting, onSave = vm::setCanary, onTest = vm::testCanary, ) } TagChipEditor( tags = f.tags, allTags = vm.allTags, onAdd = vm::addTag, onRemove = vm::removeTag, ) f.error?.let { Spacer(Modifier.height(PeachSpacing.sm)) Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp) } Spacer(Modifier.height(PeachSpacing.xl)) } } EntryEditBottomActions( saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving), canSave = canSaveEditForm(f) && !f.saving, onCancel = onBack, onSave = { vm.save(onBack) }, ) } } } @Composable private fun EntryEditBottomActions( saveLabel: String, canSave: Boolean, onCancel: () -> Unit, onSave: () -> Unit, ) { val accent = LocalPeachAccent.current Row( Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md), horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md), ) { Surface( modifier = Modifier.weight(1f).clickable(onClick = onCancel), color = peachColors.S1, shape = RoundedCornerShape(PeachRadius.StackCard), border = BorderStroke(1.dp, peachColors.T3), ) { Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center) } Surface( modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave), color = if (canSave) accent else peachColors.S2, shape = RoundedCornerShape(PeachRadius.StackCard), ) { Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center) } } } /** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */ internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when { !isNew -> "edit" type == "note" -> "new note" type == "card" -> "new card" else -> "new entry" } internal fun entryEditTypeSegments(): List = listOf( PeachSegment("login", "login"), PeachSegment("card", "card"), ) internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when { saving -> "saving..." isNew -> "create" else -> "save" } internal fun canSaveEditForm(form: EditForm): Boolean { if (form.title.isBlank()) return false if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false return true } /** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */ internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean = type == "login" && (revealed || totp.isNotBlank()) internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean = type == "login" && (revealed || notes.isNotBlank()) internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean = type == "login" && !shouldShowLoginTotp(type, revealed, totp) internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean = type == "login" && !shouldShowLoginNotes(type, revealed, notes) internal fun shouldShowLoginNotesLink(isNew: Boolean, type: String, revealed: Boolean, notes: String): Boolean = !isNew && shouldShowAddNotesLink(type, revealed, notes) @Composable private fun OptionalLoginAddLinks( showTotpLink: Boolean, showNotesLink: Boolean, onAddTotp: () -> Unit, onAddNotes: () -> Unit, ) { if (!showTotpLink && !showNotesLink) return PeachFieldStack { Text( "OPTIONAL FIELDS", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.08.em, modifier = Modifier.padding(start = PeachSpacing.lg, top = PeachSpacing.md), ) Row( Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm), horizontalArrangement = Arrangement.spacedBy(14.dp), ) { if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp) if (showNotesLink) AddFieldLink("+ add notes", onAddNotes) } } } @Composable private fun AddFieldLink(label: String, onClick: () -> Unit) { Text( label, color = LocalPeachAccent.current, fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp), ) } @Composable private fun Field( label: String, value: String, onChange: (String) -> Unit, singleLine: Boolean = true, transformation: VisualTransformation = VisualTransformation.None, trailing: (@Composable () -> Unit)? = null, joined: Boolean = false, divider: Boolean = false, ) { PeachField( label = label, value = value, onChange = onChange, singleLine = singleLine, mono = true, visualTransformation = transformation, trailing = trailing, joined = joined, divider = divider, ) } @Composable private fun PasswordField( value: String, show: Boolean, generatorOpen: Boolean, onChange: (String) -> Unit, onToggleShow: () -> Unit, onToggleGenerator: () -> Unit, joined: Boolean = false, divider: Boolean = false, ) { Field( label = "password", value = value, onChange = onChange, transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(), joined = joined, divider = divider, trailing = { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { PasswordAction(if (show) "hide" else "show", onToggleShow) PasswordAction(generatorToggleLabel(generatorOpen), onToggleGenerator) } }, ) } @Composable private fun PasswordAction(label: String, onClick: () -> Unit) { val accent = LocalPeachAccent.current Text( label, color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp, modifier = Modifier .clip(RoundedCornerShape(6.dp)) .background(accent.copy(alpha = 0.08f)) .clickable(onClick = onClick) .padding(horizontal = 7.dp, vertical = 5.dp), ) } internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate" @Composable private fun GeneratorDrawer( open: Boolean, length: Int, upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean, password: String, copied: Boolean, onLength: (Int) -> Unit, onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit, onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit, onGenerate: () -> Unit, onCopy: () -> Unit, onUse: () -> Unit, ) { val height by animateDpAsState( targetValue = if (open) 326.dp else 0.dp, animationSpec = tween(320, easing = PeachMotion.Spring), label = "gen-drawer", ) Box( Modifier.fillMaxWidth().height(height).clipToBounds() .clip(RoundedCornerShape(12.dp)) .background(peachColors.S2) .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp)), ) { GeneratorBody( length = length, upper = upper, lower = lower, numbers = numbers, symbols = symbols, password = password, copied = copied, onLength = onLength, onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols, onGenerate = onGenerate, onCopy = onCopy, showOutput = true, onUse = onUse, ) } if (open) Spacer(Modifier.height(14.dp)) } /** * Shared generator body — used by the inline drawer here and the detail * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain. */ @Composable internal fun GeneratorBody( length: Int, upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean, password: String, copied: Boolean, onLength: (Int) -> Unit, onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit, onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit, onGenerate: () -> Unit, onCopy: () -> Unit, showOutput: Boolean = false, onUse: (() -> Unit)? = null, ) { val accent = LocalPeachAccent.current Column(Modifier.fillMaxWidth().padding(16.dp)) { if (showOutput) { Box( Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)) .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) .background(accent.copy(alpha = 0.08f)) .padding(horizontal = 12.dp, vertical = 10.dp), ) { Text( text = password.ifEmpty { "tap generate" }, color = if (password.isEmpty()) peachColors.T2 else peachColors.T0, fontFamily = PeachType.Mono, fontSize = 11.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } Spacer(Modifier.height(10.dp)) } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp) Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp) } Slider( value = length.toFloat(), onValueChange = { onLength(it.toInt()) }, valueRange = 8f..128f, colors = SliderDefaults.colors( thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3, ), ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { TogglePill("A-Z", upper, onUpper) TogglePill("a-z", lower, onLower) TogglePill("0-9", numbers, onNumbers) TogglePill("!@#", symbols, onSymbols) } Spacer(Modifier.height(12.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Box( Modifier.weight(1f).clip(RoundedCornerShape(10.dp)) .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp)) .background(accent.copy(alpha = 0.10f)) .clickable(onClick = onGenerate).padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp) } Spacer(Modifier.width(8.dp)) CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() }) } onUse?.let { Spacer(Modifier.height(10.dp)) Box( Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)) .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp)) .background(accent.copy(alpha = 0.14f)) .clickable(enabled = password.isNotEmpty(), onClick = it) .padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp) } } } } @Composable private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) { val accent = LocalPeachAccent.current Box( Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab)) .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab)) .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent) .clickable { onToggle(!on) } .padding(horizontal = 10.dp, vertical = 6.dp), ) { Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp) } } // PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted // to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.