1package com.peachpasswords.ui.vault23import androidx.compose.animation.AnimatedVisibility4import androidx.compose.animation.core.LinearEasing5import androidx.compose.animation.core.RepeatMode6import androidx.compose.animation.core.animateFloat7import androidx.compose.animation.core.animateFloatAsState8import androidx.compose.animation.core.infiniteRepeatable9import androidx.compose.animation.core.rememberInfiniteTransition10import androidx.compose.animation.core.tween11import androidx.compose.foundation.background12import androidx.compose.foundation.border13import androidx.compose.foundation.clickable14import androidx.compose.foundation.gestures.detectHorizontalDragGestures15import androidx.compose.foundation.gestures.detectVerticalDragGestures16import androidx.compose.foundation.layout.Arrangement17import androidx.compose.foundation.layout.Box18import androidx.compose.foundation.layout.Column19import androidx.compose.foundation.layout.Row20import androidx.compose.foundation.layout.Spacer21import androidx.compose.foundation.layout.fillMaxSize22import androidx.compose.foundation.layout.fillMaxHeight23import androidx.compose.foundation.layout.fillMaxWidth24import androidx.compose.foundation.layout.height25import androidx.compose.foundation.layout.offset26import androidx.compose.foundation.layout.padding27import androidx.compose.foundation.layout.navigationBarsPadding28import androidx.compose.foundation.layout.size29import androidx.compose.foundation.layout.statusBarsPadding30import androidx.compose.foundation.layout.width31import androidx.compose.foundation.rememberScrollState32import androidx.compose.foundation.shape.CircleShape33import androidx.compose.foundation.shape.RoundedCornerShape34import androidx.compose.foundation.text.BasicTextField35import androidx.compose.material.icons.rounded.Check36import androidx.compose.material.icons.rounded.ContentCopy37import androidx.compose.material.icons.rounded.Search38import androidx.compose.foundation.verticalScroll39import androidx.compose.material.icons.Icons40import androidx.compose.material.icons.automirrored.rounded.ArrowBack41import androidx.compose.material.icons.automirrored.rounded.ArrowForward42import androidx.compose.material.icons.outlined.DeleteOutline43import androidx.compose.material.icons.outlined.Lock44import androidx.compose.material3.Icon45import androidx.compose.material3.Text46import androidx.compose.runtime.Composable47import androidx.compose.runtime.getValue48import androidx.compose.runtime.mutableStateOf49import androidx.compose.runtime.produceState50import androidx.compose.runtime.remember51import androidx.compose.runtime.setValue52import androidx.compose.ui.Alignment53import androidx.compose.ui.Modifier54import androidx.compose.ui.draw.clip55import androidx.compose.ui.draw.clipToBounds56import androidx.compose.ui.draw.drawBehind57import androidx.compose.ui.geometry.Offset58import androidx.compose.ui.geometry.Size59import androidx.compose.ui.graphics.Brush60import androidx.compose.ui.graphics.Color61import androidx.compose.ui.graphics.SolidColor62import androidx.compose.ui.graphics.graphicsLayer63import androidx.compose.ui.platform.LocalContext64import androidx.compose.ui.input.pointer.pointerInput65import androidx.compose.ui.text.TextStyle66import androidx.compose.ui.text.font.FontStyle67import androidx.compose.ui.text.style.TextOverflow68import androidx.compose.ui.unit.dp69import androidx.compose.ui.unit.em70import androidx.compose.ui.unit.sp71import androidx.compose.runtime.collectAsState72import androidx.fragment.app.FragmentActivity73import androidx.hilt.navigation.compose.hiltViewModel74import com.peachpasswords.shared.detectHomograph75import com.peachpasswords.ui.PeachNav76import com.peachpasswords.ui.components.CharEnterText77import com.peachpasswords.ui.components.CopyButton78import com.peachpasswords.ui.components.FaviconInitials79import com.peachpasswords.ui.components.PeachEmptyOrEndState80import com.peachpasswords.ui.components.PeachTopBar81import com.peachpasswords.ui.components.SecureFlagEffect82import com.peachpasswords.ui.components.TOTPRing83import com.peachpasswords.ui.lock.Biometrics84import com.peachpasswords.ui.theme.LocalPeachAccent85import com.peachpasswords.ui.theme.LocalReduceMotion86import com.peachpasswords.ui.theme.PeachSpacing87import com.peachpasswords.ui.theme.peachColors88import com.peachpasswords.ui.theme.PeachType89import com.peachpasswords.ui.theme.ShellBackground90import com.peachpasswords.vault.TOTPGenerator91import com.peachpasswords.vault.hasTOTP92import com.peachpasswords.vault.hasPasskey93import com.peachpasswords.vault.isBreach94import com.peachpasswords.vault.isCard95import com.peachpasswords.vault.isNote96import com.peachpasswords.vault.isPasskeyOnly9798@Composable99fun EntryDetailScreen(100 id: String,101 onNavigate: (PeachNav) -> Unit,102 103 104 105 onBack: (() -> Unit)?,106 vm: EntryDetailViewModel = hiltViewModel(),107) {108 val entry by vm.entry.collectAsState()109 val activeEntries by vm.activeEntries.collectAsState()110 val identitySuggestions by vm.identitySuggestions.collectAsState()111 val allTags by vm.allTags.collectAsState()112 val isPro by vm.isPro.collectAsState()113 val cvcRevealed by vm.cvcRevealed.collectAsState()114 var cardRevealed by remember { mutableStateOf(false) }115 val copiedField by vm.copiedField.collectAsState()116 val canaryMessage by vm.canaryMessage.collectAsState()117 val canaryTesting by vm.canaryTesting.collectAsState()118 val context = LocalContext.current119 val accent = LocalPeachAccent.current120 var confirmingDelete by remember { mutableStateOf(false) }121 var generatorOpen by remember { mutableStateOf(false) }122 var historyExpanded by remember { mutableStateOf(false) }123124 SecureFlagEffect()125126 val e = entry127 ShellBackground {128 Box(129 Modifier.fillMaxSize().statusBarsPadding()130 131 132 133 134 .pointerInput(vm) {135 var dragAccum = 0f136 detectHorizontalDragGestures(137 onDragStart = { dragAccum = 0f },138 onDragEnd = {139 when {140 dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)141 dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)142 }143 dragAccum = 0f144 },145 onDragCancel = { dragAccum = 0f },146 ) { _, dragAmount -> dragAccum += dragAmount }147 },148 ) {149 Column(150 Modifier.fillMaxSize()151 .padding(bottom = 100.dp)152 .verticalScroll(rememberScrollState())153 .padding(horizontal = PeachSpacing.xl),154 ) {155 EntryDetailTopBar(156 entry = e,157 confirmingDelete = confirmingDelete,158 onBack = onBack,159 onLock = vm::lock,160 onDelete = {161 if (confirmingDelete) {162 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })163 } else {164 confirmingDelete = true165 }166 },167 )168 Spacer(Modifier.height(PeachSpacing.lg))169170 if (e == null) {171 PeachEmptyOrEndState(172 message = "entry not found",173 detail = "It may have been moved or deleted.",174 modifier = Modifier.padding(top = PeachSpacing.xxl),175 )176 return@Column177 }178179 180 if (e.isBreach()) {181 Row(182 Modifier.fillMaxWidth()183 .background(peachColors.BreachBg)184 .padding(horizontal = 16.dp, vertical = 8.dp),185 verticalAlignment = Alignment.CenterVertically,186 ) {187 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)188 Spacer(Modifier.width(10.dp))189 Text(190 "Password found in a data breach — change it now",191 color = peachColors.Breach, style = PeachType.labelMono,192 modifier = Modifier.weight(1f),193 )194 Text(195 "Change password",196 color = accent, style = PeachType.labelMono,197 modifier = Modifier.clip(RoundedCornerShape(6.dp))198 .clickable { generatorOpen = true }199 .padding(horizontal = 6.dp, vertical = 4.dp),200 )201 }202 }203 if (entryHasHomographWarning(e)) {204 HomographWarning()205 }206207 208 209 210 Box(Modifier.fillMaxWidth().clipToBounds()) {211 Text(212 text = ghostWatermarkText(e.title),213 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),214 color = peachColors.T0.copy(alpha = 0.03f),215 maxLines = 1,216 softWrap = false,217 overflow = TextOverflow.Clip,218 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),219 )220 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {221 EditableTitleHero(222 title = e.title,223 onSave = { vm.updateField("title", it) },224 )225 }226 }227 Spacer(Modifier.height(PeachSpacing.lg))228229 if (e.isCard()) {230 CardDetailFields(231 entry = e,232 cardRevealed = cardRevealed,233 cvcRevealed = cvcRevealed,234 copiedField = copiedField,235 onToggleCardReveal = { cardRevealed = !cardRevealed },236 onRevealCvc = {237 val activity = context as? FragmentActivity238 if (activity != null && Biometrics.isAvailable(context)) {239 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)240 } else vm.revealCvc()241 },242 onMaskCvc = vm::maskCvc,243 onCopy = vm::copy,244 onSaveCard = vm::updateCard,245 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },246 )247 } else {248 if (!e.isNote()) {249 EditableFieldRow(250 label = "url", field = "url", value = e.url.orEmpty(),251 copied = copiedField == "url",252 onCopy = { vm.copy("url", e.url) },253 onSave = { vm.updateField("url", it) },254 placeholder = "add url",255 )256 }257 258 259 260 261 262 var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }263 var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }264 if (showUsernameField) {265 EditableFieldRow(266 label = "username", field = "username", value = e.username.orEmpty(),267 copied = copiedField == "username",268 onCopy = { vm.copy("username", e.username) },269 onSave = { vm.updateField("username", it) },270 suggestions = identitySuggestions,271 placeholder = "add username",272 )273 }274 if (showEmailField) {275 EditableFieldRow(276 label = "email", field = "email", value = e.email.orEmpty(),277 copied = copiedField == "email",278 onCopy = { vm.copy("email", e.email) },279 onSave = { vm.updateField("email", it) },280 suggestions = identitySuggestions,281 placeholder = "add email",282 )283 }284 if (!showUsernameField || !showEmailField) {285 Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {286 if (!showUsernameField) {287 Text(288 "+ add username", color = accent, style = PeachType.labelMono,289 modifier = Modifier290 .clip(RoundedCornerShape(6.dp))291 .clickable { showUsernameField = true }292 .padding(vertical = 6.dp),293 )294 }295 if (!showEmailField) {296 Text(297 "+ add email", color = accent, style = PeachType.labelMono,298 modifier = Modifier299 .clip(RoundedCornerShape(6.dp))300 .clickable { showEmailField = true }301 .padding(vertical = 6.dp),302 )303 }304 }305 }306 AdditionalUrlRows(307 urls = e.additionalUrls.orEmpty(),308 copiedField = copiedField,309 onCopy = { key, value -> vm.copy(key, value) },310 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },311 onAdd = vm::addAdditionalUrl,312 )313 if (e.hasPasskey()) {314 PasskeyDetailSection(e)315 }316 e.password?.takeIf { it.isNotBlank() }?.let { pw ->317 EditablePasswordRow(318 value = pw,319 copied = copiedField == "password",320 entryTitle = e.title,321 onCopy = { vm.copy("password", pw) },322 onSave = { vm.updateField("password", it) },323 onGenerate = { generatorOpen = true },324 )325 }326 val history = e.passwordHistory.orEmpty()327 if (history.isNotEmpty()) {328 PasswordHistoryToggle(329 count = history.size,330 expanded = historyExpanded,331 onToggle = { historyExpanded = !historyExpanded },332 )333 AnimatedVisibility(visible = historyExpanded) {334 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {335 PasswordHistoryList(336 history = history,337 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },338 onCopy = { oldPw -> vm.copy("password", oldPw) },339 )340 }341 }342 }343 if (e.hasTOTP()) {344 Spacer(Modifier.height(4.dp))345 TotpBandEditable(346 totpField = e.totp!!,347 copied = copiedField == "totp",348 onCopy = vm::copyTotp,349 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },350 )351 }352 }353 e.notes?.takeIf { it.isNotBlank() }?.let { v ->354 EditableFieldRow(355 label = "notes", field = "notes", value = v,356 copied = copiedField == "notes",357 onCopy = { vm.copy("notes", v) },358 onSave = { vm.updateField("notes", it) },359 mono = false, singleLine = false,360 )361 }362 DetailTagRow(363 tags = e.tags.orEmpty(),364 allTags = allTags,365 onAddTag = vm::addTag,366 onRemoveTag = vm::removeTag,367 )368 if (!e.isNote() && !e.isCard()) {369 370 371 372 373 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }374 if (advancedExpanded) {375 CanaryDetailSection(376 entryId = e.id,377 modifiedAt = e.modifiedAt,378 canary = e.canary,379 isPro = isPro,380 message = canaryMessage,381 testing = canaryTesting,382 onSave = vm::saveCanary,383 onTest = vm::testCanary,384 )385 } else {386 Text(387 "+ advanced",388 color = LocalPeachAccent.current,389 style = PeachType.labelMono,390 modifier = Modifier391 .clip(RoundedCornerShape(6.dp))392 .clickable { advancedExpanded = true }393 .padding(vertical = 6.dp),394 )395 }396 }397398 Spacer(Modifier.height(PeachSpacing.xxl))399 }400401 if (e != null) {402 EntryDetailSearchStrip(403 currentIndex = activeEntries.indexOfFirst { it.id == e.id },404 totalEntries = activeEntries.size,405 onOpenList = { onNavigate(PeachNav.VaultList) },406 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },407 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },408 modifier = Modifier.align(Alignment.BottomCenter),409 )410 }411412 if (generatorOpen) {413 GeneratorSheet(414 copied = copiedField == "genDraft",415 onCopy = { draft -> vm.copy("genDraft", draft) },416 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },417 onDismiss = { generatorOpen = false },418 )419 }420 }421 }422}423424@Composable425private fun EntryDetailTopBar(426 entry: com.peachpasswords.vault.VaultEntry?,427 confirmingDelete: Boolean,428 onBack: (() -> Unit)?,429 onLock: () -> Unit,430 onDelete: () -> Unit,431) {432 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }433 PeachTopBar(434 title = when {435 entry == null -> "entry"436 confirmingDelete -> "delete \"${entry.title}\"?"437 domain.isNotEmpty() -> domain438 else -> entry.title439 },440 leading = {441 if (onBack != null) {442 Icon(443 Icons.AutoMirrored.Rounded.ArrowBack,444 contentDescription = "back",445 tint = peachColors.T0,446 modifier = Modifier447 .size(40.dp)448 .clip(CircleShape)449 .clickable(onClick = onBack)450 .padding(PeachSpacing.sm),451 )452 } else {453 454 455 456 457 458 459 460 Icon(461 Icons.Outlined.Lock,462 contentDescription = "Lock vault",463 tint = peachColors.T0,464 modifier = Modifier465 .size(40.dp)466 .clip(CircleShape)467 .clickable(onClick = onLock)468 .padding(11.dp),469 )470 }471 if (entry != null && !confirmingDelete) {472 FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)473 }474 },475 actions = {476 if (entry != null) {477 Icon(478 Icons.Outlined.DeleteOutline,479 contentDescription = "delete",480 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,481 modifier = Modifier482 .size(40.dp)483 .clip(CircleShape)484 .clickable(onClick = onDelete)485 .padding(PeachSpacing.sm),486 )487 }488 },489 )490}491492@Composable493private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {494 val accent = LocalPeachAccent.current495 var editing by remember(title) { mutableStateOf(false) }496 var draft by remember(title) { mutableStateOf(title) }497 if (!editing) {498 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {499 CharEnterText(500 text = title,501 style = PeachType.entryNameHero.copy(color = peachColors.T0),502 )503 }504 return505 }506 Column(Modifier.fillMaxWidth()) {507 BasicTextField(508 value = draft,509 onValueChange = { draft = it },510 singleLine = true,511 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),512 cursorBrush = SolidColor(accent),513 modifier = Modifier.fillMaxWidth().drawBehind {514 drawLine(515 color = accent,516 start = Offset(0f, size.height),517 end = Offset(size.width, size.height),518 strokeWidth = 1.dp.toPx(),519 )520 },521 )522 Spacer(Modifier.height(8.dp))523 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {524 Text(525 "✓ save", color = accent, style = PeachType.labelMono,526 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {527 onSave(draft)528 editing = false529 }.padding(vertical = 4.dp),530 )531 Text(532 "✗", color = peachColors.T2, style = PeachType.labelMono,533 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {534 draft = title535 editing = false536 }.padding(horizontal = 6.dp, vertical = 4.dp),537 )538 }539 }540}541542@Composable543private fun HomographWarning() {544 Row(545 Modifier.fillMaxWidth()546 .padding(top = 8.dp)547 .clip(RoundedCornerShape(8.dp))548 .background(peachColors.BreachBg)549 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))550 .padding(horizontal = 12.dp, vertical = 9.dp),551 verticalAlignment = Alignment.CenterVertically,552 ) {553 Text("!", color = peachColors.Breach, style = PeachType.labelMono)554 Spacer(Modifier.width(10.dp))555 Text(556 "This URL contains non-ASCII or punycode characters.",557 color = peachColors.Breach,558 style = PeachType.labelMono,559 modifier = Modifier.weight(1f),560 )561 }562}563564@Composable565private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {566 val passkey = entry.passkey ?: return567 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {568 Column(Modifier.weight(1f)) {569 Text(570 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,571 color = peachColors.T0,572 style = PeachType.body,573 )574 Text(575 listOfNotNull(576 passkey.rpId,577 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,578 ).joinToString(" · "),579 color = peachColors.T2,580 style = PeachType.labelMono,581 )582 Text(583 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",584 color = peachColors.T2,585 style = PeachType.labelMono,586 modifier = Modifier.padding(top = 3.dp),587 )588 }589 }590}591592@Composable593private fun CanaryDetailSection(594 entryId: String,595 modifiedAt: Long,596 canary: com.peachpasswords.vault.Canary?,597 isPro: Boolean,598 message: String?,599 testing: Boolean,600 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,601 onTest: (webhookUrl: String) -> Unit,602) {603 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }604 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }605 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }606 val gated = !isPro && canary == null607 FieldShell("canary alert") {608 Column(Modifier.weight(1f)) {609 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {610 Text(611 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",612 color = peachColors.T0,613 style = PeachType.body,614 modifier = Modifier.weight(1f),615 )616 if (gated) {617 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)618 }619 Text(620 if (enabled) "on" else "off",621 color = if (enabled) LocalPeachAccent.current else peachColors.T2,622 style = PeachType.labelMono,623 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {624 if (gated) {625 onSave(true, webhookUrl, label)626 } else {627 val next = !enabled628 enabled = next629 if (!next) onSave(false, webhookUrl, label)630 }631 }.padding(horizontal = 6.dp, vertical = 4.dp),632 )633 }634 Text(635 "Fires a silent webhook when this entry's password or username is copied.",636 color = peachColors.T2,637 style = PeachType.labelMono,638 modifier = Modifier.padding(top = 3.dp),639 )640 if (enabled) {641 Spacer(Modifier.height(8.dp))642 PeachField(643 label = "webhook url",644 value = webhookUrl,645 onChange = { webhookUrl = it },646 )647 Text(648 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",649 color = peachColors.T2,650 style = PeachType.labelMono,651 )652 PeachField(653 label = "label",654 value = label,655 onChange = { label = it },656 mono = false,657 )658 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {659 Text(660 "save",661 color = LocalPeachAccent.current,662 style = PeachType.labelMono,663 modifier = Modifier.clip(RoundedCornerShape(6.dp))664 .clickable { onSave(true, webhookUrl, label) }665 .padding(vertical = 4.dp),666 )667 Text(668 if (testing) "sending..." else "test ping",669 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,670 style = PeachType.labelMono,671 modifier = Modifier.clip(RoundedCornerShape(6.dp))672 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }673 .padding(vertical = 4.dp),674 )675 }676 }677 message?.let {678 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))679 }680 if (canary?.lastPinged != null || canary?.pingCount != null) {681 Text(682 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),683 color = peachColors.T2,684 style = PeachType.labelMono,685 modifier = Modifier.padding(top = 3.dp),686 )687 }688 }689 }690}691692@Composable693private fun EntryDetailSearchStrip(694 currentIndex: Int,695 totalEntries: Int,696 onOpenList: () -> Unit,697 onPrevious: () -> Unit,698 onNext: () -> Unit,699 modifier: Modifier = Modifier,700) {701 val accent = LocalPeachAccent.current702 val hasEntries = totalEntries > 0 && currentIndex >= 0703 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"704705 Column(706 modifier707 .fillMaxWidth()708 .navigationBarsPadding()709 .padding(horizontal = 16.dp, vertical = 12.dp),710 ) {711 Row(712 Modifier713 .fillMaxWidth()714 .height(56.dp)715 .clip(RoundedCornerShape(16.dp))716 .background(peachColors.S1)717 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))718 .padding(horizontal = 12.dp),719 verticalAlignment = Alignment.CenterVertically,720 ) {721 Row(722 Modifier723 .weight(1f)724 .fillMaxHeight()725 .clickable(onClick = onOpenList)726 .pointerInput(onOpenList) {727 var opened = false728 detectVerticalDragGestures(729 onDragEnd = { opened = false },730 onDragCancel = { opened = false },731 ) { _, dragAmount ->732 if (!opened && dragAmount < -4f) {733 opened = true734 onOpenList()735 }736 }737 },738 verticalAlignment = Alignment.CenterVertically,739 ) {740 Icon(741 Icons.Rounded.Search,742 contentDescription = null,743 tint = accent,744 modifier = Modifier.size(14.dp),745 )746 Spacer(Modifier.width(8.dp))747 Text(748 "search vault...",749 color = peachColors.T2,750 style = PeachType.searchInput,751 modifier = Modifier.weight(1f),752 )753 Text(754 counter,755 color = peachColors.T2,756 style = PeachType.labelMono,757 )758 }759 Spacer(Modifier.width(8.dp))760 Icon(761 Icons.AutoMirrored.Rounded.ArrowBack,762 contentDescription = "Previous entry",763 tint = if (hasEntries) peachColors.T0 else peachColors.T2,764 modifier = Modifier765 .size(38.dp)766 .clip(CircleShape)767 .clickable(enabled = hasEntries, onClick = onPrevious)768 .padding(9.dp),769 )770 Icon(771 Icons.AutoMirrored.Rounded.ArrowForward,772 contentDescription = "Next entry",773 tint = if (hasEntries) peachColors.T0 else peachColors.T2,774 modifier = Modifier775 .size(38.dp)776 .clip(CircleShape)777 .clickable(enabled = hasEntries, onClick = onNext)778 .padding(9.dp),779 )780 }781 }782}783784785@Composable786internal fun FieldShell(787 label: String,788 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,789) {790 val hairline = peachColors.T3791 Column(792 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {793 drawRect(794 color = hairline,795 topLeft = Offset(0f, size.height - 1.dp.toPx()),796 size = Size(size.width, 1.dp.toPx()),797 )798 },799 ) {800 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))801 Spacer(Modifier.height(6.dp))802 Row(verticalAlignment = Alignment.CenterVertically, content = content)803 }804}805806807808809810internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }811812internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =813 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)814815internal fun shortCredential(credentialId: String): String {816 if (credentialId.length <= 12) return credentialId817 return credentialId.take(6) + "..." + credentialId.takeLast(4)818}819820internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {821 val count = pingCount ?: 0822 val countLabel = "$count total alert" + if (count == 1) "" else "s"823 if (lastPinged == null) return countLabel824 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)825 .format(java.util.Date(lastPinged))826 return "Last triggered: $date · $countLabel"827}828829private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {830 val raw = url831 if (!raw.isNullOrBlank()) {832 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')833 if (cleaned.isNotEmpty()) return cleaned834 }835 return ""836}