1package com.peachpasswords.ui.vault23import androidx.compose.foundation.BorderStroke4import androidx.compose.animation.core.animateDpAsState5import androidx.compose.animation.core.tween6import androidx.compose.foundation.background7import androidx.compose.foundation.border8import androidx.compose.foundation.clickable9import androidx.compose.foundation.layout.Arrangement10import androidx.compose.foundation.layout.Box11import androidx.compose.foundation.layout.Column12import androidx.compose.foundation.layout.Row13import androidx.compose.foundation.layout.Spacer14import androidx.compose.foundation.layout.fillMaxSize15import androidx.compose.foundation.layout.fillMaxWidth16import androidx.compose.foundation.layout.height17import androidx.compose.foundation.layout.offset18import androidx.compose.foundation.layout.navigationBarsPadding19import androidx.compose.foundation.layout.padding20import androidx.compose.foundation.layout.statusBarsPadding21import androidx.compose.foundation.layout.width22import androidx.compose.foundation.rememberScrollState23import androidx.compose.foundation.shape.RoundedCornerShape24import androidx.compose.foundation.verticalScroll25import androidx.compose.material3.Slider26import androidx.compose.material3.SliderDefaults27import androidx.compose.material3.Surface28import androidx.compose.material3.Text29import androidx.compose.runtime.Composable30import androidx.compose.runtime.getValue31import androidx.compose.runtime.mutableStateOf32import androidx.compose.runtime.remember33import androidx.compose.runtime.setValue34import androidx.compose.ui.Alignment35import androidx.compose.ui.Modifier36import androidx.compose.ui.draw.clip37import androidx.compose.ui.draw.clipToBounds38import androidx.compose.ui.graphics.Color39import androidx.compose.ui.text.TextStyle40import androidx.compose.ui.text.font.FontWeight41import androidx.compose.ui.text.input.PasswordVisualTransformation42import androidx.compose.ui.text.input.VisualTransformation43import androidx.compose.ui.text.style.TextAlign44import androidx.compose.ui.text.style.TextOverflow45import androidx.compose.ui.unit.dp46import androidx.compose.ui.unit.em47import androidx.compose.ui.unit.sp48import androidx.compose.runtime.collectAsState49import androidx.hilt.navigation.compose.hiltViewModel50import com.peachpasswords.ui.components.CopyButton51import com.peachpasswords.ui.components.PeachEmptyOrEndState52import com.peachpasswords.ui.components.PeachSegment53import com.peachpasswords.ui.components.PeachSegmentedControl54import com.peachpasswords.ui.components.SecureFlagEffect55import com.peachpasswords.ui.theme.LocalPeachAccent56import com.peachpasswords.ui.theme.PeachMotion57import com.peachpasswords.ui.theme.PeachRadius58import com.peachpasswords.ui.theme.PeachSpacing59import com.peachpasswords.ui.theme.PeachType60import com.peachpasswords.ui.theme.ShellBackground61import com.peachpasswords.ui.theme.peachColors6263@Composable64fun EntryEditScreen(65 id: String?,66 onBack: () -> Unit,67 onOpenNote: (String) -> Unit = {},68 vm: EntryEditViewModel = hiltViewModel(),69) {70 SecureFlagEffect()7172 val f by vm.form.collectAsState()73 val usernameSuggestions by vm.usernameSuggestions.collectAsState()74 val emailSuggestions by vm.emailSuggestions.collectAsState()7576 77 val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(78 targetValue = editGhostWatermarkTarget(f.title),79 animationSpec = tween(300),80 label = "edit-watermark",81 )8283 ShellBackground {84 Column(85 Modifier86 .fillMaxSize()87 .statusBarsPadding()88 .padding(horizontal = PeachSpacing.xl),89 ) {90 PeachVaultIdentity(editTitleBarLabel(vm.isNew, f.type))9192 Spacer(Modifier.height(PeachSpacing.lg))9394 Box(Modifier.weight(1f).fillMaxWidth().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 EntryEditBottomActions(213 saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),214 canSave = canSaveEditForm(f) && !f.saving,215 onCancel = onBack,216 onSave = { vm.save(onBack) },217 )218 }219 }220}221222@Composable223private fun EntryEditBottomActions(224 saveLabel: String,225 canSave: Boolean,226 onCancel: () -> Unit,227 onSave: () -> Unit,228) {229 val accent = LocalPeachAccent.current230 Row(231 Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),232 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),233 ) {234 Surface(235 modifier = Modifier.weight(1f).clickable(onClick = onCancel),236 color = peachColors.S1,237 shape = RoundedCornerShape(12.dp),238 border = BorderStroke(1.dp, peachColors.T3),239 ) {240 Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)241 }242 Surface(243 modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),244 color = if (canSave) accent else peachColors.S2,245 shape = RoundedCornerShape(12.dp),246 ) {247 Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)248 }249 }250}251252253internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {254 !isNew -> "edit"255 type == "note" -> "new note"256 type == "card" -> "new card"257 else -> "new entry"258}259260internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {261 saving -> "saving..."262 isNew -> "create"263 else -> "save"264}265266internal fun canSaveEditForm(form: EditForm): Boolean {267 if (form.title.isBlank()) return false268 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false269 return true270}271272273internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"274275276internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f277278internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =279 type == "login" && (revealed || totp.isNotBlank())280281internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =282 type == "login" && (revealed || notes.isNotBlank())283284internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =285 type == "login" && !shouldShowLoginTotp(type, revealed, totp)286287internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =288 type == "login" && !shouldShowLoginNotes(type, revealed, notes)289290@Composable291private fun OptionalLoginAddLinks(292 showTotpLink: Boolean,293 showNotesLink: Boolean,294 onAddTotp: () -> Unit,295 onAddNotes: () -> Unit,296) {297 if (!showTotpLink && !showNotesLink) return298 Row(299 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),300 horizontalArrangement = Arrangement.spacedBy(14.dp),301 ) {302 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)303 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)304 }305}306307@Composable308private fun AddFieldLink(label: String, onClick: () -> Unit) {309 Text(310 label,311 color = LocalPeachAccent.current,312 fontFamily = PeachType.Mono,313 fontSize = 11.sp,314 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),315 )316}317318@Composable319private fun Field(320 label: String,321 value: String,322 onChange: (String) -> Unit,323 singleLine: Boolean = true,324 transformation: VisualTransformation = VisualTransformation.None,325 trailing: (@Composable () -> Unit)? = null,326) {327 PeachField(328 label = label,329 value = value,330 onChange = onChange,331 singleLine = singleLine,332 mono = true,333 visualTransformation = transformation,334 trailing = trailing,335 )336}337338@Composable339private fun PasswordField(340 value: String,341 show: Boolean,342 generatorOpen: Boolean,343 onChange: (String) -> Unit,344 onToggleShow: () -> Unit,345 onToggleGenerator: () -> Unit,346) {347 Field(348 label = "password",349 value = value,350 onChange = onChange,351 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),352 trailing = {353 Text(if (show) "hide" else "show", color = LocalPeachAccent.current,354 fontFamily = PeachType.Mono, fontSize = 9.sp,355 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))356 Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,357 fontFamily = PeachType.Mono, fontSize = 9.sp,358 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))359 },360 )361}362363internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"364365@Composable366private fun GeneratorDrawer(367 open: Boolean,368 length: Int,369 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,370 password: String,371 copied: Boolean,372 onLength: (Int) -> Unit,373 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,374 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,375 onGenerate: () -> Unit,376 onCopy: () -> Unit,377 onUse: () -> Unit,378) {379 val height by animateDpAsState(380 targetValue = if (open) 326.dp else 0.dp,381 animationSpec = tween(320, easing = PeachMotion.Spring),382 label = "gen-drawer",383 )384 Box(385 Modifier.fillMaxWidth().height(height).clipToBounds()386 .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),387 ) {388 GeneratorBody(389 length = length,390 upper = upper, lower = lower, numbers = numbers, symbols = symbols,391 password = password, copied = copied,392 onLength = onLength,393 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,394 onGenerate = onGenerate, onCopy = onCopy,395 showOutput = true,396 onUse = onUse,397 )398 }399 if (open) Spacer(Modifier.height(14.dp))400}401402403404405406@Composable407internal fun GeneratorBody(408 length: Int,409 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,410 password: String,411 copied: Boolean,412 onLength: (Int) -> Unit,413 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,414 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,415 onGenerate: () -> Unit,416 onCopy: () -> Unit,417 showOutput: Boolean = false,418 onUse: (() -> Unit)? = null,419) {420 val accent = LocalPeachAccent.current421 Column(Modifier.fillMaxWidth().padding(16.dp)) {422 if (showOutput) {423 Box(424 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))425 .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))426 .background(accent.copy(alpha = 0.08f))427 .padding(horizontal = 12.dp, vertical = 10.dp),428 ) {429 Text(430 text = password.ifEmpty { "tap generate" },431 color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,432 fontFamily = PeachType.Mono,433 fontSize = 11.sp,434 maxLines = 2,435 overflow = TextOverflow.Ellipsis,436 )437 }438 Spacer(Modifier.height(10.dp))439 }440 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {441 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)442 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)443 }444 Slider(445 value = length.toFloat(),446 onValueChange = { onLength(it.toInt()) },447 valueRange = 8f..128f,448 colors = SliderDefaults.colors(449 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,450 ),451 )452 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {453 TogglePill("A-Z", upper, onUpper)454 TogglePill("a-z", lower, onLower)455 TogglePill("0-9", numbers, onNumbers)456 TogglePill("!@#", symbols, onSymbols)457 }458 Spacer(Modifier.height(12.dp))459 Row(verticalAlignment = Alignment.CenterVertically) {460 Box(461 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))462 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))463 .background(accent.copy(alpha = 0.10f))464 .clickable(onClick = onGenerate).padding(vertical = 12.dp),465 contentAlignment = Alignment.Center,466 ) {467 Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)468 }469 Spacer(Modifier.width(8.dp))470 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })471 }472 onUse?.let {473 Spacer(Modifier.height(10.dp))474 Box(475 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))476 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))477 .background(accent.copy(alpha = 0.14f))478 .clickable(enabled = password.isNotEmpty(), onClick = it)479 .padding(vertical = 12.dp),480 contentAlignment = Alignment.Center,481 ) {482 Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)483 }484 }485 }486}487488@Composable489private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {490 val accent = LocalPeachAccent.current491 Box(492 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))493 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))494 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)495 .clickable { onToggle(!on) }496 .padding(horizontal = 10.dp, vertical = 6.dp),497 ) {498 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)499 }500}501502503