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 = PeachType.ghostMark.copy(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 PeachFieldStack {131 Field("url", f.url, vm::setUrl, joined = true, divider = true)132 IdentitySuggestField(133 "username", f.username, usernameSuggestions, vm::setUsername,134 joined = true,135 divider = true,136 )137 IdentitySuggestField(138 "email", f.email, emailSuggestions, vm::setEmail,139 joined = true,140 divider = true,141 )142 PasswordField(143 value = f.password,144 show = f.showPassword,145 generatorOpen = f.generatorOpen,146 onChange = vm::setPassword,147 onToggleShow = vm::toggleShowPassword,148 onToggleGenerator = vm::toggleGenerator,149 joined = true,150 )151 }152 AdditionalUrlsEditor(153 urls = f.additionalUrls,154 onChange = vm::setAdditionalUrl,155 onAdd = vm::addAdditionalUrl,156 onRemove = vm::removeAdditionalUrl,157 )158 GeneratorDrawer(159 open = f.generatorOpen,160 length = f.genLength,161 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,162 password = f.password,163 copied = f.genCopied,164 onLength = vm::setGenLength,165 onUpper = vm::setGenUpper, onLower = vm::setGenLower,166 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,167 onGenerate = vm::generate,168 onCopy = vm::copyGenerated,169 onUse = vm::useGeneratedPassword,170 )171 PasswordHistorySection(172 history = f.history,173 expanded = f.showHistory,174 onToggle = vm::toggleHistory,175 onRestore = vm::restorePassword,176 onCopy = vm::copyHistoryPassword,177 )178 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {179 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)180 }181 }182 if (f.type == "card") {183 CardEditFields(184 cardNickname = f.cardNickname,185 cardNumber = f.cardNumber,186 cardholderName = f.cardholderName,187 expiry = f.expiry,188 cvc = f.cvc,189 showCvc = f.showCvc,190 network = f.cardNetwork,191 onCardNickname = vm::setCardNickname,192 onCardNumber = vm::setCardNumber,193 onCardholder = vm::setCardholderName,194 onExpiry = vm::setExpiry,195 onCvc = vm::setCvc,196 onToggleShowCvc = vm::toggleShowCvc,197 )198 }199 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {200 Field("notes", f.notes, vm::setNotes, singleLine = false)201 }202 if (f.type == "login") {203 OptionalLoginAddLinks(204 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),205 showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),206 onAddTotp = vm::revealTotp,207 onAddNotes = vm::revealNotes,208 )209 CanaryEditSection(210 entryId = id ?: "new",211 modifiedAt = f.canary?.lastPinged ?: 0L,212 canary = f.canary,213 isPro = vm.isPro,214 message = f.canaryMessage,215 testing = f.canaryTesting,216 onSave = vm::setCanary,217 onTest = vm::testCanary,218 )219 }220 TagChipEditor(221 tags = f.tags,222 allTags = vm.allTags,223 onAdd = vm::addTag,224 onRemove = vm::removeTag,225 )226227 f.error?.let {228 Spacer(Modifier.height(PeachSpacing.sm))229 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)230 }231 Spacer(Modifier.height(PeachSpacing.xl))232 }233 }234 EntryEditBottomActions(235 saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),236 canSave = canSaveEditForm(f) && !f.saving,237 onCancel = onBack,238 onSave = { vm.save(onBack) },239 )240 }241 }242}243244@Composable245private fun EntryEditBottomActions(246 saveLabel: String,247 canSave: Boolean,248 onCancel: () -> Unit,249 onSave: () -> Unit,250) {251 val accent = LocalPeachAccent.current252 Row(253 Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),254 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),255 ) {256 Surface(257 modifier = Modifier.weight(1f).clickable(onClick = onCancel),258 color = peachColors.S1,259 shape = RoundedCornerShape(PeachRadius.StackCard),260 border = BorderStroke(1.dp, peachColors.T3),261 ) {262 Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)263 }264 Surface(265 modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),266 color = if (canSave) accent else peachColors.S2,267 shape = RoundedCornerShape(PeachRadius.StackCard),268 ) {269 Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)270 }271 }272}273274275internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {276 !isNew -> "edit"277 type == "note" -> "new note"278 type == "card" -> "new card"279 else -> "new entry"280}281282internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {283 saving -> "saving..."284 isNew -> "create"285 else -> "save"286}287288internal fun canSaveEditForm(form: EditForm): Boolean {289 if (form.title.isBlank()) return false290 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false291 return true292}293294295internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"296297298internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f299300internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =301 type == "login" && (revealed || totp.isNotBlank())302303internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =304 type == "login" && (revealed || notes.isNotBlank())305306internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =307 type == "login" && !shouldShowLoginTotp(type, revealed, totp)308309internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =310 type == "login" && !shouldShowLoginNotes(type, revealed, notes)311312@Composable313private fun OptionalLoginAddLinks(314 showTotpLink: Boolean,315 showNotesLink: Boolean,316 onAddTotp: () -> Unit,317 onAddNotes: () -> Unit,318) {319 if (!showTotpLink && !showNotesLink) return320 Row(321 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),322 horizontalArrangement = Arrangement.spacedBy(14.dp),323 ) {324 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)325 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)326 }327}328329@Composable330private fun AddFieldLink(label: String, onClick: () -> Unit) {331 Text(332 label,333 color = LocalPeachAccent.current,334 fontFamily = PeachType.Mono,335 fontSize = 11.sp,336 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),337 )338}339340@Composable341private fun Field(342 label: String,343 value: String,344 onChange: (String) -> Unit,345 singleLine: Boolean = true,346 transformation: VisualTransformation = VisualTransformation.None,347 trailing: (@Composable () -> Unit)? = null,348 joined: Boolean = false,349 divider: Boolean = false,350) {351 PeachField(352 label = label,353 value = value,354 onChange = onChange,355 singleLine = singleLine,356 mono = true,357 visualTransformation = transformation,358 trailing = trailing,359 joined = joined,360 divider = divider,361 )362}363364@Composable365private fun PasswordField(366 value: String,367 show: Boolean,368 generatorOpen: Boolean,369 onChange: (String) -> Unit,370 onToggleShow: () -> Unit,371 onToggleGenerator: () -> Unit,372 joined: Boolean = false,373) {374 Field(375 label = "password",376 value = value,377 onChange = onChange,378 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),379 joined = joined,380 trailing = {381 Text(if (show) "hide" else "show", color = LocalPeachAccent.current,382 fontFamily = PeachType.Mono, fontSize = 9.sp,383 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))384 Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,385 fontFamily = PeachType.Mono, fontSize = 9.sp,386 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))387 },388 )389}390391internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"392393@Composable394private fun GeneratorDrawer(395 open: Boolean,396 length: Int,397 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,398 password: String,399 copied: Boolean,400 onLength: (Int) -> Unit,401 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,402 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,403 onGenerate: () -> Unit,404 onCopy: () -> Unit,405 onUse: () -> Unit,406) {407 val height by animateDpAsState(408 targetValue = if (open) 326.dp else 0.dp,409 animationSpec = tween(320, easing = PeachMotion.Spring),410 label = "gen-drawer",411 )412 Box(413 Modifier.fillMaxWidth().height(height).clipToBounds()414 .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),415 ) {416 GeneratorBody(417 length = length,418 upper = upper, lower = lower, numbers = numbers, symbols = symbols,419 password = password, copied = copied,420 onLength = onLength,421 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,422 onGenerate = onGenerate, onCopy = onCopy,423 showOutput = true,424 onUse = onUse,425 )426 }427 if (open) Spacer(Modifier.height(14.dp))428}429430431432433434@Composable435internal fun GeneratorBody(436 length: Int,437 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,438 password: String,439 copied: Boolean,440 onLength: (Int) -> Unit,441 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,442 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,443 onGenerate: () -> Unit,444 onCopy: () -> Unit,445 showOutput: Boolean = false,446 onUse: (() -> Unit)? = null,447) {448 val accent = LocalPeachAccent.current449 Column(Modifier.fillMaxWidth().padding(16.dp)) {450 if (showOutput) {451 Box(452 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))453 .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))454 .background(accent.copy(alpha = 0.08f))455 .padding(horizontal = 12.dp, vertical = 10.dp),456 ) {457 Text(458 text = password.ifEmpty { "tap generate" },459 color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,460 fontFamily = PeachType.Mono,461 fontSize = 11.sp,462 maxLines = 2,463 overflow = TextOverflow.Ellipsis,464 )465 }466 Spacer(Modifier.height(10.dp))467 }468 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {469 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)470 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)471 }472 Slider(473 value = length.toFloat(),474 onValueChange = { onLength(it.toInt()) },475 valueRange = 8f..128f,476 colors = SliderDefaults.colors(477 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,478 ),479 )480 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {481 TogglePill("A-Z", upper, onUpper)482 TogglePill("a-z", lower, onLower)483 TogglePill("0-9", numbers, onNumbers)484 TogglePill("!@#", symbols, onSymbols)485 }486 Spacer(Modifier.height(12.dp))487 Row(verticalAlignment = Alignment.CenterVertically) {488 Box(489 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))490 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))491 .background(accent.copy(alpha = 0.10f))492 .clickable(onClick = onGenerate).padding(vertical = 12.dp),493 contentAlignment = Alignment.Center,494 ) {495 Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)496 }497 Spacer(Modifier.width(8.dp))498 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })499 }500 onUse?.let {501 Spacer(Modifier.height(10.dp))502 Box(503 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))504 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))505 .background(accent.copy(alpha = 0.14f))506 .clickable(enabled = password.isNotEmpty(), onClick = it)507 .padding(vertical = 12.dp),508 contentAlignment = Alignment.Center,509 ) {510 Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)511 }512 }513 }514}515516@Composable517private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {518 val accent = LocalPeachAccent.current519 Box(520 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))521 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))522 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)523 .clickable { onToggle(!on) }524 .padding(horizontal = 10.dp, vertical = 6.dp),525 ) {526 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)527 }528}529530531