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 onHome: () -> Unit,68 onOpenNote: (String) -> Unit = {},69 vm: EntryEditViewModel = hiltViewModel(),70) {71 SecureFlagEffect()7273 val f by vm.form.collectAsState()74 val usernameSuggestions by vm.usernameSuggestions.collectAsState()75 val emailSuggestions by vm.emailSuggestions.collectAsState()7677 78 val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(79 targetValue = editGhostWatermarkTarget(f.title),80 animationSpec = tween(300),81 label = "edit-watermark",82 )8384 ShellBackground {85 Column(86 Modifier87 .fillMaxSize()88 .statusBarsPadding()89 .padding(horizontal = PeachSpacing.xl),90 ) {91 PeachVaultIdentity(editTitleBarLabel(vm.isNew, f.type), onHome = onHome)9293 Spacer(Modifier.height(PeachSpacing.lg))9495 Box(Modifier.weight(1f).fillMaxWidth().clipToBounds()) {96 97 98 if (watermarkAlpha > 0f) {99 Text(100 text = ghostWatermarkText(f.title),101 style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),102 color = peachColors.T0.copy(alpha = watermarkAlpha),103 maxLines = 1,104 softWrap = false,105 overflow = androidx.compose.ui.text.style.TextOverflow.Clip,106 modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),107 )108 }109 Column(110 Modifier.fillMaxSize().verticalScroll(rememberScrollState()),111 ) {112 PeachSegmentedControl(113 segments = listOf(114 PeachSegment("login", "login"),115 PeachSegment("note", "note"),116 PeachSegment("card", "card"),117 ),118 selectedKey = f.type,119 onSelected = { t ->120 121 122 if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)123 else vm.setType(t)124 },125 )126 Spacer(Modifier.height(PeachSpacing.lg))127128 Field("title", f.title, vm::setTitle)129 if (f.type == "login") {130 Field("url", f.url, vm::setUrl)131 AdditionalUrlsEditor(132 urls = f.additionalUrls,133 onChange = vm::setAdditionalUrl,134 onAdd = vm::addAdditionalUrl,135 onRemove = vm::removeAdditionalUrl,136 )137 IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)138 IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)139 PasswordField(140 value = f.password,141 show = f.showPassword,142 generatorOpen = f.generatorOpen,143 onChange = vm::setPassword,144 onToggleShow = vm::toggleShowPassword,145 onToggleGenerator = vm::toggleGenerator,146 )147 GeneratorDrawer(148 open = f.generatorOpen,149 length = f.genLength,150 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,151 password = f.password,152 copied = f.genCopied,153 onLength = vm::setGenLength,154 onUpper = vm::setGenUpper, onLower = vm::setGenLower,155 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,156 onGenerate = vm::generate,157 onCopy = vm::copyGenerated,158 onUse = vm::useGeneratedPassword,159 )160 PasswordHistorySection(161 history = f.history,162 expanded = f.showHistory,163 onToggle = vm::toggleHistory,164 onRestore = vm::restorePassword,165 onCopy = vm::copyHistoryPassword,166 )167 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {168 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)169 }170 }171 if (f.type == "card") {172 CardEditFields(173 cardNickname = f.cardNickname,174 cardNumber = f.cardNumber,175 cardholderName = f.cardholderName,176 expiry = f.expiry,177 cvc = f.cvc,178 showCvc = f.showCvc,179 network = f.cardNetwork,180 onCardNickname = vm::setCardNickname,181 onCardNumber = vm::setCardNumber,182 onCardholder = vm::setCardholderName,183 onExpiry = vm::setExpiry,184 onCvc = vm::setCvc,185 onToggleShowCvc = vm::toggleShowCvc,186 )187 }188 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {189 Field("notes", f.notes, vm::setNotes, singleLine = false)190 }191 if (f.type == "login") {192 OptionalLoginAddLinks(193 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),194 showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),195 onAddTotp = vm::revealTotp,196 onAddNotes = vm::revealNotes,197 )198 }199 TagChipEditor(200 tags = f.tags,201 allTags = vm.allTags,202 onAdd = vm::addTag,203 onRemove = vm::removeTag,204 )205206 f.error?.let {207 Spacer(Modifier.height(PeachSpacing.sm))208 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)209 }210 Spacer(Modifier.height(PeachSpacing.xl))211 }212 }213 EntryEditBottomActions(214 saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),215 canSave = canSaveEditForm(f) && !f.saving,216 onCancel = onBack,217 onSave = { vm.save(onBack) },218 )219 }220 }221}222223@Composable224private fun EntryEditBottomActions(225 saveLabel: String,226 canSave: Boolean,227 onCancel: () -> Unit,228 onSave: () -> Unit,229) {230 val accent = LocalPeachAccent.current231 Row(232 Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),233 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),234 ) {235 Surface(236 modifier = Modifier.weight(1f).clickable(onClick = onCancel),237 color = peachColors.S1,238 shape = RoundedCornerShape(12.dp),239 border = BorderStroke(1.dp, peachColors.T3),240 ) {241 Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)242 }243 Surface(244 modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),245 color = if (canSave) accent else peachColors.S2,246 shape = RoundedCornerShape(12.dp),247 ) {248 Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)249 }250 }251}252253254internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {255 !isNew -> "edit"256 type == "note" -> "new note"257 type == "card" -> "new card"258 else -> "new entry"259}260261internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {262 saving -> "saving..."263 isNew -> "create"264 else -> "save"265}266267internal fun canSaveEditForm(form: EditForm): Boolean {268 if (form.title.isBlank()) return false269 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false270 return true271}272273274internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"275276277internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f278279internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =280 type == "login" && (revealed || totp.isNotBlank())281282internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =283 type == "login" && (revealed || notes.isNotBlank())284285internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =286 type == "login" && !shouldShowLoginTotp(type, revealed, totp)287288internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =289 type == "login" && !shouldShowLoginNotes(type, revealed, notes)290291@Composable292private fun OptionalLoginAddLinks(293 showTotpLink: Boolean,294 showNotesLink: Boolean,295 onAddTotp: () -> Unit,296 onAddNotes: () -> Unit,297) {298 if (!showTotpLink && !showNotesLink) return299 Row(300 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),301 horizontalArrangement = Arrangement.spacedBy(14.dp),302 ) {303 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)304 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)305 }306}307308@Composable309private fun AddFieldLink(label: String, onClick: () -> Unit) {310 Text(311 label,312 color = LocalPeachAccent.current,313 fontFamily = PeachType.Mono,314 fontSize = 11.sp,315 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),316 )317}318319@Composable320private fun Field(321 label: String,322 value: String,323 onChange: (String) -> Unit,324 singleLine: Boolean = true,325 transformation: VisualTransformation = VisualTransformation.None,326 trailing: (@Composable () -> Unit)? = null,327) {328 PeachField(329 label = label,330 value = value,331 onChange = onChange,332 singleLine = singleLine,333 mono = true,334 visualTransformation = transformation,335 trailing = trailing,336 )337}338339@Composable340private fun PasswordField(341 value: String,342 show: Boolean,343 generatorOpen: Boolean,344 onChange: (String) -> Unit,345 onToggleShow: () -> Unit,346 onToggleGenerator: () -> Unit,347) {348 Field(349 label = "password",350 value = value,351 onChange = onChange,352 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),353 trailing = {354 Text(if (show) "hide" else "show", color = LocalPeachAccent.current,355 fontFamily = PeachType.Mono, fontSize = 9.sp,356 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))357 Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,358 fontFamily = PeachType.Mono, fontSize = 9.sp,359 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))360 },361 )362}363364internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"365366@Composable367private fun GeneratorDrawer(368 open: Boolean,369 length: Int,370 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,371 password: String,372 copied: Boolean,373 onLength: (Int) -> Unit,374 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,375 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,376 onGenerate: () -> Unit,377 onCopy: () -> Unit,378 onUse: () -> Unit,379) {380 val height by animateDpAsState(381 targetValue = if (open) 326.dp else 0.dp,382 animationSpec = tween(320, easing = PeachMotion.Spring),383 label = "gen-drawer",384 )385 Box(386 Modifier.fillMaxWidth().height(height).clipToBounds()387 .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),388 ) {389 GeneratorBody(390 length = length,391 upper = upper, lower = lower, numbers = numbers, symbols = symbols,392 password = password, copied = copied,393 onLength = onLength,394 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,395 onGenerate = onGenerate, onCopy = onCopy,396 showOutput = true,397 onUse = onUse,398 )399 }400 if (open) Spacer(Modifier.height(14.dp))401}402403404405406407@Composable408internal fun GeneratorBody(409 length: Int,410 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,411 password: String,412 copied: Boolean,413 onLength: (Int) -> Unit,414 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,415 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,416 onGenerate: () -> Unit,417 onCopy: () -> Unit,418 showOutput: Boolean = false,419 onUse: (() -> Unit)? = null,420) {421 val accent = LocalPeachAccent.current422 Column(Modifier.fillMaxWidth().padding(16.dp)) {423 if (showOutput) {424 Box(425 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))426 .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))427 .background(accent.copy(alpha = 0.08f))428 .padding(horizontal = 12.dp, vertical = 10.dp),429 ) {430 Text(431 text = password.ifEmpty { "tap generate" },432 color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,433 fontFamily = PeachType.Mono,434 fontSize = 11.sp,435 maxLines = 2,436 overflow = TextOverflow.Ellipsis,437 )438 }439 Spacer(Modifier.height(10.dp))440 }441 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {442 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)443 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)444 }445 Slider(446 value = length.toFloat(),447 onValueChange = { onLength(it.toInt()) },448 valueRange = 8f..128f,449 colors = SliderDefaults.colors(450 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,451 ),452 )453 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {454 TogglePill("A-Z", upper, onUpper)455 TogglePill("a-z", lower, onLower)456 TogglePill("0-9", numbers, onNumbers)457 TogglePill("!@#", symbols, onSymbols)458 }459 Spacer(Modifier.height(12.dp))460 Row(verticalAlignment = Alignment.CenterVertically) {461 Box(462 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))463 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))464 .background(accent.copy(alpha = 0.10f))465 .clickable(onClick = onGenerate).padding(vertical = 12.dp),466 contentAlignment = Alignment.Center,467 ) {468 Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)469 }470 Spacer(Modifier.width(8.dp))471 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })472 }473 onUse?.let {474 Spacer(Modifier.height(10.dp))475 Box(476 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))477 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))478 .background(accent.copy(alpha = 0.14f))479 .clickable(enabled = password.isNotEmpty(), onClick = it)480 .padding(vertical = 12.dp),481 contentAlignment = Alignment.Center,482 ) {483 Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)484 }485 }486 }487}488489@Composable490private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {491 val accent = LocalPeachAccent.current492 Box(493 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))494 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))495 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)496 .clickable { onToggle(!on) }497 .padding(horizontal = 10.dp, vertical = 6.dp),498 ) {499 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)500 }501}502503504