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.PeachEmptyOrEndState80import com.peachpasswords.ui.components.PeachTopBar81import com.peachpasswords.ui.components.TOTPRing82import com.peachpasswords.ui.lock.Biometrics83import com.peachpasswords.ui.theme.LocalPeachAccent84import com.peachpasswords.ui.theme.LocalReduceMotion85import com.peachpasswords.ui.theme.PeachSpacing86import com.peachpasswords.ui.theme.peachColors87import com.peachpasswords.ui.theme.PeachType88import com.peachpasswords.ui.theme.ShellBackground89import com.peachpasswords.vault.TOTPGenerator90import com.peachpasswords.vault.hasTOTP91import com.peachpasswords.vault.hasPasskey92import com.peachpasswords.vault.isBreach93import com.peachpasswords.vault.isCard94import com.peachpasswords.vault.isNote95import com.peachpasswords.vault.isPasskeyOnly9697@Composable98fun EntryDetailScreen(99 id: String,100 onNavigate: (PeachNav) -> Unit,101 onBack: () -> Unit,102 vm: EntryDetailViewModel = hiltViewModel(),103) {104 val entry by vm.entry.collectAsState()105 val activeEntries by vm.activeEntries.collectAsState()106 val identitySuggestions by vm.identitySuggestions.collectAsState()107 val allTags by vm.allTags.collectAsState()108 val isPro by vm.isPro.collectAsState()109 val cvcRevealed by vm.cvcRevealed.collectAsState()110 var cardRevealed by remember { mutableStateOf(false) }111 val copiedField by vm.copiedField.collectAsState()112 val canaryMessage by vm.canaryMessage.collectAsState()113 val canaryTesting by vm.canaryTesting.collectAsState()114 val context = LocalContext.current115 val accent = LocalPeachAccent.current116 var confirmingDelete by remember { mutableStateOf(false) }117 var generatorOpen by remember { mutableStateOf(false) }118 var historyExpanded by remember { mutableStateOf(false) }119120 121 DisposableEffect(Unit) {122 val window = (context as? FragmentActivity)?.window123 window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)124 onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }125 }126127 val e = entry128 ShellBackground {129 Box(Modifier.fillMaxSize().statusBarsPadding()) {130 Column(131 Modifier.fillMaxSize()132 .padding(bottom = 100.dp)133 .verticalScroll(rememberScrollState())134 .padding(horizontal = PeachSpacing.xl),135 ) {136 EntryDetailTopBar(137 entry = e,138 confirmingDelete = confirmingDelete,139 onBack = onBack,140 onDelete = {141 if (confirmingDelete) vm.delete(onDone = onBack) else confirmingDelete = true142 },143 )144 Spacer(Modifier.height(PeachSpacing.lg))145146 if (e == null) {147 PeachEmptyOrEndState(148 message = "entry not found",149 detail = "It may have been moved or deleted.",150 modifier = Modifier.padding(top = PeachSpacing.xxl),151 )152 return@Column153 }154155 156 if (e.isBreach()) {157 Row(158 Modifier.fillMaxWidth()159 .background(peachColors.BreachBg)160 .padding(horizontal = 16.dp, vertical = 8.dp),161 verticalAlignment = Alignment.CenterVertically,162 ) {163 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)164 Spacer(Modifier.width(10.dp))165 Text(166 "Password found in a data breach — change it now",167 color = peachColors.Breach, style = PeachType.labelMono,168 modifier = Modifier.weight(1f),169 )170 Text(171 "Change password",172 color = accent, style = PeachType.labelMono,173 modifier = Modifier.clip(RoundedCornerShape(6.dp))174 .clickable { generatorOpen = true }175 .padding(horizontal = 6.dp, vertical = 4.dp),176 )177 }178 }179 if (entryHasHomographWarning(e)) {180 HomographWarning()181 }182183 184 185 186 Box(Modifier.fillMaxWidth().clipToBounds()) {187 Text(188 text = ghostWatermarkText(e.title),189 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),190 color = peachColors.T0.copy(alpha = 0.03f),191 maxLines = 1,192 softWrap = false,193 overflow = TextOverflow.Clip,194 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),195 )196 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {197 EditableTitleHero(198 title = e.title,199 onSave = { vm.updateField("title", it) },200 )201 }202 }203 Spacer(Modifier.height(PeachSpacing.lg))204205 if (e.isCard()) {206 CardDetailFields(207 entry = e,208 cardRevealed = cardRevealed,209 cvcRevealed = cvcRevealed,210 copiedField = copiedField,211 onToggleCardReveal = { cardRevealed = !cardRevealed },212 onRevealCvc = {213 val activity = context as? FragmentActivity214 if (activity != null && Biometrics.isAvailable(context)) {215 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)216 } else vm.revealCvc()217 },218 onMaskCvc = vm::maskCvc,219 onCopy = vm::copy,220 onSaveCard = vm::updateCard,221 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },222 )223 } else {224 if (!e.isNote()) {225 EditableFieldRow(226 label = "url", field = "url", value = e.url.orEmpty(),227 copied = copiedField == "url",228 onCopy = { vm.copy("url", e.url) },229 onSave = { vm.updateField("url", it) },230 placeholder = "add url",231 )232 }233 e.username?.takeIf { it.isNotBlank() }?.let { v ->234 EditableFieldRow(235 label = "username", field = "username", value = v,236 copied = copiedField == "username",237 onCopy = { vm.copy("username", v) },238 onSave = { vm.updateField("username", it) },239 suggestions = identitySuggestions,240 )241 }242 e.email?.takeIf { it.isNotBlank() }?.let { v ->243 EditableFieldRow(244 label = "email", field = "email", value = v,245 copied = copiedField == "email",246 onCopy = { vm.copy("email", v) },247 onSave = { vm.updateField("email", it) },248 suggestions = identitySuggestions,249 )250 }251 AdditionalUrlRows(252 urls = e.additionalUrls.orEmpty(),253 copiedField = copiedField,254 onCopy = { key, value -> vm.copy(key, value) },255 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },256 onAdd = vm::addAdditionalUrl,257 )258 if (e.hasPasskey()) {259 PasskeyDetailSection(e)260 }261 e.password?.takeIf { it.isNotBlank() }?.let { pw ->262 EditablePasswordRow(263 value = pw,264 copied = copiedField == "password",265 entryTitle = e.title,266 onCopy = { vm.copy("password", pw) },267 onSave = { vm.updateField("password", it) },268 onGenerate = { generatorOpen = true },269 )270 }271 val history = e.passwordHistory.orEmpty()272 if (history.isNotEmpty()) {273 PasswordHistoryToggle(274 count = history.size,275 expanded = historyExpanded,276 onToggle = { historyExpanded = !historyExpanded },277 )278 AnimatedVisibility(visible = historyExpanded) {279 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {280 PasswordHistoryList(281 history = history,282 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },283 onCopy = { oldPw -> vm.copy("password", oldPw) },284 )285 }286 }287 }288 if (e.hasTOTP()) {289 Spacer(Modifier.height(4.dp))290 TotpBandEditable(291 totpField = e.totp!!,292 copied = copiedField == "totp",293 onCopy = vm::copyTotp,294 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },295 )296 }297 }298 e.notes?.takeIf { it.isNotBlank() }?.let { v ->299 EditableFieldRow(300 label = "notes", field = "notes", value = v,301 copied = copiedField == "notes",302 onCopy = { vm.copy("notes", v) },303 onSave = { vm.updateField("notes", it) },304 mono = false, singleLine = false,305 )306 }307 DetailTagRow(308 tags = e.tags.orEmpty(),309 allTags = allTags,310 onAddTag = vm::addTag,311 onRemoveTag = vm::removeTag,312 )313 if (!e.isNote() && !e.isCard()) {314 CanaryDetailSection(315 entryId = e.id,316 modifiedAt = e.modifiedAt,317 canary = e.canary,318 isPro = isPro,319 message = canaryMessage,320 testing = canaryTesting,321 onSave = vm::saveCanary,322 onTest = vm::testCanary,323 )324 }325326 PeachEmptyOrEndState(327 message = "end of entry",328 detail = "All saved fields for this item are above.",329 modifier = Modifier.padding(top = PeachSpacing.md, bottom = PeachSpacing.lg),330 )331 }332333 if (e != null) {334 EntryDetailSearchStrip(335 currentIndex = activeEntries.indexOfFirst { it.id == e.id },336 totalEntries = activeEntries.size,337 onOpenList = { onNavigate(PeachNav.VaultList) },338 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },339 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },340 modifier = Modifier.align(Alignment.BottomCenter),341 )342 }343344 if (generatorOpen) {345 GeneratorSheet(346 copied = copiedField == "genDraft",347 onCopy = { draft -> vm.copy("genDraft", draft) },348 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },349 onDismiss = { generatorOpen = false },350 )351 }352 }353 }354}355356@Composable357private fun EntryDetailTopBar(358 entry: com.peachpasswords.vault.VaultEntry?,359 confirmingDelete: Boolean,360 onBack: () -> Unit,361 onDelete: () -> Unit,362) {363 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }364 PeachTopBar(365 title = when {366 entry == null -> "entry"367 confirmingDelete -> "delete \"${entry.title}\"?"368 domain.isNotEmpty() -> domain369 else -> entry.title370 },371 leading = {372 Icon(373 Icons.AutoMirrored.Rounded.ArrowBack,374 contentDescription = "back",375 tint = peachColors.T0,376 modifier = Modifier377 .size(40.dp)378 .clip(CircleShape)379 .clickable(onClick = onBack)380 .padding(PeachSpacing.sm),381 )382 if (entry != null && !confirmingDelete) {383 FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)384 }385 },386 actions = {387 if (entry != null) {388 Icon(389 Icons.Outlined.DeleteOutline,390 contentDescription = "delete",391 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,392 modifier = Modifier393 .size(40.dp)394 .clip(CircleShape)395 .clickable(onClick = onDelete)396 .padding(PeachSpacing.sm),397 )398 }399 },400 )401}402403@Composable404private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {405 val accent = LocalPeachAccent.current406 var editing by remember(title) { mutableStateOf(false) }407 var draft by remember(title) { mutableStateOf(title) }408 if (!editing) {409 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {410 CharEnterText(411 text = title,412 style = PeachType.entryNameHero.copy(color = peachColors.T0),413 )414 }415 return416 }417 Column(Modifier.fillMaxWidth()) {418 BasicTextField(419 value = draft,420 onValueChange = { draft = it },421 singleLine = true,422 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),423 cursorBrush = SolidColor(accent),424 modifier = Modifier.fillMaxWidth().drawBehind {425 drawLine(426 color = accent,427 start = Offset(0f, size.height),428 end = Offset(size.width, size.height),429 strokeWidth = 1.dp.toPx(),430 )431 },432 )433 Spacer(Modifier.height(8.dp))434 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {435 Text(436 "✓ save", color = accent, style = PeachType.labelMono,437 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {438 onSave(draft)439 editing = false440 }.padding(vertical = 4.dp),441 )442 Text(443 "✗", color = peachColors.T2, style = PeachType.labelMono,444 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {445 draft = title446 editing = false447 }.padding(horizontal = 6.dp, vertical = 4.dp),448 )449 }450 }451}452453@Composable454private fun HomographWarning() {455 Row(456 Modifier.fillMaxWidth()457 .padding(top = 8.dp)458 .clip(RoundedCornerShape(8.dp))459 .background(peachColors.BreachBg)460 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))461 .padding(horizontal = 12.dp, vertical = 9.dp),462 verticalAlignment = Alignment.CenterVertically,463 ) {464 Text("!", color = peachColors.Breach, style = PeachType.labelMono)465 Spacer(Modifier.width(10.dp))466 Text(467 "This URL contains non-ASCII or punycode characters.",468 color = peachColors.Breach,469 style = PeachType.labelMono,470 modifier = Modifier.weight(1f),471 )472 }473}474475@Composable476private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {477 val passkey = entry.passkey ?: return478 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {479 Column(Modifier.weight(1f)) {480 Text(481 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,482 color = peachColors.T0,483 style = PeachType.body,484 )485 Text(486 listOfNotNull(487 passkey.rpId,488 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,489 ).joinToString(" · "),490 color = peachColors.T2,491 style = PeachType.labelMono,492 )493 Text(494 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",495 color = peachColors.T2,496 style = PeachType.labelMono,497 modifier = Modifier.padding(top = 3.dp),498 )499 }500 }501}502503@Composable504private fun CanaryDetailSection(505 entryId: String,506 modifiedAt: Long,507 canary: com.peachpasswords.vault.Canary?,508 isPro: Boolean,509 message: String?,510 testing: Boolean,511 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,512 onTest: (webhookUrl: String) -> Unit,513) {514 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }515 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }516 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }517 val gated = !isPro && canary == null518 FieldShell("canary alert") {519 Column(Modifier.weight(1f)) {520 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {521 Text(522 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",523 color = peachColors.T0,524 style = PeachType.body,525 modifier = Modifier.weight(1f),526 )527 if (gated) {528 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)529 }530 Text(531 if (enabled) "on" else "off",532 color = if (enabled) LocalPeachAccent.current else peachColors.T2,533 style = PeachType.labelMono,534 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {535 if (gated) {536 onSave(true, webhookUrl, label)537 } else {538 val next = !enabled539 enabled = next540 if (!next) onSave(false, webhookUrl, label)541 }542 }.padding(horizontal = 6.dp, vertical = 4.dp),543 )544 }545 Text(546 "Fires a silent webhook when this entry's password or username is copied.",547 color = peachColors.T2,548 style = PeachType.labelMono,549 modifier = Modifier.padding(top = 3.dp),550 )551 if (enabled) {552 Spacer(Modifier.height(8.dp))553 PeachField(554 label = "webhook url",555 value = webhookUrl,556 onChange = { webhookUrl = it },557 )558 Text(559 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",560 color = peachColors.T2,561 style = PeachType.labelMono,562 )563 PeachField(564 label = "label",565 value = label,566 onChange = { label = it },567 mono = false,568 )569 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {570 Text(571 "save",572 color = LocalPeachAccent.current,573 style = PeachType.labelMono,574 modifier = Modifier.clip(RoundedCornerShape(6.dp))575 .clickable { onSave(true, webhookUrl, label) }576 .padding(vertical = 4.dp),577 )578 Text(579 if (testing) "sending..." else "test ping",580 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,581 style = PeachType.labelMono,582 modifier = Modifier.clip(RoundedCornerShape(6.dp))583 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }584 .padding(vertical = 4.dp),585 )586 }587 }588 message?.let {589 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))590 }591 if (canary?.lastPinged != null || canary?.pingCount != null) {592 Text(593 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),594 color = peachColors.T2,595 style = PeachType.labelMono,596 modifier = Modifier.padding(top = 3.dp),597 )598 }599 }600 }601}602603@Composable604private fun EntryDetailSearchStrip(605 currentIndex: Int,606 totalEntries: Int,607 onOpenList: () -> Unit,608 onPrevious: () -> Unit,609 onNext: () -> Unit,610 modifier: Modifier = Modifier,611) {612 val accent = LocalPeachAccent.current613 val hasEntries = totalEntries > 0 && currentIndex >= 0614 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"615616 Column(617 modifier618 .fillMaxWidth()619 .navigationBarsPadding()620 .padding(horizontal = 16.dp, vertical = 12.dp),621 ) {622 Row(623 Modifier624 .fillMaxWidth()625 .height(56.dp)626 .clip(RoundedCornerShape(16.dp))627 .background(peachColors.S1)628 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))629 .padding(horizontal = 12.dp),630 verticalAlignment = Alignment.CenterVertically,631 ) {632 Row(633 Modifier634 .weight(1f)635 .fillMaxHeight()636 .clickable(onClick = onOpenList)637 .pointerInput(onOpenList) {638 var opened = false639 detectVerticalDragGestures(640 onDragEnd = { opened = false },641 onDragCancel = { opened = false },642 ) { _, dragAmount ->643 if (!opened && dragAmount < -4f) {644 opened = true645 onOpenList()646 }647 }648 },649 verticalAlignment = Alignment.CenterVertically,650 ) {651 Icon(652 Icons.Rounded.Search,653 contentDescription = null,654 tint = accent,655 modifier = Modifier.size(14.dp),656 )657 Spacer(Modifier.width(8.dp))658 Text(659 "search vault...",660 color = peachColors.T2,661 style = PeachType.searchInput,662 modifier = Modifier.weight(1f),663 )664 Text(665 counter,666 color = peachColors.T2,667 style = PeachType.labelMono,668 )669 }670 Spacer(Modifier.width(8.dp))671 Icon(672 Icons.AutoMirrored.Rounded.ArrowBack,673 contentDescription = "Previous entry",674 tint = if (hasEntries) peachColors.T0 else peachColors.T2,675 modifier = Modifier676 .size(38.dp)677 .clip(CircleShape)678 .clickable(enabled = hasEntries, onClick = onPrevious)679 .padding(9.dp),680 )681 Icon(682 Icons.AutoMirrored.Rounded.ArrowForward,683 contentDescription = "Next entry",684 tint = if (hasEntries) peachColors.T0 else peachColors.T2,685 modifier = Modifier686 .size(38.dp)687 .clip(CircleShape)688 .clickable(enabled = hasEntries, onClick = onNext)689 .padding(9.dp),690 )691 }692 }693}694695696@Composable697internal fun FieldShell(698 label: String,699 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,700) {701 val hairline = peachColors.T3702 Column(703 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {704 drawRect(705 color = hairline,706 topLeft = Offset(0f, size.height - 1.dp.toPx()),707 size = Size(size.width, 1.dp.toPx()),708 )709 },710 ) {711 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))712 Spacer(Modifier.height(6.dp))713 Row(verticalAlignment = Alignment.CenterVertically, content = content)714 }715}716717718719720721internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }722723internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =724 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)725726internal fun shortCredential(credentialId: String): String {727 if (credentialId.length <= 12) return credentialId728 return credentialId.take(6) + "..." + credentialId.takeLast(4)729}730731internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {732 val count = pingCount ?: 0733 val countLabel = "$count total alert" + if (count == 1) "" else "s"734 if (lastPinged == null) return countLabel735 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)736 .format(java.util.Date(lastPinged))737 return "Last triggered: $date · $countLabel"738}739740private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {741 val raw = url742 if (!raw.isNullOrBlank()) {743 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')744 if (cleaned.isNotEmpty()) return cleaned745 }746 return ""747}