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 PeachEmptyOrEndState(338 message = "end of entry",339 detail = "All saved fields for this item are above.",340 modifier = Modifier.padding(top = PeachSpacing.md, bottom = PeachSpacing.lg),341 )342 }343344 if (e != null) {345 EntryDetailSearchStrip(346 currentIndex = activeEntries.indexOfFirst { it.id == e.id },347 totalEntries = activeEntries.size,348 onOpenList = { onNavigate(PeachNav.VaultList) },349 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },350 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },351 modifier = Modifier.align(Alignment.BottomCenter),352 )353 }354355 if (generatorOpen) {356 GeneratorSheet(357 copied = copiedField == "genDraft",358 onCopy = { draft -> vm.copy("genDraft", draft) },359 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },360 onDismiss = { generatorOpen = false },361 )362 }363 }364 }365}366367@Composable368private fun EntryDetailTopBar(369 entry: com.peachpasswords.vault.VaultEntry?,370 confirmingDelete: Boolean,371 onBack: () -> Unit,372 onDelete: () -> Unit,373) {374 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }375 PeachTopBar(376 title = when {377 entry == null -> "entry"378 confirmingDelete -> "delete \"${entry.title}\"?"379 domain.isNotEmpty() -> domain380 else -> entry.title381 },382 leading = {383 Icon(384 Icons.AutoMirrored.Rounded.ArrowBack,385 contentDescription = "back",386 tint = peachColors.T0,387 modifier = Modifier388 .size(40.dp)389 .clip(CircleShape)390 .clickable(onClick = onBack)391 .padding(PeachSpacing.sm),392 )393 if (entry != null && !confirmingDelete) {394 FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)395 }396 },397 actions = {398 if (entry != null) {399 Icon(400 Icons.Outlined.DeleteOutline,401 contentDescription = "delete",402 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,403 modifier = Modifier404 .size(40.dp)405 .clip(CircleShape)406 .clickable(onClick = onDelete)407 .padding(PeachSpacing.sm),408 )409 }410 },411 )412}413414@Composable415private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {416 val accent = LocalPeachAccent.current417 var editing by remember(title) { mutableStateOf(false) }418 var draft by remember(title) { mutableStateOf(title) }419 if (!editing) {420 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {421 CharEnterText(422 text = title,423 style = PeachType.entryNameHero.copy(color = peachColors.T0),424 )425 }426 return427 }428 Column(Modifier.fillMaxWidth()) {429 BasicTextField(430 value = draft,431 onValueChange = { draft = it },432 singleLine = true,433 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),434 cursorBrush = SolidColor(accent),435 modifier = Modifier.fillMaxWidth().drawBehind {436 drawLine(437 color = accent,438 start = Offset(0f, size.height),439 end = Offset(size.width, size.height),440 strokeWidth = 1.dp.toPx(),441 )442 },443 )444 Spacer(Modifier.height(8.dp))445 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {446 Text(447 "✓ save", color = accent, style = PeachType.labelMono,448 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {449 onSave(draft)450 editing = false451 }.padding(vertical = 4.dp),452 )453 Text(454 "✗", color = peachColors.T2, style = PeachType.labelMono,455 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {456 draft = title457 editing = false458 }.padding(horizontal = 6.dp, vertical = 4.dp),459 )460 }461 }462}463464@Composable465private fun HomographWarning() {466 Row(467 Modifier.fillMaxWidth()468 .padding(top = 8.dp)469 .clip(RoundedCornerShape(8.dp))470 .background(peachColors.BreachBg)471 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))472 .padding(horizontal = 12.dp, vertical = 9.dp),473 verticalAlignment = Alignment.CenterVertically,474 ) {475 Text("!", color = peachColors.Breach, style = PeachType.labelMono)476 Spacer(Modifier.width(10.dp))477 Text(478 "This URL contains non-ASCII or punycode characters.",479 color = peachColors.Breach,480 style = PeachType.labelMono,481 modifier = Modifier.weight(1f),482 )483 }484}485486@Composable487private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {488 val passkey = entry.passkey ?: return489 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {490 Column(Modifier.weight(1f)) {491 Text(492 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,493 color = peachColors.T0,494 style = PeachType.body,495 )496 Text(497 listOfNotNull(498 passkey.rpId,499 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,500 ).joinToString(" · "),501 color = peachColors.T2,502 style = PeachType.labelMono,503 )504 Text(505 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",506 color = peachColors.T2,507 style = PeachType.labelMono,508 modifier = Modifier.padding(top = 3.dp),509 )510 }511 }512}513514@Composable515private fun CanaryDetailSection(516 entryId: String,517 modifiedAt: Long,518 canary: com.peachpasswords.vault.Canary?,519 isPro: Boolean,520 message: String?,521 testing: Boolean,522 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,523 onTest: (webhookUrl: String) -> Unit,524) {525 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }526 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }527 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }528 val gated = !isPro && canary == null529 FieldShell("canary alert") {530 Column(Modifier.weight(1f)) {531 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {532 Text(533 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",534 color = peachColors.T0,535 style = PeachType.body,536 modifier = Modifier.weight(1f),537 )538 if (gated) {539 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)540 }541 Text(542 if (enabled) "on" else "off",543 color = if (enabled) LocalPeachAccent.current else peachColors.T2,544 style = PeachType.labelMono,545 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {546 if (gated) {547 onSave(true, webhookUrl, label)548 } else {549 val next = !enabled550 enabled = next551 if (!next) onSave(false, webhookUrl, label)552 }553 }.padding(horizontal = 6.dp, vertical = 4.dp),554 )555 }556 Text(557 "Fires a silent webhook when this entry's password or username is copied.",558 color = peachColors.T2,559 style = PeachType.labelMono,560 modifier = Modifier.padding(top = 3.dp),561 )562 if (enabled) {563 Spacer(Modifier.height(8.dp))564 PeachField(565 label = "webhook url",566 value = webhookUrl,567 onChange = { webhookUrl = it },568 )569 Text(570 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",571 color = peachColors.T2,572 style = PeachType.labelMono,573 )574 PeachField(575 label = "label",576 value = label,577 onChange = { label = it },578 mono = false,579 )580 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {581 Text(582 "save",583 color = LocalPeachAccent.current,584 style = PeachType.labelMono,585 modifier = Modifier.clip(RoundedCornerShape(6.dp))586 .clickable { onSave(true, webhookUrl, label) }587 .padding(vertical = 4.dp),588 )589 Text(590 if (testing) "sending..." else "test ping",591 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,592 style = PeachType.labelMono,593 modifier = Modifier.clip(RoundedCornerShape(6.dp))594 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }595 .padding(vertical = 4.dp),596 )597 }598 }599 message?.let {600 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))601 }602 if (canary?.lastPinged != null || canary?.pingCount != null) {603 Text(604 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),605 color = peachColors.T2,606 style = PeachType.labelMono,607 modifier = Modifier.padding(top = 3.dp),608 )609 }610 }611 }612}613614@Composable615private fun EntryDetailSearchStrip(616 currentIndex: Int,617 totalEntries: Int,618 onOpenList: () -> Unit,619 onPrevious: () -> Unit,620 onNext: () -> Unit,621 modifier: Modifier = Modifier,622) {623 val accent = LocalPeachAccent.current624 val hasEntries = totalEntries > 0 && currentIndex >= 0625 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"626627 Column(628 modifier629 .fillMaxWidth()630 .navigationBarsPadding()631 .padding(horizontal = 16.dp, vertical = 12.dp),632 ) {633 Row(634 Modifier635 .fillMaxWidth()636 .height(56.dp)637 .clip(RoundedCornerShape(16.dp))638 .background(peachColors.S1)639 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))640 .padding(horizontal = 12.dp),641 verticalAlignment = Alignment.CenterVertically,642 ) {643 Row(644 Modifier645 .weight(1f)646 .fillMaxHeight()647 .clickable(onClick = onOpenList)648 .pointerInput(onOpenList) {649 var opened = false650 detectVerticalDragGestures(651 onDragEnd = { opened = false },652 onDragCancel = { opened = false },653 ) { _, dragAmount ->654 if (!opened && dragAmount < -4f) {655 opened = true656 onOpenList()657 }658 }659 },660 verticalAlignment = Alignment.CenterVertically,661 ) {662 Icon(663 Icons.Rounded.Search,664 contentDescription = null,665 tint = accent,666 modifier = Modifier.size(14.dp),667 )668 Spacer(Modifier.width(8.dp))669 Text(670 "search vault...",671 color = peachColors.T2,672 style = PeachType.searchInput,673 modifier = Modifier.weight(1f),674 )675 Text(676 counter,677 color = peachColors.T2,678 style = PeachType.labelMono,679 )680 }681 Spacer(Modifier.width(8.dp))682 Icon(683 Icons.AutoMirrored.Rounded.ArrowBack,684 contentDescription = "Previous entry",685 tint = if (hasEntries) peachColors.T0 else peachColors.T2,686 modifier = Modifier687 .size(38.dp)688 .clip(CircleShape)689 .clickable(enabled = hasEntries, onClick = onPrevious)690 .padding(9.dp),691 )692 Icon(693 Icons.AutoMirrored.Rounded.ArrowForward,694 contentDescription = "Next entry",695 tint = if (hasEntries) peachColors.T0 else peachColors.T2,696 modifier = Modifier697 .size(38.dp)698 .clip(CircleShape)699 .clickable(enabled = hasEntries, onClick = onNext)700 .padding(9.dp),701 )702 }703 }704}705706707@Composable708internal fun FieldShell(709 label: String,710 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,711) {712 val hairline = peachColors.T3713 Column(714 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {715 drawRect(716 color = hairline,717 topLeft = Offset(0f, size.height - 1.dp.toPx()),718 size = Size(size.width, 1.dp.toPx()),719 )720 },721 ) {722 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))723 Spacer(Modifier.height(6.dp))724 Row(verticalAlignment = Alignment.CenterVertically, content = content)725 }726}727728729730731732internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }733734internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =735 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)736737internal fun shortCredential(credentialId: String): String {738 if (credentialId.length <= 12) return credentialId739 return credentialId.take(6) + "..." + credentialId.takeLast(4)740}741742internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {743 val count = pingCount ?: 0744 val countLabel = "$count total alert" + if (count == 1) "" else "s"745 if (lastPinged == null) return countLabel746 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)747 .format(java.util.Date(lastPinged))748 return "Last triggered: $date · $countLabel"749}750751private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {752 val raw = url753 if (!raw.isNullOrBlank()) {754 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')755 if (cleaned.isNotEmpty()) return cleaned756 }757 return ""758}