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.automirrored.outlined.Notes45import androidx.compose.material.icons.outlined.DeleteOutline46import androidx.compose.material.icons.outlined.Lock47import androidx.compose.material3.Icon48import androidx.compose.material3.Text49import androidx.compose.runtime.Composable50import androidx.compose.runtime.getValue51import androidx.compose.runtime.mutableStateOf52import androidx.compose.runtime.produceState53import androidx.compose.runtime.remember54import androidx.compose.runtime.setValue55import androidx.compose.ui.Alignment56import androidx.compose.ui.Modifier57import androidx.compose.ui.draw.clip58import androidx.compose.ui.draw.clipToBounds59import androidx.compose.ui.draw.drawBehind60import androidx.compose.ui.geometry.Offset61import androidx.compose.ui.geometry.Size62import androidx.compose.ui.graphics.Brush63import androidx.compose.ui.graphics.Color64import androidx.compose.ui.graphics.SolidColor65import androidx.compose.ui.graphics.graphicsLayer66import androidx.compose.ui.platform.LocalContext67import androidx.compose.ui.input.pointer.pointerInput68import androidx.compose.ui.text.TextStyle69import androidx.compose.ui.text.font.FontStyle70import androidx.compose.ui.text.style.TextOverflow71import androidx.compose.ui.unit.dp72import androidx.compose.ui.unit.em73import androidx.compose.ui.unit.sp74import androidx.compose.runtime.collectAsState75import androidx.fragment.app.FragmentActivity76import androidx.hilt.navigation.compose.hiltViewModel77import com.peachpasswords.shared.detectHomograph78import com.peachpasswords.ui.PeachNav79import com.peachpasswords.ui.components.CharEnterText80import com.peachpasswords.ui.components.CopyButton81import com.peachpasswords.ui.components.PeachEmptyOrEndState82import com.peachpasswords.ui.components.PeachMark83import com.peachpasswords.ui.components.PeachTopBar84import com.peachpasswords.ui.components.SecureFlagEffect85import com.peachpasswords.ui.components.TOTPRing86import com.peachpasswords.ui.lock.Biometrics87import com.peachpasswords.ui.theme.LocalPeachAccent88import com.peachpasswords.ui.theme.LocalReduceMotion89import com.peachpasswords.ui.theme.PeachSpacing90import com.peachpasswords.ui.theme.peachColors91import com.peachpasswords.ui.theme.PeachType92import com.peachpasswords.ui.theme.ShellBackground93import com.peachpasswords.vault.TOTPGenerator94import com.peachpasswords.vault.hasTOTP95import com.peachpasswords.vault.hasPasskey96import com.peachpasswords.vault.isBreach97import com.peachpasswords.vault.isCard98import com.peachpasswords.vault.isNote99import com.peachpasswords.vault.isPasskeyOnly100101@Composable102fun EntryDetailScreen(103 id: String,104 onNavigate: (PeachNav) -> Unit,105 106 107 108 onBack: (() -> Unit)?,109 modifier: Modifier = Modifier,110 vm: EntryDetailViewModel = hiltViewModel(),111) {112 val entry by vm.entry.collectAsState()113 val activeEntries by vm.activeEntries.collectAsState()114 val identitySuggestions by vm.identitySuggestions.collectAsState()115 val allTags by vm.allTags.collectAsState()116 val isPro by vm.isPro.collectAsState()117 val cvcRevealed by vm.cvcRevealed.collectAsState()118 var cardRevealed by remember { mutableStateOf(false) }119 val copiedField by vm.copiedField.collectAsState()120 val canaryMessage by vm.canaryMessage.collectAsState()121 val canaryTesting by vm.canaryTesting.collectAsState()122 val context = LocalContext.current123 val accent = LocalPeachAccent.current124 var confirmingDelete by remember { mutableStateOf(false) }125 var generatorOpen by remember { mutableStateOf(false) }126 var historyExpanded by remember { mutableStateOf(false) }127128 SecureFlagEffect()129130 val e = entry131 ShellBackground(modifier) {132 Box(133 Modifier.fillMaxSize().statusBarsPadding()134 135 136 137 138 .pointerInput(vm) {139 var dragAccum = 0f140 detectHorizontalDragGestures(141 onDragStart = { dragAccum = 0f },142 onDragEnd = {143 when {144 dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)145 dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)146 }147 dragAccum = 0f148 },149 onDragCancel = { dragAccum = 0f },150 ) { _, dragAmount -> dragAccum += dragAmount }151 },152 ) {153 Column(154 Modifier.fillMaxSize()155 .padding(bottom = 100.dp)156 .verticalScroll(rememberScrollState())157 .padding(horizontal = PeachSpacing.xl),158 ) {159 EntryDetailTopBar(160 entry = e,161 confirmingDelete = confirmingDelete,162 onBack = onBack,163 onLock = vm::lock,164 onDelete = {165 if (confirmingDelete) {166 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })167 } else {168 confirmingDelete = true169 }170 },171 onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },172 onSettings = { onNavigate(PeachNav.Settings) },173 onOpenNotes = { onNavigate(PeachNav.VaultNotes) },174 )175 Spacer(Modifier.height(PeachSpacing.lg))176177 if (e == null) {178 PeachEmptyOrEndState(179 message = "entry not found",180 detail = "It may have been moved or deleted.",181 modifier = Modifier.padding(top = PeachSpacing.xxl),182 )183 return@Column184 }185186 187 if (e.isBreach()) {188 Row(189 Modifier.fillMaxWidth()190 .background(peachColors.BreachBg)191 .padding(horizontal = 16.dp, vertical = 8.dp),192 verticalAlignment = Alignment.CenterVertically,193 ) {194 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)195 Spacer(Modifier.width(10.dp))196 Text(197 "Password found in a data breach — change it now",198 color = peachColors.Breach, style = PeachType.labelMono,199 modifier = Modifier.weight(1f),200 )201 Text(202 "Change password",203 color = accent, style = PeachType.labelMono,204 modifier = Modifier.clip(RoundedCornerShape(6.dp))205 .clickable { generatorOpen = true }206 .padding(horizontal = 6.dp, vertical = 4.dp),207 )208 }209 }210 if (entryHasHomographWarning(e)) {211 HomographWarning()212 }213214 215 216 217 Box(Modifier.fillMaxWidth().clipToBounds()) {218 Text(219 text = ghostWatermarkText(e.title),220 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),221 color = peachColors.T0.copy(alpha = 0.03f),222 maxLines = 1,223 softWrap = false,224 overflow = TextOverflow.Clip,225 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),226 )227 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {228 EditableTitleHero(229 title = e.title,230 onSave = { vm.updateField("title", it) },231 )232 }233 }234 Spacer(Modifier.height(PeachSpacing.lg))235236 if (e.isCard()) {237 CardDetailFields(238 entry = e,239 cardRevealed = cardRevealed,240 cvcRevealed = cvcRevealed,241 copiedField = copiedField,242 onToggleCardReveal = { cardRevealed = !cardRevealed },243 onRevealCvc = {244 val activity = context as? FragmentActivity245 if (activity != null && Biometrics.isAvailable(context)) {246 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)247 } else vm.revealCvc()248 },249 onMaskCvc = vm::maskCvc,250 onCopy = vm::copy,251 onSaveCard = vm::updateCard,252 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },253 )254 } else {255 if (!e.isNote()) {256 EditableFieldRow(257 label = "url", field = "url", value = e.url.orEmpty(),258 copied = copiedField == "url",259 onCopy = { vm.copy("url", e.url) },260 onSave = { vm.updateField("url", it) },261 placeholder = "add url",262 )263 }264 265 266 267 268 269 var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }270 var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }271 if (showUsernameField) {272 EditableFieldRow(273 label = "username", field = "username", value = e.username.orEmpty(),274 copied = copiedField == "username",275 onCopy = { vm.copy("username", e.username) },276 onSave = { vm.updateField("username", it) },277 suggestions = identitySuggestions,278 placeholder = "add username",279 )280 }281 if (showEmailField) {282 EditableFieldRow(283 label = "email", field = "email", value = e.email.orEmpty(),284 copied = copiedField == "email",285 onCopy = { vm.copy("email", e.email) },286 onSave = { vm.updateField("email", it) },287 suggestions = identitySuggestions,288 placeholder = "add email",289 )290 }291 if (!showUsernameField || !showEmailField) {292 Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {293 if (!showUsernameField) {294 Text(295 "+ add username", color = accent, style = PeachType.labelMono,296 modifier = Modifier297 .clip(RoundedCornerShape(6.dp))298 .clickable { showUsernameField = true }299 .padding(vertical = 6.dp),300 )301 }302 if (!showEmailField) {303 Text(304 "+ add email", color = accent, style = PeachType.labelMono,305 modifier = Modifier306 .clip(RoundedCornerShape(6.dp))307 .clickable { showEmailField = true }308 .padding(vertical = 6.dp),309 )310 }311 }312 }313 AdditionalUrlRows(314 urls = e.additionalUrls.orEmpty(),315 copiedField = copiedField,316 onCopy = { key, value -> vm.copy(key, value) },317 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },318 onAdd = vm::addAdditionalUrl,319 )320 if (e.hasPasskey()) {321 PasskeyDetailSection(e)322 }323 e.password?.takeIf { it.isNotBlank() }?.let { pw ->324 EditablePasswordRow(325 value = pw,326 copied = copiedField == "password",327 entryTitle = e.title,328 onCopy = { vm.copy("password", pw) },329 onSave = { vm.updateField("password", it) },330 onGenerate = { generatorOpen = true },331 )332 }333 val history = e.passwordHistory.orEmpty()334 if (history.isNotEmpty()) {335 PasswordHistoryToggle(336 count = history.size,337 expanded = historyExpanded,338 onToggle = { historyExpanded = !historyExpanded },339 )340 AnimatedVisibility(visible = historyExpanded) {341 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {342 PasswordHistoryList(343 history = history,344 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },345 onCopy = { oldPw -> vm.copy("password", oldPw) },346 )347 }348 }349 }350 if (e.hasTOTP()) {351 Spacer(Modifier.height(4.dp))352 TotpBandEditable(353 totpField = e.totp!!,354 copied = copiedField == "totp",355 onCopy = vm::copyTotp,356 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },357 )358 }359 }360 e.notes?.takeIf { it.isNotBlank() }?.let { v ->361 EditableFieldRow(362 label = "notes", field = "notes", value = v,363 copied = copiedField == "notes",364 onCopy = { vm.copy("notes", v) },365 onSave = { vm.updateField("notes", it) },366 mono = false, singleLine = false,367 )368 }369 DetailTagRow(370 tags = e.tags.orEmpty(),371 allTags = allTags,372 onAddTag = vm::addTag,373 onRemoveTag = vm::removeTag,374 )375 if (!e.isNote() && !e.isCard()) {376 377 378 379 380 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }381 if (advancedExpanded) {382 CanaryDetailSection(383 entryId = e.id,384 modifiedAt = e.modifiedAt,385 canary = e.canary,386 isPro = isPro,387 message = canaryMessage,388 testing = canaryTesting,389 onSave = vm::saveCanary,390 onTest = vm::testCanary,391 )392 } else {393 Text(394 "+ advanced",395 color = LocalPeachAccent.current,396 style = PeachType.labelMono,397 modifier = Modifier398 .clip(RoundedCornerShape(6.dp))399 .clickable { advancedExpanded = true }400 .padding(vertical = 6.dp),401 )402 }403 }404405 Spacer(Modifier.height(PeachSpacing.xxl))406 }407408 if (e != null) {409 EntryDetailSearchStrip(410 currentIndex = activeEntries.indexOfFirst { it.id == e.id },411 totalEntries = activeEntries.size,412 onOpenList = { onNavigate(PeachNav.VaultList) },413 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },414 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },415 modifier = Modifier.align(Alignment.BottomCenter),416 )417 }418419 if (generatorOpen) {420 GeneratorSheet(421 copied = copiedField == "genDraft",422 onCopy = { draft -> vm.copy("genDraft", draft) },423 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },424 onDismiss = { generatorOpen = false },425 )426 }427 }428 }429}430431@Composable432private fun EntryDetailTopBar(433 entry: com.peachpasswords.vault.VaultEntry?,434 confirmingDelete: Boolean,435 onBack: (() -> Unit)?,436 onLock: () -> Unit,437 onDelete: () -> Unit,438 onAddEntry: () -> Unit,439 onSettings: () -> Unit,440 onOpenNotes: () -> Unit,441) {442 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }443 PeachTopBar(444 title = when {445 confirmingDelete && entry != null -> "delete \"${entry.title}\"?"446 447 448 449 450 onBack == null -> "peach"451 entry == null -> "entry"452 domain.isNotEmpty() -> domain453 else -> entry.title454 },455 leading = {456 if (onBack != null) {457 Icon(458 Icons.AutoMirrored.Rounded.ArrowBack,459 contentDescription = "back",460 tint = peachColors.T1,461 modifier = Modifier462 .size(40.dp)463 .clip(CircleShape)464 .clickable(onClick = onBack)465 .padding(PeachSpacing.sm),466 )467 } else {468 PeachMark(modifier = Modifier.padding(start = PeachSpacing.sm, end = PeachSpacing.xs))469 }470 471 472 473 474 475 },476 actions = {477 478 479 480 481 482 483 484 485 if (onBack == null) {486 Icon(487 Icons.AutoMirrored.Outlined.Notes,488 contentDescription = "Notes",489 tint = peachColors.T1,490 modifier = Modifier491 .size(40.dp)492 .clip(CircleShape)493 .clickable(onClick = onOpenNotes)494 .padding(PeachSpacing.sm),495 )496 Icon(497 Icons.Rounded.Settings,498 contentDescription = "settings",499 tint = peachColors.T1,500 modifier = Modifier501 .size(40.dp)502 .clip(CircleShape)503 .clickable(onClick = onSettings)504 .padding(PeachSpacing.sm),505 )506 Icon(507 Icons.Rounded.Add,508 contentDescription = "new entry",509 tint = peachColors.T1,510 modifier = Modifier511 .size(40.dp)512 .clip(CircleShape)513 .clickable(onClick = onAddEntry)514 .padding(PeachSpacing.sm),515 )516 }517 if (entry != null) {518 Icon(519 Icons.Outlined.DeleteOutline,520 contentDescription = "delete",521 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,522 modifier = Modifier523 .size(40.dp)524 .clip(CircleShape)525 .clickable(onClick = onDelete)526 .padding(PeachSpacing.sm),527 )528 }529 if (onBack == null) {530 Icon(531 Icons.Outlined.Lock,532 contentDescription = "Lock vault",533 tint = peachColors.T1,534 modifier = Modifier535 .size(40.dp)536 .clip(CircleShape)537 .clickable(onClick = onLock)538 .padding(PeachSpacing.sm),539 )540 }541 },542 )543}544545@Composable546private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {547 val accent = LocalPeachAccent.current548 var editing by remember(title) { mutableStateOf(false) }549 var draft by remember(title) { mutableStateOf(title) }550 if (!editing) {551 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {552 CharEnterText(553 text = title,554 style = PeachType.entryNameHero.copy(color = peachColors.T0),555 )556 }557 return558 }559 Column(Modifier.fillMaxWidth()) {560 BasicTextField(561 value = draft,562 onValueChange = { draft = it },563 singleLine = true,564 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),565 cursorBrush = SolidColor(accent),566 modifier = Modifier.fillMaxWidth().drawBehind {567 drawLine(568 color = accent,569 start = Offset(0f, size.height),570 end = Offset(size.width, size.height),571 strokeWidth = 1.dp.toPx(),572 )573 },574 )575 Spacer(Modifier.height(8.dp))576 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {577 Text(578 "✓ save", color = accent, style = PeachType.labelMono,579 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {580 onSave(draft)581 editing = false582 }.padding(vertical = 4.dp),583 )584 Text(585 "✗", color = peachColors.T2, style = PeachType.labelMono,586 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {587 draft = title588 editing = false589 }.padding(horizontal = 6.dp, vertical = 4.dp),590 )591 }592 }593}594595@Composable596private fun HomographWarning() {597 Row(598 Modifier.fillMaxWidth()599 .padding(top = 8.dp)600 .clip(RoundedCornerShape(8.dp))601 .background(peachColors.BreachBg)602 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))603 .padding(horizontal = 12.dp, vertical = 9.dp),604 verticalAlignment = Alignment.CenterVertically,605 ) {606 Text("!", color = peachColors.Breach, style = PeachType.labelMono)607 Spacer(Modifier.width(10.dp))608 Text(609 "This URL contains non-ASCII or punycode characters.",610 color = peachColors.Breach,611 style = PeachType.labelMono,612 modifier = Modifier.weight(1f),613 )614 }615}616617@Composable618private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {619 val passkey = entry.passkey ?: return620 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {621 Column(Modifier.weight(1f)) {622 Text(623 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,624 color = peachColors.T0,625 style = PeachType.body,626 )627 Text(628 listOfNotNull(629 passkey.rpId,630 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,631 ).joinToString(" · "),632 color = peachColors.T2,633 style = PeachType.labelMono,634 )635 Text(636 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",637 color = peachColors.T2,638 style = PeachType.labelMono,639 modifier = Modifier.padding(top = 3.dp),640 )641 }642 }643}644645@Composable646private fun CanaryDetailSection(647 entryId: String,648 modifiedAt: Long,649 canary: com.peachpasswords.vault.Canary?,650 isPro: Boolean,651 message: String?,652 testing: Boolean,653 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,654 onTest: (webhookUrl: String) -> Unit,655) {656 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }657 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }658 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }659 val gated = !isPro && canary == null660 FieldShell("canary alert") {661 Column(Modifier.weight(1f)) {662 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {663 Text(664 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",665 color = peachColors.T0,666 style = PeachType.body,667 modifier = Modifier.weight(1f),668 )669 if (gated) {670 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)671 }672 Text(673 if (enabled) "on" else "off",674 color = if (enabled) LocalPeachAccent.current else peachColors.T2,675 style = PeachType.labelMono,676 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {677 if (gated) {678 onSave(true, webhookUrl, label)679 } else {680 val next = !enabled681 enabled = next682 if (!next) onSave(false, webhookUrl, label)683 }684 }.padding(horizontal = 6.dp, vertical = 4.dp),685 )686 }687 Text(688 "Fires a silent webhook when this entry's password or username is copied.",689 color = peachColors.T2,690 style = PeachType.labelMono,691 modifier = Modifier.padding(top = 3.dp),692 )693 if (enabled) {694 Spacer(Modifier.height(8.dp))695 PeachField(696 label = "webhook url",697 value = webhookUrl,698 onChange = { webhookUrl = it },699 )700 Text(701 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",702 color = peachColors.T2,703 style = PeachType.labelMono,704 )705 PeachField(706 label = "label",707 value = label,708 onChange = { label = it },709 mono = false,710 )711 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {712 Text(713 "save",714 color = LocalPeachAccent.current,715 style = PeachType.labelMono,716 modifier = Modifier.clip(RoundedCornerShape(6.dp))717 .clickable { onSave(true, webhookUrl, label) }718 .padding(vertical = 4.dp),719 )720 Text(721 if (testing) "sending..." else "test ping",722 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,723 style = PeachType.labelMono,724 modifier = Modifier.clip(RoundedCornerShape(6.dp))725 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }726 .padding(vertical = 4.dp),727 )728 }729 }730 message?.let {731 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))732 }733 if (canary?.lastPinged != null || canary?.pingCount != null) {734 Text(735 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),736 color = peachColors.T2,737 style = PeachType.labelMono,738 modifier = Modifier.padding(top = 3.dp),739 )740 }741 }742 }743}744745@Composable746private fun EntryDetailSearchStrip(747 currentIndex: Int,748 totalEntries: Int,749 onOpenList: () -> Unit,750 onPrevious: () -> Unit,751 onNext: () -> Unit,752 modifier: Modifier = Modifier,753) {754 val accent = LocalPeachAccent.current755 val hasEntries = totalEntries > 0 && currentIndex >= 0756 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"757758 Column(759 modifier760 .fillMaxWidth()761 .navigationBarsPadding()762 .padding(horizontal = 16.dp, vertical = 12.dp),763 ) {764 Row(765 Modifier766 .fillMaxWidth()767 .height(56.dp)768 .clip(RoundedCornerShape(16.dp))769 .background(peachColors.S1)770 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))771 .padding(horizontal = 12.dp),772 verticalAlignment = Alignment.CenterVertically,773 ) {774 Row(775 Modifier776 .weight(1f)777 .fillMaxHeight()778 .clickable(onClick = onOpenList)779 .pointerInput(onOpenList) {780 var opened = false781 detectVerticalDragGestures(782 onDragEnd = { opened = false },783 onDragCancel = { opened = false },784 ) { _, dragAmount ->785 if (!opened && dragAmount < -4f) {786 opened = true787 onOpenList()788 }789 }790 },791 verticalAlignment = Alignment.CenterVertically,792 ) {793 Icon(794 Icons.Rounded.Search,795 contentDescription = null,796 tint = accent,797 modifier = Modifier.size(14.dp),798 )799 Spacer(Modifier.width(8.dp))800 Text(801 "search vault...",802 color = peachColors.T2,803 style = PeachType.searchInput,804 modifier = Modifier.weight(1f),805 )806 Text(807 counter,808 color = peachColors.T2,809 style = PeachType.labelMono,810 )811 }812 Spacer(Modifier.width(8.dp))813 Icon(814 Icons.AutoMirrored.Rounded.ArrowBack,815 contentDescription = "Previous entry",816 tint = if (hasEntries) peachColors.T0 else peachColors.T2,817 modifier = Modifier818 .size(38.dp)819 .clip(CircleShape)820 .clickable(enabled = hasEntries, onClick = onPrevious)821 .padding(9.dp),822 )823 Icon(824 Icons.AutoMirrored.Rounded.ArrowForward,825 contentDescription = "Next entry",826 tint = if (hasEntries) peachColors.T0 else peachColors.T2,827 modifier = Modifier828 .size(38.dp)829 .clip(CircleShape)830 .clickable(enabled = hasEntries, onClick = onNext)831 .padding(9.dp),832 )833 }834 }835}836837838@Composable839internal fun FieldShell(840 label: String,841 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,842) {843 val hairline = peachColors.T3844 Column(845 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {846 drawRect(847 color = hairline,848 topLeft = Offset(0f, size.height - 1.dp.toPx()),849 size = Size(size.width, 1.dp.toPx()),850 )851 },852 ) {853 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))854 Spacer(Modifier.height(6.dp))855 Row(verticalAlignment = Alignment.CenterVertically, content = content)856 }857}858859860861862863internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }864865internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =866 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)867868internal fun shortCredential(credentialId: String): String {869 if (credentialId.length <= 12) return credentialId870 return credentialId.take(6) + "..." + credentialId.takeLast(4)871}872873internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {874 val count = pingCount ?: 0875 val countLabel = "$count total alert" + if (count == 1) "" else "s"876 if (lastPinged == null) return countLabel877 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)878 .format(java.util.Date(lastPinged))879 return "Last triggered: $date · $countLabel"880}881882private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {883 val raw = url884 if (!raw.isNullOrBlank()) {885 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')886 if (cleaned.isNotEmpty()) return cleaned887 }888 return ""889}