1package com.peachpasswords.ui.vault23import android.view.WindowManager4import androidx.compose.animation.AnimatedVisibility5import androidx.compose.animation.core.LinearEasing6import androidx.compose.animation.core.RepeatMode7import androidx.compose.animation.core.animateFloat8import androidx.compose.animation.core.animateFloatAsState9import androidx.compose.animation.core.infiniteRepeatable10import androidx.compose.animation.core.rememberInfiniteTransition11import androidx.compose.animation.core.tween12import androidx.compose.foundation.background13import androidx.compose.foundation.border14import androidx.compose.foundation.clickable15import 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.material3.Icon44import androidx.compose.material3.Text45import androidx.compose.runtime.Composable46import androidx.compose.runtime.DisposableEffect47import 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.TOTPRing80import com.peachpasswords.ui.lock.Biometrics81import com.peachpasswords.ui.theme.LocalPeachAccent82import com.peachpasswords.ui.theme.LocalReduceMotion83import com.peachpasswords.ui.theme.peachColors84import com.peachpasswords.ui.theme.PeachType85import com.peachpasswords.ui.theme.ShellBackground86import com.peachpasswords.vault.TOTPGenerator87import com.peachpasswords.vault.hasTOTP88import com.peachpasswords.vault.hasPasskey89import com.peachpasswords.vault.isBreach90import com.peachpasswords.vault.isCard91import com.peachpasswords.vault.isNote92import com.peachpasswords.vault.isPasskeyOnly9394@Composable95fun EntryDetailScreen(96 id: String,97 onNavigate: (PeachNav) -> Unit,98 onBack: () -> Unit,99 vm: EntryDetailViewModel = hiltViewModel(),100) {101 val entry by vm.entry.collectAsState()102 val activeEntries by vm.activeEntries.collectAsState()103 val identitySuggestions by vm.identitySuggestions.collectAsState()104 val allTags by vm.allTags.collectAsState()105 val isPro by vm.isPro.collectAsState()106 val cvcRevealed by vm.cvcRevealed.collectAsState()107 var cardRevealed by remember { mutableStateOf(false) }108 val copiedField by vm.copiedField.collectAsState()109 val canaryMessage by vm.canaryMessage.collectAsState()110 val canaryTesting by vm.canaryTesting.collectAsState()111 val context = LocalContext.current112 val accent = LocalPeachAccent.current113 var confirmingDelete by remember { mutableStateOf(false) }114 var generatorOpen by remember { mutableStateOf(false) }115 var historyExpanded by remember { mutableStateOf(false) }116117 118 DisposableEffect(Unit) {119 val window = (context as? FragmentActivity)?.window120 window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)121 onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }122 }123124 val e = entry125 ShellBackground {126 Box(Modifier.fillMaxSize().statusBarsPadding()) {127 Column(128 Modifier.fillMaxSize()129 .padding(bottom = 100.dp)130 .verticalScroll(rememberScrollState())131 .padding(horizontal = 24.dp),132 ) {133 134 Row(135 Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp),136 verticalAlignment = Alignment.CenterVertically,137 ) {138 Icon(139 Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back", tint = peachColors.T0,140 modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onBack).padding(8.dp),141 )142 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {143 when {144 e == null -> {}145 confirmingDelete -> Text(146 "delete “${e.title}”?",147 color = peachColors.Breach,148 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),149 )150 else -> Row(151 verticalAlignment = Alignment.CenterVertically,152 horizontalArrangement = Arrangement.spacedBy(8.dp),153 ) {154 FaviconInitials(source = e.url ?: e.title, size = 16.dp)155 val domain = remember(e.url, e.title) { e.displayDomain() }156 if (domain.isNotEmpty()) {157 Text(domain, color = peachColors.T2, style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp))158 }159 }160 }161 }162 if (e != null) {163 Icon(164 Icons.Outlined.DeleteOutline, contentDescription = "delete",165 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,166 modifier = Modifier.size(40.dp).clip(CircleShape)167 .clickable { if (confirmingDelete) vm.delete(onDone = onBack) else confirmingDelete = true }168 .padding(8.dp),169 )170 }171 }172173 if (e == null) {174 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {175 Text("entry not found", color = peachColors.T1, style = PeachType.bodyMono)176 }177 return@Column178 }179180 181 if (e.isBreach()) {182 Row(183 Modifier.fillMaxWidth()184 .background(peachColors.BreachBg)185 .padding(horizontal = 16.dp, vertical = 8.dp),186 verticalAlignment = Alignment.CenterVertically,187 ) {188 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)189 Spacer(Modifier.width(10.dp))190 Text(191 "Password found in a data breach — change it now",192 color = peachColors.Breach, style = PeachType.labelMono,193 modifier = Modifier.weight(1f),194 )195 Text(196 "Change password",197 color = accent, style = PeachType.labelMono,198 modifier = Modifier.clip(RoundedCornerShape(6.dp))199 .clickable { generatorOpen = true }200 .padding(horizontal = 6.dp, vertical = 4.dp),201 )202 }203 }204 if (entryHasHomographWarning(e)) {205 HomographWarning()206 }207208 209 210 211 Box(Modifier.fillMaxWidth().clipToBounds()) {212 Text(213 text = ghostWatermarkText(e.title),214 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),215 color = peachColors.T0.copy(alpha = 0.03f),216 maxLines = 1,217 softWrap = false,218 overflow = TextOverflow.Clip,219 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),220 )221 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {222 EditableTitleHero(223 title = e.title,224 onSave = { vm.updateField("title", it) },225 )226 }227 }228 Spacer(Modifier.height(20.dp))229230 if (e.isCard()) {231 CardDetailFields(232 entry = e,233 cardRevealed = cardRevealed,234 cvcRevealed = cvcRevealed,235 copiedField = copiedField,236 onToggleCardReveal = { cardRevealed = !cardRevealed },237 onRevealCvc = {238 val activity = context as? FragmentActivity239 if (activity != null && Biometrics.isAvailable(context)) {240 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)241 } else vm.revealCvc()242 },243 onMaskCvc = vm::maskCvc,244 onCopy = vm::copy,245 onSaveCard = vm::updateCard,246 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },247 )248 } else {249 if (!e.isNote()) {250 EditableFieldRow(251 label = "url", field = "url", value = e.url.orEmpty(),252 copied = copiedField == "url",253 onCopy = { vm.copy("url", e.url) },254 onSave = { vm.updateField("url", it) },255 placeholder = "add url",256 )257 }258 e.username?.takeIf { it.isNotBlank() }?.let { v ->259 EditableFieldRow(260 label = "username", field = "username", value = v,261 copied = copiedField == "username",262 onCopy = { vm.copy("username", v) },263 onSave = { vm.updateField("username", it) },264 suggestions = identitySuggestions,265 )266 }267 e.email?.takeIf { it.isNotBlank() }?.let { v ->268 EditableFieldRow(269 label = "email", field = "email", value = v,270 copied = copiedField == "email",271 onCopy = { vm.copy("email", v) },272 onSave = { vm.updateField("email", it) },273 suggestions = identitySuggestions,274 )275 }276 AdditionalUrlRows(277 urls = e.additionalUrls.orEmpty(),278 copiedField = copiedField,279 onCopy = { key, value -> vm.copy(key, value) },280 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },281 onAdd = vm::addAdditionalUrl,282 )283 if (e.hasPasskey()) {284 PasskeyDetailSection(e)285 }286 e.password?.takeIf { it.isNotBlank() }?.let { pw ->287 EditablePasswordRow(288 value = pw,289 copied = copiedField == "password",290 entryTitle = e.title,291 onCopy = { vm.copy("password", pw) },292 onSave = { vm.updateField("password", it) },293 onGenerate = { generatorOpen = true },294 )295 }296 val history = e.passwordHistory.orEmpty()297 if (history.isNotEmpty()) {298 PasswordHistoryToggle(299 count = history.size,300 expanded = historyExpanded,301 onToggle = { historyExpanded = !historyExpanded },302 )303 AnimatedVisibility(visible = historyExpanded) {304 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {305 PasswordHistoryList(306 history = history,307 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },308 onCopy = { oldPw -> vm.copy("password", oldPw) },309 )310 }311 }312 }313 if (e.hasTOTP()) {314 Spacer(Modifier.height(4.dp))315 TotpBandEditable(316 totpField = e.totp!!,317 copied = copiedField == "totp",318 onCopy = vm::copyTotp,319 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },320 )321 }322 }323 e.notes?.takeIf { it.isNotBlank() }?.let { v ->324 EditableFieldRow(325 label = "notes", field = "notes", value = v,326 copied = copiedField == "notes",327 onCopy = { vm.copy("notes", v) },328 onSave = { vm.updateField("notes", it) },329 mono = false, singleLine = false,330 )331 }332 DetailTagRow(333 tags = e.tags.orEmpty(),334 allTags = allTags,335 onAddTag = vm::addTag,336 onRemoveTag = vm::removeTag,337 )338 if (!e.isNote() && !e.isCard()) {339 CanaryDetailSection(340 entryId = e.id,341 modifiedAt = e.modifiedAt,342 canary = e.canary,343 isPro = isPro,344 message = canaryMessage,345 testing = canaryTesting,346 onSave = vm::saveCanary,347 onTest = vm::testCanary,348 )349 }350351 Spacer(Modifier.height(24.dp))352 }353354 if (e != null) {355 EntryDetailSearchStrip(356 currentIndex = activeEntries.indexOfFirst { it.id == e.id },357 totalEntries = activeEntries.size,358 onOpenList = { onNavigate(PeachNav.VaultList) },359 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },360 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },361 modifier = Modifier.align(Alignment.BottomCenter),362 )363 }364365 if (generatorOpen) {366 GeneratorSheet(367 copied = copiedField == "genDraft",368 onCopy = { draft -> vm.copy("genDraft", draft) },369 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },370 onDismiss = { generatorOpen = false },371 )372 }373 }374 }375}376377@Composable378private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {379 val accent = LocalPeachAccent.current380 var editing by remember(title) { mutableStateOf(false) }381 var draft by remember(title) { mutableStateOf(title) }382 if (!editing) {383 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {384 CharEnterText(385 text = title,386 style = PeachType.entryNameHero.copy(color = peachColors.T0),387 )388 }389 return390 }391 Column(Modifier.fillMaxWidth()) {392 BasicTextField(393 value = draft,394 onValueChange = { draft = it },395 singleLine = true,396 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),397 cursorBrush = SolidColor(accent),398 modifier = Modifier.fillMaxWidth().drawBehind {399 drawLine(400 color = accent,401 start = Offset(0f, size.height),402 end = Offset(size.width, size.height),403 strokeWidth = 1.dp.toPx(),404 )405 },406 )407 Spacer(Modifier.height(8.dp))408 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {409 Text(410 "✓ save", color = accent, style = PeachType.labelMono,411 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {412 onSave(draft)413 editing = false414 }.padding(vertical = 4.dp),415 )416 Text(417 "✗", color = peachColors.T2, style = PeachType.labelMono,418 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {419 draft = title420 editing = false421 }.padding(horizontal = 6.dp, vertical = 4.dp),422 )423 }424 }425}426427@Composable428private fun HomographWarning() {429 Row(430 Modifier.fillMaxWidth()431 .padding(top = 8.dp)432 .clip(RoundedCornerShape(8.dp))433 .background(peachColors.BreachBg)434 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))435 .padding(horizontal = 12.dp, vertical = 9.dp),436 verticalAlignment = Alignment.CenterVertically,437 ) {438 Text("!", color = peachColors.Breach, style = PeachType.labelMono)439 Spacer(Modifier.width(10.dp))440 Text(441 "This URL contains non-ASCII or punycode characters.",442 color = peachColors.Breach,443 style = PeachType.labelMono,444 modifier = Modifier.weight(1f),445 )446 }447}448449@Composable450private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {451 val passkey = entry.passkey ?: return452 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {453 Column(Modifier.weight(1f)) {454 Text(455 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,456 color = peachColors.T0,457 style = PeachType.body,458 )459 Text(460 listOfNotNull(461 passkey.rpId,462 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,463 ).joinToString(" · "),464 color = peachColors.T2,465 style = PeachType.labelMono,466 )467 Text(468 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",469 color = peachColors.T2,470 style = PeachType.labelMono,471 modifier = Modifier.padding(top = 3.dp),472 )473 }474 }475}476477@Composable478private fun CanaryDetailSection(479 entryId: String,480 modifiedAt: Long,481 canary: com.peachpasswords.vault.Canary?,482 isPro: Boolean,483 message: String?,484 testing: Boolean,485 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,486 onTest: (webhookUrl: String) -> Unit,487) {488 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }489 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }490 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }491 val gated = !isPro && canary == null492 FieldShell("canary alert") {493 Column(Modifier.weight(1f)) {494 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {495 Text(496 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",497 color = peachColors.T0,498 style = PeachType.body,499 modifier = Modifier.weight(1f),500 )501 if (gated) {502 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)503 }504 Text(505 if (enabled) "on" else "off",506 color = if (enabled) LocalPeachAccent.current else peachColors.T2,507 style = PeachType.labelMono,508 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {509 if (gated) {510 onSave(true, webhookUrl, label)511 } else {512 val next = !enabled513 enabled = next514 if (!next) onSave(false, webhookUrl, label)515 }516 }.padding(horizontal = 6.dp, vertical = 4.dp),517 )518 }519 Text(520 "Fires a silent webhook when this entry's password or username is copied.",521 color = peachColors.T2,522 style = PeachType.labelMono,523 modifier = Modifier.padding(top = 3.dp),524 )525 if (enabled) {526 Spacer(Modifier.height(8.dp))527 PeachField(528 label = "webhook url",529 value = webhookUrl,530 onChange = { webhookUrl = it },531 )532 Text(533 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",534 color = peachColors.T2,535 style = PeachType.labelMono,536 )537 PeachField(538 label = "label",539 value = label,540 onChange = { label = it },541 mono = false,542 )543 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {544 Text(545 "save",546 color = LocalPeachAccent.current,547 style = PeachType.labelMono,548 modifier = Modifier.clip(RoundedCornerShape(6.dp))549 .clickable { onSave(true, webhookUrl, label) }550 .padding(vertical = 4.dp),551 )552 Text(553 if (testing) "sending..." else "test ping",554 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,555 style = PeachType.labelMono,556 modifier = Modifier.clip(RoundedCornerShape(6.dp))557 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }558 .padding(vertical = 4.dp),559 )560 }561 }562 message?.let {563 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))564 }565 if (canary?.lastPinged != null || canary?.pingCount != null) {566 Text(567 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),568 color = peachColors.T2,569 style = PeachType.labelMono,570 modifier = Modifier.padding(top = 3.dp),571 )572 }573 }574 }575}576577@Composable578private fun EntryDetailSearchStrip(579 currentIndex: Int,580 totalEntries: Int,581 onOpenList: () -> Unit,582 onPrevious: () -> Unit,583 onNext: () -> Unit,584 modifier: Modifier = Modifier,585) {586 val accent = LocalPeachAccent.current587 val hasEntries = totalEntries > 0 && currentIndex >= 0588 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"589590 Column(591 modifier592 .fillMaxWidth()593 .navigationBarsPadding()594 .padding(horizontal = 16.dp, vertical = 12.dp),595 ) {596 Row(597 Modifier598 .fillMaxWidth()599 .height(56.dp)600 .clip(RoundedCornerShape(16.dp))601 .background(peachColors.S1)602 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))603 .padding(horizontal = 12.dp),604 verticalAlignment = Alignment.CenterVertically,605 ) {606 Row(607 Modifier608 .weight(1f)609 .fillMaxHeight()610 .clickable(onClick = onOpenList)611 .pointerInput(onOpenList) {612 var opened = false613 detectVerticalDragGestures(614 onDragEnd = { opened = false },615 onDragCancel = { opened = false },616 ) { _, dragAmount ->617 if (!opened && dragAmount < -4f) {618 opened = true619 onOpenList()620 }621 }622 },623 verticalAlignment = Alignment.CenterVertically,624 ) {625 Icon(626 Icons.Rounded.Search,627 contentDescription = null,628 tint = accent,629 modifier = Modifier.size(14.dp),630 )631 Spacer(Modifier.width(8.dp))632 Text(633 "search vault...",634 color = peachColors.T2,635 style = PeachType.searchInput,636 modifier = Modifier.weight(1f),637 )638 Text(639 counter,640 color = peachColors.T2,641 style = PeachType.labelMono,642 )643 }644 Spacer(Modifier.width(8.dp))645 Icon(646 Icons.AutoMirrored.Rounded.ArrowBack,647 contentDescription = "Previous entry",648 tint = if (hasEntries) peachColors.T0 else peachColors.T2,649 modifier = Modifier650 .size(38.dp)651 .clip(CircleShape)652 .clickable(enabled = hasEntries, onClick = onPrevious)653 .padding(9.dp),654 )655 Icon(656 Icons.AutoMirrored.Rounded.ArrowForward,657 contentDescription = "Next entry",658 tint = if (hasEntries) peachColors.T0 else peachColors.T2,659 modifier = Modifier660 .size(38.dp)661 .clip(CircleShape)662 .clickable(enabled = hasEntries, onClick = onNext)663 .padding(9.dp),664 )665 }666 }667}668669670@Composable671internal fun FieldShell(672 label: String,673 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,674) {675 val hairline = peachColors.T3676 Column(677 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {678 drawRect(679 color = hairline,680 topLeft = Offset(0f, size.height - 1.dp.toPx()),681 size = Size(size.width, 1.dp.toPx()),682 )683 },684 ) {685 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))686 Spacer(Modifier.height(6.dp))687 Row(verticalAlignment = Alignment.CenterVertically, content = content)688 }689}690691692693694695internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }696697internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =698 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)699700internal fun shortCredential(credentialId: String): String {701 if (credentialId.length <= 12) return credentialId702 return credentialId.take(6) + "..." + credentialId.takeLast(4)703}704705internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {706 val count = pingCount ?: 0707 val countLabel = "$count total alert" + if (count == 1) "" else "s"708 if (lastPinged == null) return countLabel709 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)710 .format(java.util.Date(lastPinged))711 return "Last triggered: $date · $countLabel"712}713714private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {715 val raw = url716 if (!raw.isNullOrBlank()) {717 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')718 if (cleaned.isNotEmpty()) return cleaned719 }720 return ""721}