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.Add36import androidx.compose.material.icons.rounded.Check37import androidx.compose.material.icons.rounded.ContentCopy38import androidx.compose.material.icons.rounded.Search39import androidx.compose.material.icons.rounded.Settings40import androidx.compose.foundation.verticalScroll41import androidx.compose.material.icons.Icons42import androidx.compose.material.icons.automirrored.rounded.ArrowBack43import androidx.compose.material.icons.automirrored.rounded.ArrowForward44import androidx.compose.material.icons.outlined.Add45import androidx.compose.material.icons.outlined.Delete46import androidx.compose.material.icons.outlined.Description47import androidx.compose.material.icons.outlined.Settings48import androidx.compose.material.icons.outlined.Lock49import androidx.compose.material3.Icon50import androidx.compose.material3.Text51import androidx.compose.runtime.Composable52import androidx.compose.runtime.getValue53import androidx.compose.runtime.mutableStateOf54import androidx.compose.runtime.produceState55import androidx.compose.runtime.remember56import androidx.compose.runtime.setValue57import androidx.compose.ui.Alignment58import androidx.compose.ui.Modifier59import androidx.compose.ui.draw.clip60import androidx.compose.ui.draw.clipToBounds61import androidx.compose.ui.draw.drawBehind62import androidx.compose.ui.geometry.Offset63import androidx.compose.ui.geometry.Size64import androidx.compose.ui.graphics.Brush65import androidx.compose.ui.graphics.Color66import androidx.compose.ui.graphics.SolidColor67import androidx.compose.ui.graphics.graphicsLayer68import androidx.compose.ui.platform.LocalContext69import androidx.compose.ui.input.pointer.pointerInput70import androidx.compose.ui.text.TextStyle71import androidx.compose.ui.text.font.FontStyle72import androidx.compose.ui.text.style.TextOverflow73import androidx.compose.ui.unit.dp74import androidx.compose.ui.unit.em75import androidx.compose.ui.unit.sp76import androidx.compose.runtime.collectAsState77import androidx.fragment.app.FragmentActivity78import androidx.hilt.navigation.compose.hiltViewModel79import com.peachpasswords.shared.detectHomograph80import com.peachpasswords.ui.PeachNav81import com.peachpasswords.ui.components.CharEnterText82import com.peachpasswords.ui.components.CopyButton83import com.peachpasswords.ui.components.PeachEmptyOrEndState84import com.peachpasswords.ui.components.PeachMark85import com.peachpasswords.ui.components.PeachTopBar86import com.peachpasswords.ui.components.SecureFlagEffect87import com.peachpasswords.ui.components.TOTPRing88import com.peachpasswords.ui.lock.Biometrics89import com.peachpasswords.ui.theme.LocalPeachAccent90import com.peachpasswords.ui.theme.LocalReduceMotion91import com.peachpasswords.ui.theme.PeachSpacing92import com.peachpasswords.ui.theme.peachColors93import com.peachpasswords.ui.theme.PeachType94import com.peachpasswords.ui.theme.ShellBackground95import com.peachpasswords.vault.TOTPGenerator96import com.peachpasswords.vault.hasTOTP97import com.peachpasswords.vault.hasPasskey98import com.peachpasswords.vault.isBreach99import com.peachpasswords.vault.isCard100import com.peachpasswords.vault.isNote101import com.peachpasswords.vault.isPasskeyOnly102103@Composable104fun EntryDetailScreen(105 id: String,106 onNavigate: (PeachNav) -> Unit,107 108 109 110 onBack: (() -> Unit)?,111 modifier: Modifier = Modifier,112 vm: EntryDetailViewModel = hiltViewModel(),113) {114 SecureFlagEffect()115116 val entry by vm.entry.collectAsState()117 val activeEntries by vm.activeEntries.collectAsState()118 val identitySuggestions by vm.identitySuggestions.collectAsState()119 val allTags by vm.allTags.collectAsState()120 val isPro by vm.isPro.collectAsState()121 val cvcRevealed by vm.cvcRevealed.collectAsState()122 var cardRevealed by remember { mutableStateOf(false) }123 val copiedField by vm.copiedField.collectAsState()124 val canaryMessage by vm.canaryMessage.collectAsState()125 val canaryTesting by vm.canaryTesting.collectAsState()126 val context = LocalContext.current127 val accent = LocalPeachAccent.current128 var confirmingDelete by remember { mutableStateOf(false) }129 var generatorOpen by remember { mutableStateOf(false) }130 var historyExpanded by remember { mutableStateOf(false) }131132 val e = entry133 ShellBackground(modifier) {134 Box(135 Modifier.fillMaxSize().statusBarsPadding()136 137 138 139 140 .pointerInput(vm) {141 var dragAccum = 0f142 detectHorizontalDragGestures(143 onDragStart = { dragAccum = 0f },144 onDragEnd = {145 when {146 dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)147 dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)148 }149 dragAccum = 0f150 },151 onDragCancel = { dragAccum = 0f },152 ) { _, dragAmount -> dragAccum += dragAmount }153 },154 ) {155 Column(156 Modifier.fillMaxSize()157 .padding(bottom = 100.dp)158 .verticalScroll(rememberScrollState())159 .padding(horizontal = PeachSpacing.xl),160 ) {161 EntryDetailTopBar(162 entry = e,163 confirmingDelete = confirmingDelete,164 onBack = onBack,165 onLock = vm::lock,166 onDelete = {167 if (confirmingDelete) {168 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })169 } else {170 confirmingDelete = true171 }172 },173 onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },174 onSettings = { onNavigate(PeachNav.Settings) },175 onOpenNotes = { onNavigate(PeachNav.Notes) },176 )177 Spacer(Modifier.height(PeachSpacing.lg))178179 if (e == null) {180 PeachEmptyOrEndState(181 message = "entry not found",182 detail = "It may have been moved or deleted.",183 modifier = Modifier.padding(top = PeachSpacing.xxl),184 )185 return@Column186 }187188 189 if (e.isBreach()) {190 Row(191 Modifier.fillMaxWidth()192 .background(peachColors.BreachBg)193 .padding(horizontal = 16.dp, vertical = 8.dp),194 verticalAlignment = Alignment.CenterVertically,195 ) {196 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)197 Spacer(Modifier.width(10.dp))198 Text(199 "Password found in a data breach — change it now",200 color = peachColors.Breach, style = PeachType.labelMono,201 modifier = Modifier.weight(1f),202 )203 Text(204 "Change password",205 color = accent, style = PeachType.labelMono,206 modifier = Modifier.clip(RoundedCornerShape(6.dp))207 .clickable { generatorOpen = true }208 .padding(horizontal = 6.dp, vertical = 4.dp),209 )210 }211 }212 if (entryHasHomographWarning(e)) {213 HomographWarning()214 }215216 217 218 219 Box(Modifier.fillMaxWidth().clipToBounds()) {220 Text(221 text = ghostWatermarkText(e.title),222 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),223 color = peachColors.T0.copy(alpha = 0.03f),224 maxLines = 1,225 softWrap = false,226 overflow = TextOverflow.Clip,227 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),228 )229 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {230 EditableTitleHero(231 title = e.title,232 onSave = { vm.updateField("title", it) },233 )234 }235 }236 Spacer(Modifier.height(PeachSpacing.lg))237238 if (e.isCard()) {239 CardDetailFields(240 entry = e,241 cardRevealed = cardRevealed,242 cvcRevealed = cvcRevealed,243 copiedField = copiedField,244 onToggleCardReveal = { cardRevealed = !cardRevealed },245 onRevealCvc = {246 val activity = context as? FragmentActivity247 if (activity != null && Biometrics.isAvailable(context)) {248 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)249 } else vm.revealCvc()250 },251 onMaskCvc = vm::maskCvc,252 onCopy = vm::copy,253 onSaveCard = vm::updateCard,254 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },255 )256 } else {257 if (!e.isNote()) {258 EditableFieldRow(259 label = "url", field = "url", value = e.url.orEmpty(),260 copied = copiedField == "url",261 onCopy = { vm.copy("url", e.url) },262 onSave = { vm.updateField("url", it) },263 placeholder = "add url",264 )265 }266 267 268 269 270 271 var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }272 var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }273 if (showUsernameField) {274 EditableFieldRow(275 label = "username", field = "username", value = e.username.orEmpty(),276 copied = copiedField == "username",277 onCopy = { vm.copy("username", e.username) },278 onSave = { vm.updateField("username", it) },279 suggestions = identitySuggestions,280 placeholder = "add username",281 )282 }283 if (showEmailField) {284 EditableFieldRow(285 label = "email", field = "email", value = e.email.orEmpty(),286 copied = copiedField == "email",287 onCopy = { vm.copy("email", e.email) },288 onSave = { vm.updateField("email", it) },289 suggestions = identitySuggestions,290 placeholder = "add email",291 )292 }293 if (!showUsernameField || !showEmailField) {294 Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {295 if (!showUsernameField) {296 Text(297 "+ add username", color = accent, style = PeachType.labelMono,298 modifier = Modifier299 .clip(RoundedCornerShape(6.dp))300 .clickable { showUsernameField = true }301 .padding(vertical = 6.dp),302 )303 }304 if (!showEmailField) {305 Text(306 "+ add email", color = accent, style = PeachType.labelMono,307 modifier = Modifier308 .clip(RoundedCornerShape(6.dp))309 .clickable { showEmailField = true }310 .padding(vertical = 6.dp),311 )312 }313 }314 }315 AdditionalUrlRows(316 urls = e.additionalUrls.orEmpty(),317 copiedField = copiedField,318 onCopy = { key, value -> vm.copy(key, value) },319 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },320 onAdd = vm::addAdditionalUrl,321 )322 if (e.hasPasskey()) {323 PasskeyDetailSection(e)324 }325 e.password?.takeIf { it.isNotBlank() }?.let { pw ->326 EditablePasswordRow(327 value = pw,328 copied = copiedField == "password",329 entryTitle = e.title,330 onCopy = { vm.copy("password", pw) },331 onSave = { vm.updateField("password", it) },332 onGenerate = { generatorOpen = true },333 )334 }335 336 337 338 339 val history = e.passwordHistory.orEmpty()340 PasswordHistoryToggle(341 count = history.size,342 expanded = historyExpanded,343 onToggle = { historyExpanded = !historyExpanded },344 )345 AnimatedVisibility(visible = historyExpanded) {346 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {347 PasswordHistoryList(348 history = history,349 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },350 onCopy = { oldPw -> vm.copy("password", oldPw) },351 )352 }353 }354 if (e.hasTOTP()) {355 Spacer(Modifier.height(4.dp))356 TotpBandEditable(357 totpField = e.totp!!,358 copied = copiedField == "totp",359 onCopy = vm::copyTotp,360 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },361 )362 }363 }364 e.notes?.takeIf { it.isNotBlank() }?.let { v ->365 366 367 val notesAreHtml = isNoteHtml(v)368 EditableFieldRow(369 label = "notes", field = "notes", value = v,370 copied = copiedField == "notes",371 onCopy = { vm.copy("notes", notePlainText(v)) },372 onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },373 mono = false, singleLine = false,374 displayText = noteRendered(v),375 draftText = noteEditorText(v),376 )377 }378 DetailTagRow(379 tags = e.tags.orEmpty(),380 allTags = allTags,381 onAddTag = vm::addTag,382 onRemoveTag = vm::removeTag,383 )384 if (!e.isNote() && !e.isCard()) {385 386 387 388 389 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }390 if (advancedExpanded) {391 CanaryDetailSection(392 entryId = e.id,393 modifiedAt = e.modifiedAt,394 canary = e.canary,395 isPro = isPro,396 message = canaryMessage,397 testing = canaryTesting,398 onSave = vm::saveCanary,399 onTest = vm::testCanary,400 )401 } else {402 Text(403 "+ advanced",404 color = LocalPeachAccent.current,405 style = PeachType.labelMono,406 modifier = Modifier407 .clip(RoundedCornerShape(6.dp))408 .clickable { advancedExpanded = true }409 .padding(vertical = 6.dp),410 )411 }412 }413414 Spacer(Modifier.height(PeachSpacing.xxl))415 }416417 if (e != null) {418 EntryDetailSearchStrip(419 currentIndex = activeEntries.indexOfFirst { it.id == e.id },420 totalEntries = activeEntries.size,421 onOpenList = { onNavigate(PeachNav.VaultList) },422 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },423 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },424 modifier = Modifier.align(Alignment.BottomCenter),425 )426 }427428 if (generatorOpen) {429 GeneratorSheet(430 copied = copiedField == "genDraft",431 onCopy = { draft -> vm.copy("genDraft", draft) },432 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },433 onDismiss = { generatorOpen = false },434 )435 }436 }437 }438}439440@Composable441private fun EntryDetailTopBar(442 entry: com.peachpasswords.vault.VaultEntry?,443 confirmingDelete: Boolean,444 onBack: (() -> Unit)?,445 onLock: () -> Unit,446 onDelete: () -> Unit,447 onAddEntry: () -> Unit,448 onSettings: () -> Unit,449 onOpenNotes: () -> Unit,450) {451 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }452 val accent = LocalPeachAccent.current453 PeachTopBar(454 title = when {455 confirmingDelete && entry != null -> "delete \"${entry.title}\"?"456 457 458 459 460 onBack == null -> "peach"461 entry == null -> "entry"462 domain.isNotEmpty() -> domain463 else -> entry.title464 },465 leading = {466 if (onBack != null) {467 Icon(468 Icons.AutoMirrored.Rounded.ArrowBack,469 contentDescription = "back",470 tint = peachColors.T1,471 modifier = Modifier472 .size(40.dp)473 .clip(CircleShape)474 .clickable(onClick = onBack)475 .padding(PeachSpacing.sm),476 )477 } else {478 PeachMark(modifier = Modifier.padding(start = PeachSpacing.sm, end = PeachSpacing.xs))479 }480 481 482 483 484 485 },486 actions = {487 488 489 490 491 492 493 494 495 ChromeAction(Icons.Outlined.Description, "Notes", onClick = onOpenNotes)496 ChromeAction(Icons.Outlined.Settings, "settings", onClick = onSettings)497 ChromeAction(Icons.Outlined.Add, "new entry", onClick = onAddEntry)498 if (entry != null) {499 ChromeAction(500 Icons.Outlined.Delete,501 "delete",502 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,503 onClick = onDelete,504 )505 }506 ChromeAction(Icons.Outlined.Lock, "Lock vault", tint = accent, onClick = onLock)507 },508 )509}510511512@Composable513private fun ChromeAction(514 icon: androidx.compose.ui.graphics.vector.ImageVector,515 label: String,516 tint: Color = peachColors.T1,517 onClick: () -> Unit,518) {519 Icon(520 icon,521 contentDescription = label,522 tint = tint,523 modifier = Modifier524 .size(38.dp)525 .clip(CircleShape)526 .clickable(onClick = onClick)527 .padding(9.dp),528 )529}530531@Composable532private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {533 val accent = LocalPeachAccent.current534 var editing by remember(title) { mutableStateOf(false) }535 var draft by remember(title) { mutableStateOf(title) }536 if (!editing) {537 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {538 CharEnterText(539 text = title,540 style = PeachType.entryNameHero.copy(color = peachColors.T0),541 )542 }543 return544 }545 Column(Modifier.fillMaxWidth()) {546 BasicTextField(547 value = draft,548 onValueChange = { draft = it },549 singleLine = true,550 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),551 cursorBrush = SolidColor(accent),552 modifier = Modifier.fillMaxWidth().drawBehind {553 drawLine(554 color = accent,555 start = Offset(0f, size.height),556 end = Offset(size.width, size.height),557 strokeWidth = 1.dp.toPx(),558 )559 },560 )561 Spacer(Modifier.height(8.dp))562 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {563 Text(564 "✓ save", color = accent, style = PeachType.labelMono,565 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {566 onSave(draft)567 editing = false568 }.padding(vertical = 4.dp),569 )570 Text(571 "✗", color = peachColors.T2, style = PeachType.labelMono,572 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {573 draft = title574 editing = false575 }.padding(horizontal = 6.dp, vertical = 4.dp),576 )577 }578 }579}580581@Composable582private fun HomographWarning() {583 Row(584 Modifier.fillMaxWidth()585 .padding(top = 8.dp)586 .clip(RoundedCornerShape(8.dp))587 .background(peachColors.BreachBg)588 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))589 .padding(horizontal = 12.dp, vertical = 9.dp),590 verticalAlignment = Alignment.CenterVertically,591 ) {592 Text("!", color = peachColors.Breach, style = PeachType.labelMono)593 Spacer(Modifier.width(10.dp))594 Text(595 "This URL contains non-ASCII or punycode characters.",596 color = peachColors.Breach,597 style = PeachType.labelMono,598 modifier = Modifier.weight(1f),599 )600 }601}602603@Composable604private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {605 val passkey = entry.passkey ?: return606 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {607 Column(Modifier.weight(1f)) {608 Text(609 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,610 color = peachColors.T0,611 style = PeachType.body,612 )613 Text(614 listOfNotNull(615 passkey.rpId,616 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,617 ).joinToString(" · "),618 color = peachColors.T2,619 style = PeachType.labelMono,620 )621 Text(622 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",623 color = peachColors.T2,624 style = PeachType.labelMono,625 modifier = Modifier.padding(top = 3.dp),626 )627 }628 }629}630631@Composable632private fun CanaryDetailSection(633 entryId: String,634 modifiedAt: Long,635 canary: com.peachpasswords.vault.Canary?,636 isPro: Boolean,637 message: String?,638 testing: Boolean,639 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,640 onTest: (webhookUrl: String) -> Unit,641) {642 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }643 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }644 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }645 val gated = !isPro && canary == null646 FieldShell("canary alert") {647 Column(Modifier.weight(1f)) {648 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {649 Text(650 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",651 color = peachColors.T0,652 style = PeachType.body,653 modifier = Modifier.weight(1f),654 )655 if (gated) {656 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)657 }658 Text(659 if (enabled) "on" else "off",660 color = if (enabled) LocalPeachAccent.current else peachColors.T2,661 style = PeachType.labelMono,662 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {663 if (gated) {664 onSave(true, webhookUrl, label)665 } else {666 val next = !enabled667 enabled = next668 if (!next) onSave(false, webhookUrl, label)669 }670 }.padding(horizontal = 6.dp, vertical = 4.dp),671 )672 }673 Text(674 "Fires a silent webhook when this entry's password or username is copied.",675 color = peachColors.T2,676 style = PeachType.labelMono,677 modifier = Modifier.padding(top = 3.dp),678 )679 if (enabled) {680 Spacer(Modifier.height(8.dp))681 PeachField(682 label = "webhook url",683 value = webhookUrl,684 onChange = { webhookUrl = it },685 )686 Text(687 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",688 color = peachColors.T2,689 style = PeachType.labelMono,690 )691 PeachField(692 label = "label",693 value = label,694 onChange = { label = it },695 mono = false,696 )697 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {698 Text(699 "save",700 color = LocalPeachAccent.current,701 style = PeachType.labelMono,702 modifier = Modifier.clip(RoundedCornerShape(6.dp))703 .clickable { onSave(true, webhookUrl, label) }704 .padding(vertical = 4.dp),705 )706 Text(707 if (testing) "sending..." else "test ping",708 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,709 style = PeachType.labelMono,710 modifier = Modifier.clip(RoundedCornerShape(6.dp))711 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }712 .padding(vertical = 4.dp),713 )714 }715 }716 message?.let {717 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))718 }719 if (canary?.lastPinged != null || canary?.pingCount != null) {720 Text(721 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),722 color = peachColors.T2,723 style = PeachType.labelMono,724 modifier = Modifier.padding(top = 3.dp),725 )726 }727 }728 }729}730731@Composable732private fun EntryDetailSearchStrip(733 currentIndex: Int,734 totalEntries: Int,735 onOpenList: () -> Unit,736 onPrevious: () -> Unit,737 onNext: () -> Unit,738 modifier: Modifier = Modifier,739) {740 val accent = LocalPeachAccent.current741 val hasEntries = totalEntries > 0 && currentIndex >= 0742 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"743744 Column(745 modifier746 .fillMaxWidth()747 ) {748 749 750 Box(751 Modifier.fillMaxWidth().height(40.dp)752 .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),753 )754 Column(755 Modifier.fillMaxWidth()756 .background(peachColors.Bg)757 .navigationBarsPadding(),758 ) {759 Row(760 Modifier761 .fillMaxWidth()762 .padding(horizontal = 16.dp, vertical = 12.dp)763 .height(56.dp)764 .clip(RoundedCornerShape(16.dp))765 .background(peachColors.S1)766 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))767 .padding(horizontal = 12.dp),768 verticalAlignment = Alignment.CenterVertically,769 ) {770 Row(771 Modifier772 .weight(1f)773 .fillMaxHeight()774 .clickable(onClick = onOpenList)775 .pointerInput(onOpenList) {776 var opened = false777 detectVerticalDragGestures(778 onDragEnd = { opened = false },779 onDragCancel = { opened = false },780 ) { _, dragAmount ->781 if (!opened && dragAmount < -4f) {782 opened = true783 onOpenList()784 }785 }786 },787 verticalAlignment = Alignment.CenterVertically,788 ) {789 Icon(790 Icons.Rounded.Search,791 contentDescription = null,792 tint = accent,793 modifier = Modifier.size(14.dp),794 )795 Spacer(Modifier.width(8.dp))796 Text(797 "search vault...",798 color = peachColors.T2,799 style = PeachType.searchInput,800 modifier = Modifier.weight(1f),801 )802 Text(803 counter,804 color = peachColors.T2,805 style = PeachType.labelMono,806 )807 }808 Spacer(Modifier.width(8.dp))809 Icon(810 Icons.AutoMirrored.Rounded.ArrowBack,811 contentDescription = "Previous entry",812 tint = if (hasEntries) peachColors.T0 else peachColors.T2,813 modifier = Modifier814 .size(36.dp)815 .clip(RoundedCornerShape(8.dp))816 .clickable(enabled = hasEntries, onClick = onPrevious)817 .padding(8.dp),818 )819 Spacer(Modifier.width(4.dp))820 Icon(821 Icons.AutoMirrored.Rounded.ArrowForward,822 contentDescription = "Next entry",823 tint = if (hasEntries) peachColors.T0 else peachColors.T2,824 modifier = Modifier825 .size(36.dp)826 .clip(RoundedCornerShape(8.dp))827 .clickable(enabled = hasEntries, onClick = onNext)828 .padding(8.dp),829 )830 }831 }832 }833}834835836@Composable837internal fun FieldShell(838 label: String,839 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,840) {841 val hairline = peachColors.T3842 Column(843 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {844 drawRect(845 color = hairline,846 topLeft = Offset(0f, size.height - 1.dp.toPx()),847 size = Size(size.width, 1.dp.toPx()),848 )849 },850 ) {851 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))852 Spacer(Modifier.height(6.dp))853 Row(verticalAlignment = Alignment.CenterVertically, content = content)854 }855}856857858859860861internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }862863internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =864 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)865866internal fun shortCredential(credentialId: String): String {867 if (credentialId.length <= 12) return credentialId868 return credentialId.take(6) + "..." + credentialId.takeLast(4)869}870871internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {872 val count = pingCount ?: 0873 val countLabel = "$count total alert" + if (count == 1) "" else "s"874 if (lastPinged == null) return countLabel875 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)876 .format(java.util.Date(lastPinged))877 return "Last triggered: $date · $countLabel"878}879880private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {881 val raw = url882 if (!raw.isNullOrBlank()) {883 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')884 if (cleaned.isNotEmpty()) return cleaned885 }886 return ""887}