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.FontStyle38import androidx.compose.ui.text.font.FontWeight39import androidx.compose.ui.text.input.PasswordVisualTransformation40import androidx.compose.ui.text.input.VisualTransformation41import 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.theme.LocalPeachAccent48import com.peachpasswords.ui.theme.peachColors49import com.peachpasswords.ui.theme.PeachMotion50import com.peachpasswords.ui.theme.PeachRadius51import com.peachpasswords.ui.theme.PeachType52import com.peachpasswords.ui.theme.ShellBackground5354@Composable55fun EntryEditScreen(56 id: String?,57 onBack: () -> Unit,58 onOpenNote: (String) -> Unit = {},59 vm: EntryEditViewModel = hiltViewModel(),60) {61 val f by vm.form.collectAsState()62 val usernameSuggestions by vm.usernameSuggestions.collectAsState()63 val emailSuggestions by vm.emailSuggestions.collectAsState()64 val accent = LocalPeachAccent.current6566 67 val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(68 targetValue = editGhostWatermarkTarget(f.title),69 animationSpec = tween(300),70 label = "edit-watermark",71 )7273 ShellBackground {74 Column(Modifier.fillMaxSize().statusBarsPadding()) {75 Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 18.dp)) {76 val canSave = canSaveEditForm(f)77 Text("cancel", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,78 modifier = Modifier.align(Alignment.CenterStart)79 .clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(8.dp))80 Text(editTitleBarLabel(vm.isNew, f.type), color = peachColors.T1,81 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,82 modifier = Modifier.align(Alignment.Center))83 Text(entryEditPrimaryActionLabel(vm.isNew, f.saving), color = if (canSave && !f.saving) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,84 modifier = Modifier.align(Alignment.CenterEnd)85 .clip(RoundedCornerShape(8.dp)).clickable(enabled = canSave && !f.saving) { vm.save(onBack) }.padding(8.dp))86 }8788 Box(Modifier.fillMaxSize().clipToBounds()) {89 90 91 if (watermarkAlpha > 0f) {92 Text(93 text = ghostWatermarkText(f.title),94 style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),95 color = peachColors.T0.copy(alpha = watermarkAlpha),96 maxLines = 1,97 softWrap = false,98 overflow = androidx.compose.ui.text.style.TextOverflow.Clip,99 modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),100 )101 }102 Column(103 Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),104 ) {105 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {106 listOf("login", "note", "card").forEach { t ->107 EntryTypePill(t, f.type == t) {108 109 110 if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)111 else vm.setType(t)112 }113 }114 }115 Spacer(Modifier.height(18.dp))116117 Field("title", f.title, vm::setTitle)118 if (f.type == "login") {119 Field("url", f.url, vm::setUrl)120 AdditionalUrlsEditor(121 urls = f.additionalUrls,122 onChange = vm::setAdditionalUrl,123 onAdd = vm::addAdditionalUrl,124 onRemove = vm::removeAdditionalUrl,125 )126 IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)127 IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)128 PasswordField(129 value = f.password,130 show = f.showPassword,131 onChange = vm::setPassword,132 onToggleShow = vm::toggleShowPassword,133 onToggleGenerator = vm::toggleGenerator,134 )135 GeneratorDrawer(136 open = f.generatorOpen,137 length = f.genLength,138 upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,139 password = f.password,140 copied = f.genCopied,141 onLength = vm::setGenLength,142 onUpper = vm::setGenUpper, onLower = vm::setGenLower,143 onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,144 onGenerate = vm::generate,145 onCopy = vm::copyGenerated,146 )147 PasswordHistorySection(148 history = f.history,149 expanded = f.showHistory,150 onToggle = vm::toggleHistory,151 onRestore = vm::restorePassword,152 onCopy = vm::copyHistoryPassword,153 )154 if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {155 Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)156 }157 }158 if (f.type == "card") {159 CardEditFields(160 cardNumber = f.cardNumber,161 cardholderName = f.cardholderName,162 expiry = f.expiry,163 cvc = f.cvc,164 showCvc = f.showCvc,165 network = f.cardNetwork,166 onCardNumber = vm::setCardNumber,167 onCardholder = vm::setCardholderName,168 onExpiry = vm::setExpiry,169 onCvc = vm::setCvc,170 onToggleShowCvc = vm::toggleShowCvc,171 )172 }173 if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {174 Field("notes", f.notes, vm::setNotes, singleLine = false)175 }176 if (f.type == "login") {177 OptionalLoginAddLinks(178 showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),179 showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),180 onAddTotp = vm::revealTotp,181 onAddNotes = vm::revealNotes,182 )183 }184 TagChipEditor(185 tags = f.tags,186 allTags = vm.allTags,187 onAdd = vm::addTag,188 onRemove = vm::removeTag,189 )190191 f.error?.let {192 Spacer(Modifier.height(8.dp))193 Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)194 }195 Spacer(Modifier.height(40.dp))196 }197 }198 }199 }200}201202@Composable203private fun EntryTypePill(type: String, selected: Boolean, onClick: () -> Unit) {204 val accent = LocalPeachAccent.current205 Box(206 modifier = Modifier207 .clip(RoundedCornerShape(PeachRadius.TypeTab))208 .border(209 1.dp,210 if (selected) accent.copy(alpha = 0.5f) else peachColors.T3,211 RoundedCornerShape(PeachRadius.TypeTab),212 )213 .background(if (selected) accent.copy(alpha = 0.08f) else Color.Transparent)214 .clickable(onClick = onClick)215 .padding(horizontal = 12.dp, vertical = 5.dp),216 ) {217 Text(218 text = type.uppercase(),219 color = if (selected) accent else peachColors.T2,220 fontFamily = PeachType.Mono,221 fontSize = 9.sp,222 letterSpacing = 0.08.em,223 )224 }225}226227228internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {229 !isNew -> "edit"230 type == "note" -> "new note"231 type == "card" -> "new card"232 else -> "new entry"233}234235internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {236 saving -> "saving..."237 isNew -> "create"238 else -> "save"239}240241internal fun canSaveEditForm(form: EditForm): Boolean {242 if (form.title.isBlank()) return false243 if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false244 return true245}246247248internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"249250251internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f252253internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =254 type == "login" && (revealed || totp.isNotBlank())255256internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =257 type == "login" && (revealed || notes.isNotBlank())258259internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =260 type == "login" && !shouldShowLoginTotp(type, revealed, totp)261262internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =263 type == "login" && !shouldShowLoginNotes(type, revealed, notes)264265@Composable266private fun OptionalLoginAddLinks(267 showTotpLink: Boolean,268 showNotesLink: Boolean,269 onAddTotp: () -> Unit,270 onAddNotes: () -> Unit,271) {272 if (!showTotpLink && !showNotesLink) return273 Row(274 Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),275 horizontalArrangement = Arrangement.spacedBy(14.dp),276 ) {277 if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)278 if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)279 }280}281282@Composable283private fun AddFieldLink(label: String, onClick: () -> Unit) {284 Text(285 label,286 color = LocalPeachAccent.current,287 fontFamily = PeachType.Mono,288 fontSize = 11.sp,289 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),290 )291}292293@Composable294private fun Field(295 label: String,296 value: String,297 onChange: (String) -> Unit,298 singleLine: Boolean = true,299 transformation: VisualTransformation = VisualTransformation.None,300 trailing: (@Composable () -> Unit)? = null,301) {302 PeachField(303 label = label,304 value = value,305 onChange = onChange,306 singleLine = singleLine,307 mono = true,308 visualTransformation = transformation,309 trailing = trailing,310 )311}312313@Composable314private fun PasswordField(315 value: String,316 show: Boolean,317 onChange: (String) -> Unit,318 onToggleShow: () -> Unit,319 onToggleGenerator: () -> Unit,320) {321 Field(322 label = "password",323 value = value,324 onChange = onChange,325 transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),326 trailing = {327 Text(if (show) "hide" else "show", color = LocalPeachAccent.current,328 fontFamily = PeachType.Mono, fontSize = 9.sp,329 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))330 Text("⟳", color = LocalPeachAccent.current, fontSize = 16.sp,331 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))332 },333 )334}335336@Composable337private fun GeneratorDrawer(338 open: Boolean,339 length: Int,340 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,341 password: String,342 copied: Boolean,343 onLength: (Int) -> Unit,344 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,345 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,346 onGenerate: () -> Unit,347 onCopy: () -> Unit,348) {349 val height by animateDpAsState(350 targetValue = if (open) 234.dp else 0.dp,351 animationSpec = tween(320, easing = PeachMotion.Spring),352 label = "gen-drawer",353 )354 Box(355 Modifier.fillMaxWidth().height(height).clipToBounds()356 .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),357 ) {358 GeneratorBody(359 length = length,360 upper = upper, lower = lower, numbers = numbers, symbols = symbols,361 password = password, copied = copied,362 onLength = onLength,363 onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,364 onGenerate = onGenerate, onCopy = onCopy,365 )366 }367 if (open) Spacer(Modifier.height(14.dp))368}369370371372373374@Composable375internal fun GeneratorBody(376 length: Int,377 upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,378 password: String,379 copied: Boolean,380 onLength: (Int) -> Unit,381 onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,382 onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,383 onGenerate: () -> Unit,384 onCopy: () -> Unit,385) {386 val accent = LocalPeachAccent.current387 Column(Modifier.fillMaxWidth().padding(16.dp)) {388 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {389 Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)390 Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)391 }392 Slider(393 value = length.toFloat(),394 onValueChange = { onLength(it.toInt()) },395 valueRange = 8f..128f,396 colors = SliderDefaults.colors(397 thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,398 ),399 )400 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {401 TogglePill("A-Z", upper, onUpper)402 TogglePill("a-z", lower, onLower)403 TogglePill("0-9", numbers, onNumbers)404 TogglePill("!@#", symbols, onSymbols)405 }406 Spacer(Modifier.height(12.dp))407 Row(verticalAlignment = Alignment.CenterVertically) {408 Box(409 Modifier.weight(1f).clip(RoundedCornerShape(10.dp))410 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))411 .background(accent.copy(alpha = 0.10f))412 .clickable(onClick = onGenerate).padding(vertical = 12.dp),413 contentAlignment = Alignment.Center,414 ) {415 Text("generate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)416 }417 Spacer(Modifier.width(8.dp))418 CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })419 }420 }421}422423@Composable424private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {425 val accent = LocalPeachAccent.current426 Box(427 Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))428 .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))429 .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)430 .clickable { onToggle(!on) }431 .padding(horizontal = 10.dp, vertical = 6.dp),432 ) {433 Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)434 }435}436437438