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 vm: EntryDetailViewModel = hiltViewModel(),110) {111 val entry by vm.entry.collectAsState()112 val activeEntries by vm.activeEntries.collectAsState()113 val identitySuggestions by vm.identitySuggestions.collectAsState()114 val allTags by vm.allTags.collectAsState()115 val isPro by vm.isPro.collectAsState()116 val cvcRevealed by vm.cvcRevealed.collectAsState()117 var cardRevealed by remember { mutableStateOf(false) }118 val copiedField by vm.copiedField.collectAsState()119 val canaryMessage by vm.canaryMessage.collectAsState()120 val canaryTesting by vm.canaryTesting.collectAsState()121 val context = LocalContext.current122 val accent = LocalPeachAccent.current123 var confirmingDelete by remember { mutableStateOf(false) }124 var generatorOpen by remember { mutableStateOf(false) }125 var historyExpanded by remember { mutableStateOf(false) }126127 SecureFlagEffect()128129 val e = entry130 ShellBackground {131 Box(132 Modifier.fillMaxSize().statusBarsPadding()133 134 135 136 137 .pointerInput(vm) {138 var dragAccum = 0f139 detectHorizontalDragGestures(140 onDragStart = { dragAccum = 0f },141 onDragEnd = {142 when {143 dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)144 dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)145 }146 dragAccum = 0f147 },148 onDragCancel = { dragAccum = 0f },149 ) { _, dragAmount -> dragAccum += dragAmount }150 },151 ) {152 Column(153 Modifier.fillMaxSize()154 .padding(bottom = 100.dp)155 .verticalScroll(rememberScrollState())156 .padding(horizontal = PeachSpacing.xl),157 ) {158 EntryDetailTopBar(159 entry = e,160 confirmingDelete = confirmingDelete,161 onBack = onBack,162 onLock = vm::lock,163 onDelete = {164 if (confirmingDelete) {165 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })166 } else {167 confirmingDelete = true168 }169 },170 onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },171 onSettings = { onNavigate(PeachNav.Settings) },172 onOpenNotes = { onNavigate(PeachNav.VaultNotes) },173 )174 Spacer(Modifier.height(PeachSpacing.lg))175176 if (e == null) {177 PeachEmptyOrEndState(178 message = "entry not found",179 detail = "It may have been moved or deleted.",180 modifier = Modifier.padding(top = PeachSpacing.xxl),181 )182 return@Column183 }184185 186 if (e.isBreach()) {187 Row(188 Modifier.fillMaxWidth()189 .background(peachColors.BreachBg)190 .padding(horizontal = 16.dp, vertical = 8.dp),191 verticalAlignment = Alignment.CenterVertically,192 ) {193 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)194 Spacer(Modifier.width(10.dp))195 Text(196 "Password found in a data breach — change it now",197 color = peachColors.Breach, style = PeachType.labelMono,198 modifier = Modifier.weight(1f),199 )200 Text(201 "Change password",202 color = accent, style = PeachType.labelMono,203 modifier = Modifier.clip(RoundedCornerShape(6.dp))204 .clickable { generatorOpen = true }205 .padding(horizontal = 6.dp, vertical = 4.dp),206 )207 }208 }209 if (entryHasHomographWarning(e)) {210 HomographWarning()211 }212213 214 215 216 Box(Modifier.fillMaxWidth().clipToBounds()) {217 Text(218 text = ghostWatermarkText(e.title),219 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),220 color = peachColors.T0.copy(alpha = 0.03f),221 maxLines = 1,222 softWrap = false,223 overflow = TextOverflow.Clip,224 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),225 )226 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {227 EditableTitleHero(228 title = e.title,229 onSave = { vm.updateField("title", it) },230 )231 }232 }233 Spacer(Modifier.height(PeachSpacing.lg))234235 if (e.isCard()) {236 CardDetailFields(237 entry = e,238 cardRevealed = cardRevealed,239 cvcRevealed = cvcRevealed,240 copiedField = copiedField,241 onToggleCardReveal = { cardRevealed = !cardRevealed },242 onRevealCvc = {243 val activity = context as? FragmentActivity244 if (activity != null && Biometrics.isAvailable(context)) {245 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)246 } else vm.revealCvc()247 },248 onMaskCvc = vm::maskCvc,249 onCopy = vm::copy,250 onSaveCard = vm::updateCard,251 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },252 )253 } else {254 if (!e.isNote()) {255 EditableFieldRow(256 label = "url", field = "url", value = e.url.orEmpty(),257 copied = copiedField == "url",258 onCopy = { vm.copy("url", e.url) },259 onSave = { vm.updateField("url", it) },260 placeholder = "add url",261 )262 }263 264 265 266 267 268 var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }269 var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }270 if (showUsernameField) {271 EditableFieldRow(272 label = "username", field = "username", value = e.username.orEmpty(),273 copied = copiedField == "username",274 onCopy = { vm.copy("username", e.username) },275 onSave = { vm.updateField("username", it) },276 suggestions = identitySuggestions,277 placeholder = "add username",278 )279 }280 if (showEmailField) {281 EditableFieldRow(282 label = "email", field = "email", value = e.email.orEmpty(),283 copied = copiedField == "email",284 onCopy = { vm.copy("email", e.email) },285 onSave = { vm.updateField("email", it) },286 suggestions = identitySuggestions,287 placeholder = "add email",288 )289 }290 if (!showUsernameField || !showEmailField) {291 Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {292 if (!showUsernameField) {293 Text(294 "+ add username", color = accent, style = PeachType.labelMono,295 modifier = Modifier296 .clip(RoundedCornerShape(6.dp))297 .clickable { showUsernameField = true }298 .padding(vertical = 6.dp),299 )300 }301 if (!showEmailField) {302 Text(303 "+ add email", color = accent, style = PeachType.labelMono,304 modifier = Modifier305 .clip(RoundedCornerShape(6.dp))306 .clickable { showEmailField = true }307 .padding(vertical = 6.dp),308 )309 }310 }311 }312 AdditionalUrlRows(313 urls = e.additionalUrls.orEmpty(),314 copiedField = copiedField,315 onCopy = { key, value -> vm.copy(key, value) },316 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },317 onAdd = vm::addAdditionalUrl,318 )319 if (e.hasPasskey()) {320 PasskeyDetailSection(e)321 }322 e.password?.takeIf { it.isNotBlank() }?.let { pw ->323 EditablePasswordRow(324 value = pw,325 copied = copiedField == "password",326 entryTitle = e.title,327 onCopy = { vm.copy("password", pw) },328 onSave = { vm.updateField("password", it) },329 onGenerate = { generatorOpen = true },330 )331 }332 val history = e.passwordHistory.orEmpty()333 if (history.isNotEmpty()) {334 PasswordHistoryToggle(335 count = history.size,336 expanded = historyExpanded,337 onToggle = { historyExpanded = !historyExpanded },338 )339 AnimatedVisibility(visible = historyExpanded) {340 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {341 PasswordHistoryList(342 history = history,343 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },344 onCopy = { oldPw -> vm.copy("password", oldPw) },345 )346 }347 }348 }349 if (e.hasTOTP()) {350 Spacer(Modifier.height(4.dp))351 TotpBandEditable(352 totpField = e.totp!!,353 copied = copiedField == "totp",354 onCopy = vm::copyTotp,355 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },356 )357 }358 }359 e.notes?.takeIf { it.isNotBlank() }?.let { v ->360 EditableFieldRow(361 label = "notes", field = "notes", value = v,362 copied = copiedField == "notes",363 onCopy = { vm.copy("notes", v) },364 onSave = { vm.updateField("notes", it) },365 mono = false, singleLine = false,366 )367 }368 DetailTagRow(369 tags = e.tags.orEmpty(),370 allTags = allTags,371 onAddTag = vm::addTag,372 onRemoveTag = vm::removeTag,373 )374 if (!e.isNote() && !e.isCard()) {375 376 377 378 379 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }380 if (advancedExpanded) {381 CanaryDetailSection(382 entryId = e.id,383 modifiedAt = e.modifiedAt,384 canary = e.canary,385 isPro = isPro,386 message = canaryMessage,387 testing = canaryTesting,388 onSave = vm::saveCanary,389 onTest = vm::testCanary,390 )391 } else {392 Text(393 "+ advanced",394 color = LocalPeachAccent.current,395 style = PeachType.labelMono,396 modifier = Modifier397 .clip(RoundedCornerShape(6.dp))398 .clickable { advancedExpanded = true }399 .padding(vertical = 6.dp),400 )401 }402 }403404 Spacer(Modifier.height(PeachSpacing.xxl))405 }406407 if (e != null) {408 EntryDetailSearchStrip(409 currentIndex = activeEntries.indexOfFirst { it.id == e.id },410 totalEntries = activeEntries.size,411 onOpenList = { onNavigate(PeachNav.VaultList) },412 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },413 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },414 modifier = Modifier.align(Alignment.BottomCenter),415 )416 }417418 if (generatorOpen) {419 GeneratorSheet(420 copied = copiedField == "genDraft",421 onCopy = { draft -> vm.copy("genDraft", draft) },422 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },423 onDismiss = { generatorOpen = false },424 )425 }426 }427 }428}429430@Composable431private fun EntryDetailTopBar(432 entry: com.peachpasswords.vault.VaultEntry?,433 confirmingDelete: Boolean,434 onBack: (() -> Unit)?,435 onLock: () -> Unit,436 onDelete: () -> Unit,437 onAddEntry: () -> Unit,438 onSettings: () -> Unit,439 onOpenNotes: () -> Unit,440) {441 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }442 PeachTopBar(443 title = when {444 confirmingDelete && entry != null -> "delete \"${entry.title}\"?"445 446 447 448 449 onBack == null -> "peach"450 entry == null -> "entry"451 domain.isNotEmpty() -> domain452 else -> entry.title453 },454 leading = {455 if (onBack != null) {456 Icon(457 Icons.AutoMirrored.Rounded.ArrowBack,458 contentDescription = "back",459 tint = peachColors.T1,460 modifier = Modifier461 .size(40.dp)462 .clip(CircleShape)463 .clickable(onClick = onBack)464 .padding(PeachSpacing.sm),465 )466 } else {467 PeachMark(modifier = Modifier.padding(start = PeachSpacing.sm, end = PeachSpacing.xs))468 }469 470 471 472 473 474 },475 actions = {476 477 478 479 480 481 482 483 484 if (onBack == null) {485 Icon(486 Icons.AutoMirrored.Outlined.Notes,487 contentDescription = "Notes",488 tint = peachColors.T1,489 modifier = Modifier490 .size(40.dp)491 .clip(CircleShape)492 .clickable(onClick = onOpenNotes)493 .padding(PeachSpacing.sm),494 )495 Icon(496 Icons.Rounded.Settings,497 contentDescription = "settings",498 tint = peachColors.T1,499 modifier = Modifier500 .size(40.dp)501 .clip(CircleShape)502 .clickable(onClick = onSettings)503 .padding(PeachSpacing.sm),504 )505 Icon(506 Icons.Rounded.Add,507 contentDescription = "new entry",508 tint = peachColors.T1,509 modifier = Modifier510 .size(40.dp)511 .clip(CircleShape)512 .clickable(onClick = onAddEntry)513 .padding(PeachSpacing.sm),514 )515 }516 if (entry != null) {517 Icon(518 Icons.Outlined.DeleteOutline,519 contentDescription = "delete",520 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,521 modifier = Modifier522 .size(40.dp)523 .clip(CircleShape)524 .clickable(onClick = onDelete)525 .padding(PeachSpacing.sm),526 )527 }528 if (onBack == null) {529 Icon(530 Icons.Outlined.Lock,531 contentDescription = "Lock vault",532 tint = peachColors.T1,533 modifier = Modifier534 .size(40.dp)535 .clip(CircleShape)536 .clickable(onClick = onLock)537 .padding(PeachSpacing.sm),538 )539 }540 },541 )542}543544@Composable545private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {546 val accent = LocalPeachAccent.current547 var editing by remember(title) { mutableStateOf(false) }548 var draft by remember(title) { mutableStateOf(title) }549 if (!editing) {550 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {551 CharEnterText(552 text = title,553 style = PeachType.entryNameHero.copy(color = peachColors.T0),554 )555 }556 return557 }558 Column(Modifier.fillMaxWidth()) {559 BasicTextField(560 value = draft,561 onValueChange = { draft = it },562 singleLine = true,563 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),564 cursorBrush = SolidColor(accent),565 modifier = Modifier.fillMaxWidth().drawBehind {566 drawLine(567 color = accent,568 start = Offset(0f, size.height),569 end = Offset(size.width, size.height),570 strokeWidth = 1.dp.toPx(),571 )572 },573 )574 Spacer(Modifier.height(8.dp))575 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {576 Text(577 "✓ save", color = accent, style = PeachType.labelMono,578 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {579 onSave(draft)580 editing = false581 }.padding(vertical = 4.dp),582 )583 Text(584 "✗", color = peachColors.T2, style = PeachType.labelMono,585 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {586 draft = title587 editing = false588 }.padding(horizontal = 6.dp, vertical = 4.dp),589 )590 }591 }592}593594@Composable595private fun HomographWarning() {596 Row(597 Modifier.fillMaxWidth()598 .padding(top = 8.dp)599 .clip(RoundedCornerShape(8.dp))600 .background(peachColors.BreachBg)601 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))602 .padding(horizontal = 12.dp, vertical = 9.dp),603 verticalAlignment = Alignment.CenterVertically,604 ) {605 Text("!", color = peachColors.Breach, style = PeachType.labelMono)606 Spacer(Modifier.width(10.dp))607 Text(608 "This URL contains non-ASCII or punycode characters.",609 color = peachColors.Breach,610 style = PeachType.labelMono,611 modifier = Modifier.weight(1f),612 )613 }614}615616@Composable617private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {618 val passkey = entry.passkey ?: return619 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {620 Column(Modifier.weight(1f)) {621 Text(622 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,623 color = peachColors.T0,624 style = PeachType.body,625 )626 Text(627 listOfNotNull(628 passkey.rpId,629 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,630 ).joinToString(" · "),631 color = peachColors.T2,632 style = PeachType.labelMono,633 )634 Text(635 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",636 color = peachColors.T2,637 style = PeachType.labelMono,638 modifier = Modifier.padding(top = 3.dp),639 )640 }641 }642}643644@Composable645private fun CanaryDetailSection(646 entryId: String,647 modifiedAt: Long,648 canary: com.peachpasswords.vault.Canary?,649 isPro: Boolean,650 message: String?,651 testing: Boolean,652 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,653 onTest: (webhookUrl: String) -> Unit,654) {655 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }656 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }657 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }658 val gated = !isPro && canary == null659 FieldShell("canary alert") {660 Column(Modifier.weight(1f)) {661 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {662 Text(663 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",664 color = peachColors.T0,665 style = PeachType.body,666 modifier = Modifier.weight(1f),667 )668 if (gated) {669 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)670 }671 Text(672 if (enabled) "on" else "off",673 color = if (enabled) LocalPeachAccent.current else peachColors.T2,674 style = PeachType.labelMono,675 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {676 if (gated) {677 onSave(true, webhookUrl, label)678 } else {679 val next = !enabled680 enabled = next681 if (!next) onSave(false, webhookUrl, label)682 }683 }.padding(horizontal = 6.dp, vertical = 4.dp),684 )685 }686 Text(687 "Fires a silent webhook when this entry's password or username is copied.",688 color = peachColors.T2,689 style = PeachType.labelMono,690 modifier = Modifier.padding(top = 3.dp),691 )692 if (enabled) {693 Spacer(Modifier.height(8.dp))694 PeachField(695 label = "webhook url",696 value = webhookUrl,697 onChange = { webhookUrl = it },698 )699 Text(700 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",701 color = peachColors.T2,702 style = PeachType.labelMono,703 )704 PeachField(705 label = "label",706 value = label,707 onChange = { label = it },708 mono = false,709 )710 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {711 Text(712 "save",713 color = LocalPeachAccent.current,714 style = PeachType.labelMono,715 modifier = Modifier.clip(RoundedCornerShape(6.dp))716 .clickable { onSave(true, webhookUrl, label) }717 .padding(vertical = 4.dp),718 )719 Text(720 if (testing) "sending..." else "test ping",721 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,722 style = PeachType.labelMono,723 modifier = Modifier.clip(RoundedCornerShape(6.dp))724 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }725 .padding(vertical = 4.dp),726 )727 }728 }729 message?.let {730 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))731 }732 if (canary?.lastPinged != null || canary?.pingCount != null) {733 Text(734 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),735 color = peachColors.T2,736 style = PeachType.labelMono,737 modifier = Modifier.padding(top = 3.dp),738 )739 }740 }741 }742}743744@Composable745private fun EntryDetailSearchStrip(746 currentIndex: Int,747 totalEntries: Int,748 onOpenList: () -> Unit,749 onPrevious: () -> Unit,750 onNext: () -> Unit,751 modifier: Modifier = Modifier,752) {753 val accent = LocalPeachAccent.current754 val hasEntries = totalEntries > 0 && currentIndex >= 0755 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"756757 Column(758 modifier759 .fillMaxWidth()760 .navigationBarsPadding()761 .padding(horizontal = 16.dp, vertical = 12.dp),762 ) {763 Row(764 Modifier765 .fillMaxWidth()766 .height(56.dp)767 .clip(RoundedCornerShape(16.dp))768 .background(peachColors.S1)769 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))770 .padding(horizontal = 12.dp),771 verticalAlignment = Alignment.CenterVertically,772 ) {773 Row(774 Modifier775 .weight(1f)776 .fillMaxHeight()777 .clickable(onClick = onOpenList)778 .pointerInput(onOpenList) {779 var opened = false780 detectVerticalDragGestures(781 onDragEnd = { opened = false },782 onDragCancel = { opened = false },783 ) { _, dragAmount ->784 if (!opened && dragAmount < -4f) {785 opened = true786 onOpenList()787 }788 }789 },790 verticalAlignment = Alignment.CenterVertically,791 ) {792 Icon(793 Icons.Rounded.Search,794 contentDescription = null,795 tint = accent,796 modifier = Modifier.size(14.dp),797 )798 Spacer(Modifier.width(8.dp))799 Text(800 "search vault...",801 color = peachColors.T2,802 style = PeachType.searchInput,803 modifier = Modifier.weight(1f),804 )805 Text(806 counter,807 color = peachColors.T2,808 style = PeachType.labelMono,809 )810 }811 Spacer(Modifier.width(8.dp))812 Icon(813 Icons.AutoMirrored.Rounded.ArrowBack,814 contentDescription = "Previous entry",815 tint = if (hasEntries) peachColors.T0 else peachColors.T2,816 modifier = Modifier817 .size(38.dp)818 .clip(CircleShape)819 .clickable(enabled = hasEntries, onClick = onPrevious)820 .padding(9.dp),821 )822 Icon(823 Icons.AutoMirrored.Rounded.ArrowForward,824 contentDescription = "Next entry",825 tint = if (hasEntries) peachColors.T0 else peachColors.T2,826 modifier = Modifier827 .size(38.dp)828 .clip(CircleShape)829 .clickable(enabled = hasEntries, onClick = onNext)830 .padding(9.dp),831 )832 }833 }834}835836837@Composable838internal fun FieldShell(839 label: String,840 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,841) {842 val hairline = peachColors.T3843 Column(844 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {845 drawRect(846 color = hairline,847 topLeft = Offset(0f, size.height - 1.dp.toPx()),848 size = Size(size.width, 1.dp.toPx()),849 )850 },851 ) {852 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))853 Spacer(Modifier.height(6.dp))854 Row(verticalAlignment = Alignment.CenterVertically, content = content)855 }856}857858859860861862internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }863864internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =865 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)866867internal fun shortCredential(credentialId: String): String {868 if (credentialId.length <= 12) return credentialId869 return credentialId.take(6) + "..." + credentialId.takeLast(4)870}871872internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {873 val count = pingCount ?: 0874 val countLabel = "$count total alert" + if (count == 1) "" else "s"875 if (lastPinged == null) return countLabel876 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)877 .format(java.util.Date(lastPinged))878 return "Last triggered: $date · $countLabel"879}880881private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {882 val raw = url883 if (!raw.isNullOrBlank()) {884 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')885 if (cleaned.isNotEmpty()) return cleaned886 }887 return ""888}