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.SecureFlagEffect51import com.peachpasswords.ui.components.PeachTopBar52import com.peachpasswords.ui.theme.LocalPeachAccent53import com.peachpasswords.ui.theme.PeachMotion54import com.peachpasswords.ui.theme.PeachRadius55import com.peachpasswords.ui.theme.PeachSpacing56import com.peachpasswords.ui.theme.PeachType57import com.peachpasswords.ui.theme.ShellBackground58import com.peachpasswords.ui.theme.peachColors5960@Composable61fun EntryEditScreen(62 id: String?,63 onBack: () -> Unit,64 onOpenNote: (String) -> Unit = {},65 vm: EntryEditViewModel = hiltViewModel(),66) {67 SecureFlagEffect()6869 val f by vm.form.collectAsState()70 val usernameSuggestions by vm.usernameSuggestions.collectAsState()71 val emailSuggestions by vm.emailSuggestions.collectAsState()7273 74 val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(75 targetValue = editGhostWatermarkTarget(f.title),76 animationSpec = tween(300),77 label = "edit-watermark",78 )7980 ShellBackground {81 Column(82 Modifier83 .fillMaxSize()84 .statusBarsPadding()85 .padding(horizontal = PeachSpacing.xl),86 ) {87 EntryEditTopBar(88 title = editTitleBarLabel(vm.isNew, f.type),89 saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),90 canSave = canSaveEditForm(f) && !f.saving,91 onCancel = onBack,92 onSave = { vm.save(onBack) },93 )9495 Spacer(Modifier.height(PeachSpacing.lg))9697 Box(Modifier.fillMaxSize().clipToBounds()) {98 99 100 if (watermarkAlpha > 0f) {101 Text(102 text = ghostWatermarkText(f.title),103 style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),104 color = peachColors.T0.copy(alpha = watermarkAlpha),105 maxLines = 1,106 softWrap = false,107 overflow = androidx.compose.ui.text.style.TextOverflow.Clip,108 modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),109 )110 }111 Column(112 Modifier.fillMaxSize().verticalScroll(rememberScrollState()),113 ) {114 PeachSegmentedControl(115 segments = listOf(116 PeachSegment("login", "login"),117 PeachSegment("note", "note"),118 PeachSegment("card", "card"),119 ),120 selectedKey = f.type,121 onSelected = { t ->122 123 124 if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)125 else vm.setType(t)126 },127 )128 Spacer(Modifier.height(PeachSpacing.lg))129130 Field("title", f.title, vm::setTitle)131 if (f.type == "login") {132 Field("url", f.url, vm::setUrl)133 AdditionalUrlsEditor(134 urls = f.additionalUrls,135 onChange = vm::setAdditionalUrl,136 onAdd = vm::addAdditionalUrl,137 onRemove = vm::removeAdditionalUrl,138 )139 IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)140 IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)141 PasswordField(142 value = f.password,143 show = f.showPassword,144 generatorOpen = f.generatorOpen,145 onChange = vm::setPassword,146 onToggleShow = vm::toggleShowPassword,147 onToggleGenerator = vm::toggleGenerator,148 )149 GeneratorDrawer(150 open = f.generatorOpen,151 length = f.genLength,152 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,153 password = f.password,154 copied = f.genCopied,155 onLength = vm::setGenLength,156 onUpper = vm::setGenUpper, onLower = vm::setGenLower,157 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,158 onGenerate = vm::generate,159 onCopy = vm::copyGenerated,160 onUse = vm::useGeneratedPassword,161 )162 PasswordHistorySection(163 history = f.history,164 expanded = f.showHistory,165 onToggle = vm::toggleHistory,166 onRestore = vm::restorePassword,167 onCopy = vm::copyHistoryPassword,168 )169 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {170 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)171 }172 }173 if (f.type == "card") {174 CardEditFields(175 cardNickname = f.cardNickname,176 cardNumber = f.cardNumber,177 cardholderName = f.cardholderName,178 expiry = f.expiry,179 cvc = f.cvc,180 showCvc = f.showCvc,181 network = f.cardNetwork,182 onCardNickname = vm::setCardNickname,183 onCardNumber = vm::setCardNumber,184 onCardholder = vm::setCardholderName,185 onExpiry = vm::setExpiry,186 onCvc = vm::setCvc,187 onToggleShowCvc = vm::toggleShowCvc,188 )189 }190 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {191 Field("notes", f.notes, vm::setNotes, singleLine = false)192 }193 if (f.type == "login") {194 OptionalLoginAddLinks(195 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),196 showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),197 onAddTotp = vm::revealTotp,198 onAddNotes = vm::revealNotes,199 )200 }201 TagChipEditor(202 tags = f.tags,203 allTags = vm.allTags,204 onAdd = vm::addTag,205 onRemove = vm::removeTag,206 )207208 f.error?.let {209 Spacer(Modifier.height(PeachSpacing.sm))210 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)211 }212 Spacer(Modifier.height(PeachSpacing.xl))213 }214 }215 }216 }217}218219@Composable220private fun EntryEditTopBar(221 title: String,222 saveLabel: String,223 canSave: Boolean,224 onCancel: () -> Unit,225 onSave: () -> Unit,226) {227 val accent = LocalPeachAccent.current228 PeachTopBar(229 title = title,230 leading = {231 Text(232 "cancel",233 color = peachColors.T2,234 fontFamily = PeachType.Mono,235 fontSize = 11.sp,236 modifier = Modifier237 .clip(RoundedCornerShape(8.dp))238 .clickable(onClick = onCancel)239 .padding(PeachSpacing.sm),240 )241 },242 actions = {243 Text(244 saveLabel,245 color = if (canSave) accent else peachColors.T2,246 fontFamily = PeachType.Mono,247 fontSize = 11.sp,248 modifier = Modifier249 .clip(RoundedCornerShape(8.dp))250 .clickable(enabled = canSave, onClick = onSave)251 .padding(PeachSpacing.sm),252 )253 },254 )255}256257258internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {259 !isNew -> "edit"260 type == "note" -> "new note"261 type == "card" -> "new card"262 else -> "new entry"263}264265internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {266 saving -> "saving..."267 isNew -> "create"268 else -> "save"269}270271internal fun canSaveEditForm(form: EditForm): Boolean {272 if (form.title.isBlank()) return false273 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false274 return true275}276277278internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"279280281internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f282283internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =284 type == "login" && (revealed || totp.isNotBlank())285286internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =287 type == "login" && (revealed || notes.isNotBlank())288289internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =290 type == "login" && !shouldShowLoginTotp(type, revealed, totp)291292internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =293 type == "login" && !shouldShowLoginNotes(type, revealed, notes)294295@Composable296private fun OptionalLoginAddLinks(297 showTotpLink: Boolean,298 showNotesLink: Boolean,299 onAddTotp: () -> Unit,300 onAddNotes: () -> Unit,301) {302 if (!showTotpLink && !showNotesLink) return303 Row(304 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),305 horizontalArrangement = Arrangement.spacedBy(14.dp),306 ) {307 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)308 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)309 }310}311312@Composable313private fun AddFieldLink(label: String, onClick: () -> Unit) {314 Text(315 label,316 color = LocalPeachAccent.current,317 fontFamily = PeachType.Mono,318 fontSize = 11.sp,319 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),320 )321}322323@Composable324private fun Field(325 label: String,326 value: String,327 onChange: (String) -> Unit,328 singleLine: Boolean = true,329 transformation: VisualTransformation = VisualTransformation.None,330 trailing: (@Composable () -> Unit)? = null,331) {332 PeachField(333 label = label,334 value = value,335 onChange = onChange,336 singleLine = singleLine,337 mono = true,338 visualTransformation = transformation,339 trailing = trailing,340 )341}342343@Composable344private fun PasswordField(345 value: String,346 show: Boolean,347 generatorOpen: Boolean,348 onChange: (String) -> Unit,349 onToggleShow: () -> Unit,350 onToggleGenerator: () -> Unit,351) {352 Field(353 label = "password",354 value = value,355 onChange = onChange,356 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),357 trailing = {358 Text(if (show) "hide" else "show", color = LocalPeachAccent.current,359 fontFamily = PeachType.Mono, fontSize = 9.sp,360 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))361 Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,362 fontFamily = PeachType.Mono, fontSize = 9.sp,363 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))364 },365 )366}367368internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"369370@Composable371private fun GeneratorDrawer(372 open: Boolean,373 length: Int,374 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,375 password: String,376 copied: Boolean,377 onLength: (Int) -> Unit,378 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,379 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,380 onGenerate: () -> Unit,381 onCopy: () -> Unit,382 onUse: () -> Unit,383) {384 val height by animateDpAsState(385 targetValue = if (open) 326.dp else 0.dp,386 animationSpec = tween(320, easing = PeachMotion.Spring),387 label = "gen-drawer",388 )389 Box(390 Modifier.fillMaxWidth().height(height).clipToBounds()391 .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),392 ) {393 GeneratorBody(394 length = length,395 upper = upper, lower = lower, numbers = numbers, symbols = symbols,396 password = password, copied = copied,397 onLength = onLength,398 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,399 onGenerate = onGenerate, onCopy = onCopy,400 showOutput = true,401 onUse = onUse,402 )403 }404 if (open) Spacer(Modifier.height(14.dp))405}406407408409410411@Composable412internal fun GeneratorBody(413 length: Int,414 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,415 password: String,416 copied: Boolean,417 onLength: (Int) -> Unit,418 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,419 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,420 onGenerate: () -> Unit,421 onCopy: () -> Unit,422 showOutput: Boolean = false,423 onUse: (() -> Unit)? = null,424) {425 val accent = LocalPeachAccent.current426 Column(Modifier.fillMaxWidth().padding(16.dp)) {427 if (showOutput) {428 Box(429 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))430 .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))431 .background(accent.copy(alpha = 0.08f))432 .padding(horizontal = 12.dp, vertical = 10.dp),433 ) {434 Text(435 text = password.ifEmpty { "tap generate" },436 color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,437 fontFamily = PeachType.Mono,438 fontSize = 11.sp,439 maxLines = 2,440 overflow = TextOverflow.Ellipsis,441 )442 }443 Spacer(Modifier.height(10.dp))444 }445 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {446 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)447 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)448 }449 Slider(450 value = length.toFloat(),451 onValueChange = { onLength(it.toInt()) },452 valueRange = 8f..128f,453 colors = SliderDefaults.colors(454 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,455 ),456 )457 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {458 TogglePill("A-Z", upper, onUpper)459 TogglePill("a-z", lower, onLower)460 TogglePill("0-9", numbers, onNumbers)461 TogglePill("!@#", symbols, onSymbols)462 }463 Spacer(Modifier.height(12.dp))464 Row(verticalAlignment = Alignment.CenterVertically) {465 Box(466 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))467 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))468 .background(accent.copy(alpha = 0.10f))469 .clickable(onClick = onGenerate).padding(vertical = 12.dp),470 contentAlignment = Alignment.Center,471 ) {472 Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)473 }474 Spacer(Modifier.width(8.dp))475 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })476 }477 onUse?.let {478 Spacer(Modifier.height(10.dp))479 Box(480 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))481 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))482 .background(accent.copy(alpha = 0.14f))483 .clickable(enabled = password.isNotEmpty(), onClick = it)484 .padding(vertical = 12.dp),485 contentAlignment = Alignment.Center,486 ) {487 Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)488 }489 }490 }491}492493@Composable494private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {495 val accent = LocalPeachAccent.current496 Box(497 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))498 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))499 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)500 .clickable { onToggle(!on) }501 .padding(horizontal = 10.dp, vertical = 6.dp),502 ) {503 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)504 }505}506507508