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.detectVerticalDragGestures15import androidx.compose.foundation.layout.Arrangement16import androidx.compose.foundation.layout.Box17import androidx.compose.foundation.layout.Column18import androidx.compose.foundation.layout.Row19import androidx.compose.foundation.layout.Spacer20import androidx.compose.foundation.layout.fillMaxSize21import androidx.compose.foundation.layout.fillMaxHeight22import androidx.compose.foundation.layout.fillMaxWidth23import androidx.compose.foundation.layout.height24import androidx.compose.foundation.layout.offset25import androidx.compose.foundation.layout.padding26import androidx.compose.foundation.layout.navigationBarsPadding27import androidx.compose.foundation.layout.size28import androidx.compose.foundation.layout.statusBarsPadding29import androidx.compose.foundation.layout.width30import androidx.compose.foundation.rememberScrollState31import androidx.compose.foundation.shape.CircleShape32import androidx.compose.foundation.shape.RoundedCornerShape33import androidx.compose.foundation.text.BasicTextField34import androidx.compose.material.icons.rounded.Check35import androidx.compose.material.icons.rounded.ContentCopy36import androidx.compose.material.icons.rounded.Search37import androidx.compose.foundation.verticalScroll38import androidx.compose.material.icons.Icons39import androidx.compose.material.icons.automirrored.rounded.ArrowBack40import androidx.compose.material.icons.automirrored.rounded.ArrowForward41import androidx.compose.material.icons.outlined.DeleteOutline42import androidx.compose.material3.Icon43import androidx.compose.material3.Text44import androidx.compose.runtime.Composable45import androidx.compose.runtime.getValue46import androidx.compose.runtime.mutableStateOf47import androidx.compose.runtime.produceState48import androidx.compose.runtime.remember49import androidx.compose.runtime.setValue50import androidx.compose.ui.Alignment51import androidx.compose.ui.Modifier52import androidx.compose.ui.draw.clip53import androidx.compose.ui.draw.clipToBounds54import androidx.compose.ui.draw.drawBehind55import androidx.compose.ui.geometry.Offset56import androidx.compose.ui.geometry.Size57import androidx.compose.ui.graphics.Brush58import androidx.compose.ui.graphics.Color59import androidx.compose.ui.graphics.SolidColor60import androidx.compose.ui.graphics.graphicsLayer61import androidx.compose.ui.platform.LocalContext62import androidx.compose.ui.input.pointer.pointerInput63import androidx.compose.ui.text.TextStyle64import androidx.compose.ui.text.font.FontStyle65import androidx.compose.ui.text.style.TextOverflow66import androidx.compose.ui.unit.dp67import androidx.compose.ui.unit.em68import androidx.compose.ui.unit.sp69import androidx.compose.runtime.collectAsState70import androidx.fragment.app.FragmentActivity71import androidx.hilt.navigation.compose.hiltViewModel72import com.peachpasswords.shared.detectHomograph73import com.peachpasswords.ui.PeachNav74import com.peachpasswords.ui.components.CharEnterText75import com.peachpasswords.ui.components.CopyButton76import com.peachpasswords.ui.components.FaviconInitials77import com.peachpasswords.ui.components.PeachEmptyOrEndState78import com.peachpasswords.ui.components.PeachTopBar79import com.peachpasswords.ui.components.SecureFlagEffect80import com.peachpasswords.ui.components.TOTPRing81import com.peachpasswords.ui.lock.Biometrics82import com.peachpasswords.ui.theme.LocalPeachAccent83import com.peachpasswords.ui.theme.LocalReduceMotion84import com.peachpasswords.ui.theme.PeachSpacing85import com.peachpasswords.ui.theme.peachColors86import com.peachpasswords.ui.theme.PeachType87import com.peachpasswords.ui.theme.ShellBackground88import com.peachpasswords.vault.TOTPGenerator89import com.peachpasswords.vault.hasTOTP90import com.peachpasswords.vault.hasPasskey91import com.peachpasswords.vault.isBreach92import com.peachpasswords.vault.isCard93import com.peachpasswords.vault.isNote94import com.peachpasswords.vault.isPasskeyOnly9596@Composable97fun EntryDetailScreen(98 id: String,99 onNavigate: (PeachNav) -> Unit,100 onBack: () -> Unit,101 vm: EntryDetailViewModel = hiltViewModel(),102) {103 val entry by vm.entry.collectAsState()104 val activeEntries by vm.activeEntries.collectAsState()105 val identitySuggestions by vm.identitySuggestions.collectAsState()106 val allTags by vm.allTags.collectAsState()107 val isPro by vm.isPro.collectAsState()108 val cvcRevealed by vm.cvcRevealed.collectAsState()109 var cardRevealed by remember { mutableStateOf(false) }110 val copiedField by vm.copiedField.collectAsState()111 val canaryMessage by vm.canaryMessage.collectAsState()112 val canaryTesting by vm.canaryTesting.collectAsState()113 val context = LocalContext.current114 val accent = LocalPeachAccent.current115 var confirmingDelete by remember { mutableStateOf(false) }116 var generatorOpen by remember { mutableStateOf(false) }117 var historyExpanded by remember { mutableStateOf(false) }118119 SecureFlagEffect()120121 val e = entry122 ShellBackground {123 Box(Modifier.fillMaxSize().statusBarsPadding()) {124 Column(125 Modifier.fillMaxSize()126 .padding(bottom = 100.dp)127 .verticalScroll(rememberScrollState())128 .padding(horizontal = PeachSpacing.xl),129 ) {130 EntryDetailTopBar(131 entry = e,132 confirmingDelete = confirmingDelete,133 onBack = onBack,134 onDelete = {135 if (confirmingDelete) vm.delete(onDone = onBack) else confirmingDelete = true136 },137 )138 Spacer(Modifier.height(PeachSpacing.lg))139140 if (e == null) {141 PeachEmptyOrEndState(142 message = "entry not found",143 detail = "It may have been moved or deleted.",144 modifier = Modifier.padding(top = PeachSpacing.xxl),145 )146 return@Column147 }148149 150 if (e.isBreach()) {151 Row(152 Modifier.fillMaxWidth()153 .background(peachColors.BreachBg)154 .padding(horizontal = 16.dp, vertical = 8.dp),155 verticalAlignment = Alignment.CenterVertically,156 ) {157 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)158 Spacer(Modifier.width(10.dp))159 Text(160 "Password found in a data breach — change it now",161 color = peachColors.Breach, style = PeachType.labelMono,162 modifier = Modifier.weight(1f),163 )164 Text(165 "Change password",166 color = accent, style = PeachType.labelMono,167 modifier = Modifier.clip(RoundedCornerShape(6.dp))168 .clickable { generatorOpen = true }169 .padding(horizontal = 6.dp, vertical = 4.dp),170 )171 }172 }173 if (entryHasHomographWarning(e)) {174 HomographWarning()175 }176177 178 179 180 Box(Modifier.fillMaxWidth().clipToBounds()) {181 Text(182 text = ghostWatermarkText(e.title),183 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),184 color = peachColors.T0.copy(alpha = 0.03f),185 maxLines = 1,186 softWrap = false,187 overflow = TextOverflow.Clip,188 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),189 )190 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {191 EditableTitleHero(192 title = e.title,193 onSave = { vm.updateField("title", it) },194 )195 }196 }197 Spacer(Modifier.height(PeachSpacing.lg))198199 if (e.isCard()) {200 CardDetailFields(201 entry = e,202 cardRevealed = cardRevealed,203 cvcRevealed = cvcRevealed,204 copiedField = copiedField,205 onToggleCardReveal = { cardRevealed = !cardRevealed },206 onRevealCvc = {207 val activity = context as? FragmentActivity208 if (activity != null && Biometrics.isAvailable(context)) {209 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)210 } else vm.revealCvc()211 },212 onMaskCvc = vm::maskCvc,213 onCopy = vm::copy,214 onSaveCard = vm::updateCard,215 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },216 )217 } else {218 if (!e.isNote()) {219 EditableFieldRow(220 label = "url", field = "url", value = e.url.orEmpty(),221 copied = copiedField == "url",222 onCopy = { vm.copy("url", e.url) },223 onSave = { vm.updateField("url", it) },224 placeholder = "add url",225 )226 }227 e.username?.takeIf { it.isNotBlank() }?.let { v ->228 EditableFieldRow(229 label = "username", field = "username", value = v,230 copied = copiedField == "username",231 onCopy = { vm.copy("username", v) },232 onSave = { vm.updateField("username", it) },233 suggestions = identitySuggestions,234 )235 }236 e.email?.takeIf { it.isNotBlank() }?.let { v ->237 EditableFieldRow(238 label = "email", field = "email", value = v,239 copied = copiedField == "email",240 onCopy = { vm.copy("email", v) },241 onSave = { vm.updateField("email", it) },242 suggestions = identitySuggestions,243 )244 }245 AdditionalUrlRows(246 urls = e.additionalUrls.orEmpty(),247 copiedField = copiedField,248 onCopy = { key, value -> vm.copy(key, value) },249 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },250 onAdd = vm::addAdditionalUrl,251 )252 if (e.hasPasskey()) {253 PasskeyDetailSection(e)254 }255 e.password?.takeIf { it.isNotBlank() }?.let { pw ->256 EditablePasswordRow(257 value = pw,258 copied = copiedField == "password",259 entryTitle = e.title,260 onCopy = { vm.copy("password", pw) },261 onSave = { vm.updateField("password", it) },262 onGenerate = { generatorOpen = true },263 )264 }265 val history = e.passwordHistory.orEmpty()266 if (history.isNotEmpty()) {267 PasswordHistoryToggle(268 count = history.size,269 expanded = historyExpanded,270 onToggle = { historyExpanded = !historyExpanded },271 )272 AnimatedVisibility(visible = historyExpanded) {273 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {274 PasswordHistoryList(275 history = history,276 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },277 onCopy = { oldPw -> vm.copy("password", oldPw) },278 )279 }280 }281 }282 if (e.hasTOTP()) {283 Spacer(Modifier.height(4.dp))284 TotpBandEditable(285 totpField = e.totp!!,286 copied = copiedField == "totp",287 onCopy = vm::copyTotp,288 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },289 )290 }291 }292 e.notes?.takeIf { it.isNotBlank() }?.let { v ->293 EditableFieldRow(294 label = "notes", field = "notes", value = v,295 copied = copiedField == "notes",296 onCopy = { vm.copy("notes", v) },297 onSave = { vm.updateField("notes", it) },298 mono = false, singleLine = false,299 )300 }301 DetailTagRow(302 tags = e.tags.orEmpty(),303 allTags = allTags,304 onAddTag = vm::addTag,305 onRemoveTag = vm::removeTag,306 )307 if (!e.isNote() && !e.isCard()) {308 309 310 311 312 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }313 if (advancedExpanded) {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 } else {325 Text(326 "+ advanced",327 color = LocalPeachAccent.current,328 style = PeachType.labelMono,329 modifier = Modifier330 .clip(RoundedCornerShape(6.dp))331 .clickable { advancedExpanded = true }332 .padding(vertical = 6.dp),333 )334 }335 }336337 Spacer(Modifier.height(PeachSpacing.xxl))338 }339340 if (e != null) {341 EntryDetailSearchStrip(342 currentIndex = activeEntries.indexOfFirst { it.id == e.id },343 totalEntries = activeEntries.size,344 onOpenList = { onNavigate(PeachNav.VaultList) },345 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },346 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },347 modifier = Modifier.align(Alignment.BottomCenter),348 )349 }350351 if (generatorOpen) {352 GeneratorSheet(353 copied = copiedField == "genDraft",354 onCopy = { draft -> vm.copy("genDraft", draft) },355 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },356 onDismiss = { generatorOpen = false },357 )358 }359 }360 }361}362363@Composable364private fun EntryDetailTopBar(365 entry: com.peachpasswords.vault.VaultEntry?,366 confirmingDelete: Boolean,367 onBack: () -> Unit,368 onDelete: () -> Unit,369) {370 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }371 PeachTopBar(372 title = when {373 entry == null -> "entry"374 confirmingDelete -> "delete \"${entry.title}\"?"375 domain.isNotEmpty() -> domain376 else -> entry.title377 },378 leading = {379 Icon(380 Icons.AutoMirrored.Rounded.ArrowBack,381 contentDescription = "back",382 tint = peachColors.T0,383 modifier = Modifier384 .size(40.dp)385 .clip(CircleShape)386 .clickable(onClick = onBack)387 .padding(PeachSpacing.sm),388 )389 if (entry != null && !confirmingDelete) {390 FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)391 }392 },393 actions = {394 if (entry != null) {395 Icon(396 Icons.Outlined.DeleteOutline,397 contentDescription = "delete",398 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,399 modifier = Modifier400 .size(40.dp)401 .clip(CircleShape)402 .clickable(onClick = onDelete)403 .padding(PeachSpacing.sm),404 )405 }406 },407 )408}409410@Composable411private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {412 val accent = LocalPeachAccent.current413 var editing by remember(title) { mutableStateOf(false) }414 var draft by remember(title) { mutableStateOf(title) }415 if (!editing) {416 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {417 CharEnterText(418 text = title,419 style = PeachType.entryNameHero.copy(color = peachColors.T0),420 )421 }422 return423 }424 Column(Modifier.fillMaxWidth()) {425 BasicTextField(426 value = draft,427 onValueChange = { draft = it },428 singleLine = true,429 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),430 cursorBrush = SolidColor(accent),431 modifier = Modifier.fillMaxWidth().drawBehind {432 drawLine(433 color = accent,434 start = Offset(0f, size.height),435 end = Offset(size.width, size.height),436 strokeWidth = 1.dp.toPx(),437 )438 },439 )440 Spacer(Modifier.height(8.dp))441 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {442 Text(443 "✓ save", color = accent, style = PeachType.labelMono,444 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {445 onSave(draft)446 editing = false447 }.padding(vertical = 4.dp),448 )449 Text(450 "✗", color = peachColors.T2, style = PeachType.labelMono,451 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {452 draft = title453 editing = false454 }.padding(horizontal = 6.dp, vertical = 4.dp),455 )456 }457 }458}459460@Composable461private fun HomographWarning() {462 Row(463 Modifier.fillMaxWidth()464 .padding(top = 8.dp)465 .clip(RoundedCornerShape(8.dp))466 .background(peachColors.BreachBg)467 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))468 .padding(horizontal = 12.dp, vertical = 9.dp),469 verticalAlignment = Alignment.CenterVertically,470 ) {471 Text("!", color = peachColors.Breach, style = PeachType.labelMono)472 Spacer(Modifier.width(10.dp))473 Text(474 "This URL contains non-ASCII or punycode characters.",475 color = peachColors.Breach,476 style = PeachType.labelMono,477 modifier = Modifier.weight(1f),478 )479 }480}481482@Composable483private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {484 val passkey = entry.passkey ?: return485 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {486 Column(Modifier.weight(1f)) {487 Text(488 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,489 color = peachColors.T0,490 style = PeachType.body,491 )492 Text(493 listOfNotNull(494 passkey.rpId,495 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,496 ).joinToString(" · "),497 color = peachColors.T2,498 style = PeachType.labelMono,499 )500 Text(501 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",502 color = peachColors.T2,503 style = PeachType.labelMono,504 modifier = Modifier.padding(top = 3.dp),505 )506 }507 }508}509510@Composable511private fun CanaryDetailSection(512 entryId: String,513 modifiedAt: Long,514 canary: com.peachpasswords.vault.Canary?,515 isPro: Boolean,516 message: String?,517 testing: Boolean,518 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,519 onTest: (webhookUrl: String) -> Unit,520) {521 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }522 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }523 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }524 val gated = !isPro && canary == null525 FieldShell("canary alert") {526 Column(Modifier.weight(1f)) {527 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {528 Text(529 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",530 color = peachColors.T0,531 style = PeachType.body,532 modifier = Modifier.weight(1f),533 )534 if (gated) {535 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)536 }537 Text(538 if (enabled) "on" else "off",539 color = if (enabled) LocalPeachAccent.current else peachColors.T2,540 style = PeachType.labelMono,541 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {542 if (gated) {543 onSave(true, webhookUrl, label)544 } else {545 val next = !enabled546 enabled = next547 if (!next) onSave(false, webhookUrl, label)548 }549 }.padding(horizontal = 6.dp, vertical = 4.dp),550 )551 }552 Text(553 "Fires a silent webhook when this entry's password or username is copied.",554 color = peachColors.T2,555 style = PeachType.labelMono,556 modifier = Modifier.padding(top = 3.dp),557 )558 if (enabled) {559 Spacer(Modifier.height(8.dp))560 PeachField(561 label = "webhook url",562 value = webhookUrl,563 onChange = { webhookUrl = it },564 )565 Text(566 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",567 color = peachColors.T2,568 style = PeachType.labelMono,569 )570 PeachField(571 label = "label",572 value = label,573 onChange = { label = it },574 mono = false,575 )576 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {577 Text(578 "save",579 color = LocalPeachAccent.current,580 style = PeachType.labelMono,581 modifier = Modifier.clip(RoundedCornerShape(6.dp))582 .clickable { onSave(true, webhookUrl, label) }583 .padding(vertical = 4.dp),584 )585 Text(586 if (testing) "sending..." else "test ping",587 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,588 style = PeachType.labelMono,589 modifier = Modifier.clip(RoundedCornerShape(6.dp))590 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }591 .padding(vertical = 4.dp),592 )593 }594 }595 message?.let {596 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))597 }598 if (canary?.lastPinged != null || canary?.pingCount != null) {599 Text(600 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),601 color = peachColors.T2,602 style = PeachType.labelMono,603 modifier = Modifier.padding(top = 3.dp),604 )605 }606 }607 }608}609610@Composable611private fun EntryDetailSearchStrip(612 currentIndex: Int,613 totalEntries: Int,614 onOpenList: () -> Unit,615 onPrevious: () -> Unit,616 onNext: () -> Unit,617 modifier: Modifier = Modifier,618) {619 val accent = LocalPeachAccent.current620 val hasEntries = totalEntries > 0 && currentIndex >= 0621 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"622623 Column(624 modifier625 .fillMaxWidth()626 .navigationBarsPadding()627 .padding(horizontal = 16.dp, vertical = 12.dp),628 ) {629 Row(630 Modifier631 .fillMaxWidth()632 .height(56.dp)633 .clip(RoundedCornerShape(16.dp))634 .background(peachColors.S1)635 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))636 .padding(horizontal = 12.dp),637 verticalAlignment = Alignment.CenterVertically,638 ) {639 Row(640 Modifier641 .weight(1f)642 .fillMaxHeight()643 .clickable(onClick = onOpenList)644 .pointerInput(onOpenList) {645 var opened = false646 detectVerticalDragGestures(647 onDragEnd = { opened = false },648 onDragCancel = { opened = false },649 ) { _, dragAmount ->650 if (!opened && dragAmount < -4f) {651 opened = true652 onOpenList()653 }654 }655 },656 verticalAlignment = Alignment.CenterVertically,657 ) {658 Icon(659 Icons.Rounded.Search,660 contentDescription = null,661 tint = accent,662 modifier = Modifier.size(14.dp),663 )664 Spacer(Modifier.width(8.dp))665 Text(666 "search vault...",667 color = peachColors.T2,668 style = PeachType.searchInput,669 modifier = Modifier.weight(1f),670 )671 Text(672 counter,673 color = peachColors.T2,674 style = PeachType.labelMono,675 )676 }677 Spacer(Modifier.width(8.dp))678 Icon(679 Icons.AutoMirrored.Rounded.ArrowBack,680 contentDescription = "Previous entry",681 tint = if (hasEntries) peachColors.T0 else peachColors.T2,682 modifier = Modifier683 .size(38.dp)684 .clip(CircleShape)685 .clickable(enabled = hasEntries, onClick = onPrevious)686 .padding(9.dp),687 )688 Icon(689 Icons.AutoMirrored.Rounded.ArrowForward,690 contentDescription = "Next entry",691 tint = if (hasEntries) peachColors.T0 else peachColors.T2,692 modifier = Modifier693 .size(38.dp)694 .clip(CircleShape)695 .clickable(enabled = hasEntries, onClick = onNext)696 .padding(9.dp),697 )698 }699 }700}701702703@Composable704internal fun FieldShell(705 label: String,706 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,707) {708 val hairline = peachColors.T3709 Column(710 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {711 drawRect(712 color = hairline,713 topLeft = Offset(0f, size.height - 1.dp.toPx()),714 size = Size(size.width, 1.dp.toPx()),715 )716 },717 ) {718 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))719 Spacer(Modifier.height(6.dp))720 Row(verticalAlignment = Alignment.CenterVertically, content = content)721 }722}723724725726727728internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }729730internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =731 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)732733internal fun shortCredential(credentialId: String): String {734 if (credentialId.length <= 12) return credentialId735 return credentialId.take(6) + "..." + credentialId.takeLast(4)736}737738internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {739 val count = pingCount ?: 0740 val countLabel = "$count total alert" + if (count == 1) "" else "s"741 if (lastPinged == null) return countLabel742 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)743 .format(java.util.Date(lastPinged))744 return "Last triggered: $date · $countLabel"745}746747private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {748 val raw = url749 if (!raw.isNullOrBlank()) {750 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')751 if (cleaned.isNotEmpty()) return cleaned752 }753 return ""754}