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.material.icons.outlined.Lock43import androidx.compose.material3.Icon44import androidx.compose.material3.Text45import androidx.compose.runtime.Composable46import androidx.compose.runtime.getValue47import androidx.compose.runtime.mutableStateOf48import androidx.compose.runtime.produceState49import androidx.compose.runtime.remember50import androidx.compose.runtime.setValue51import androidx.compose.ui.Alignment52import androidx.compose.ui.Modifier53import androidx.compose.ui.draw.clip54import androidx.compose.ui.draw.clipToBounds55import androidx.compose.ui.draw.drawBehind56import androidx.compose.ui.geometry.Offset57import androidx.compose.ui.geometry.Size58import androidx.compose.ui.graphics.Brush59import androidx.compose.ui.graphics.Color60import androidx.compose.ui.graphics.SolidColor61import androidx.compose.ui.graphics.graphicsLayer62import androidx.compose.ui.platform.LocalContext63import androidx.compose.ui.input.pointer.pointerInput64import androidx.compose.ui.text.TextStyle65import androidx.compose.ui.text.font.FontStyle66import androidx.compose.ui.text.style.TextOverflow67import androidx.compose.ui.unit.dp68import androidx.compose.ui.unit.em69import androidx.compose.ui.unit.sp70import androidx.compose.runtime.collectAsState71import androidx.fragment.app.FragmentActivity72import androidx.hilt.navigation.compose.hiltViewModel73import com.peachpasswords.shared.detectHomograph74import com.peachpasswords.ui.PeachNav75import com.peachpasswords.ui.components.CharEnterText76import com.peachpasswords.ui.components.CopyButton77import com.peachpasswords.ui.components.FaviconInitials78import com.peachpasswords.ui.components.PeachEmptyOrEndState79import com.peachpasswords.ui.components.PeachTopBar80import com.peachpasswords.ui.components.SecureFlagEffect81import 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 102 103 104 onBack: (() -> Unit)?,105 vm: EntryDetailViewModel = hiltViewModel(),106) {107 val entry by vm.entry.collectAsState()108 val activeEntries by vm.activeEntries.collectAsState()109 val identitySuggestions by vm.identitySuggestions.collectAsState()110 val allTags by vm.allTags.collectAsState()111 val isPro by vm.isPro.collectAsState()112 val cvcRevealed by vm.cvcRevealed.collectAsState()113 var cardRevealed by remember { mutableStateOf(false) }114 val copiedField by vm.copiedField.collectAsState()115 val canaryMessage by vm.canaryMessage.collectAsState()116 val canaryTesting by vm.canaryTesting.collectAsState()117 val context = LocalContext.current118 val accent = LocalPeachAccent.current119 var confirmingDelete by remember { mutableStateOf(false) }120 var generatorOpen by remember { mutableStateOf(false) }121 var historyExpanded by remember { mutableStateOf(false) }122123 SecureFlagEffect()124125 val e = entry126 ShellBackground {127 Box(Modifier.fillMaxSize().statusBarsPadding()) {128 Column(129 Modifier.fillMaxSize()130 .padding(bottom = 100.dp)131 .verticalScroll(rememberScrollState())132 .padding(horizontal = PeachSpacing.xl),133 ) {134 EntryDetailTopBar(135 entry = e,136 confirmingDelete = confirmingDelete,137 onBack = onBack,138 onLock = vm::lock,139 onDelete = {140 if (confirmingDelete) {141 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })142 } else {143 confirmingDelete = true144 }145 },146 )147 Spacer(Modifier.height(PeachSpacing.lg))148149 if (e == null) {150 PeachEmptyOrEndState(151 message = "entry not found",152 detail = "It may have been moved or deleted.",153 modifier = Modifier.padding(top = PeachSpacing.xxl),154 )155 return@Column156 }157158 159 if (e.isBreach()) {160 Row(161 Modifier.fillMaxWidth()162 .background(peachColors.BreachBg)163 .padding(horizontal = 16.dp, vertical = 8.dp),164 verticalAlignment = Alignment.CenterVertically,165 ) {166 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)167 Spacer(Modifier.width(10.dp))168 Text(169 "Password found in a data breach — change it now",170 color = peachColors.Breach, style = PeachType.labelMono,171 modifier = Modifier.weight(1f),172 )173 Text(174 "Change password",175 color = accent, style = PeachType.labelMono,176 modifier = Modifier.clip(RoundedCornerShape(6.dp))177 .clickable { generatorOpen = true }178 .padding(horizontal = 6.dp, vertical = 4.dp),179 )180 }181 }182 if (entryHasHomographWarning(e)) {183 HomographWarning()184 }185186 187 188 189 Box(Modifier.fillMaxWidth().clipToBounds()) {190 Text(191 text = ghostWatermarkText(e.title),192 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),193 color = peachColors.T0.copy(alpha = 0.03f),194 maxLines = 1,195 softWrap = false,196 overflow = TextOverflow.Clip,197 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),198 )199 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {200 EditableTitleHero(201 title = e.title,202 onSave = { vm.updateField("title", it) },203 )204 }205 }206 Spacer(Modifier.height(PeachSpacing.lg))207208 if (e.isCard()) {209 CardDetailFields(210 entry = e,211 cardRevealed = cardRevealed,212 cvcRevealed = cvcRevealed,213 copiedField = copiedField,214 onToggleCardReveal = { cardRevealed = !cardRevealed },215 onRevealCvc = {216 val activity = context as? FragmentActivity217 if (activity != null && Biometrics.isAvailable(context)) {218 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)219 } else vm.revealCvc()220 },221 onMaskCvc = vm::maskCvc,222 onCopy = vm::copy,223 onSaveCard = vm::updateCard,224 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },225 )226 } else {227 if (!e.isNote()) {228 EditableFieldRow(229 label = "url", field = "url", value = e.url.orEmpty(),230 copied = copiedField == "url",231 onCopy = { vm.copy("url", e.url) },232 onSave = { vm.updateField("url", it) },233 placeholder = "add url",234 )235 }236 e.username?.takeIf { it.isNotBlank() }?.let { v ->237 EditableFieldRow(238 label = "username", field = "username", value = v,239 copied = copiedField == "username",240 onCopy = { vm.copy("username", v) },241 onSave = { vm.updateField("username", it) },242 suggestions = identitySuggestions,243 )244 }245 e.email?.takeIf { it.isNotBlank() }?.let { v ->246 EditableFieldRow(247 label = "email", field = "email", value = v,248 copied = copiedField == "email",249 onCopy = { vm.copy("email", v) },250 onSave = { vm.updateField("email", it) },251 suggestions = identitySuggestions,252 )253 }254 AdditionalUrlRows(255 urls = e.additionalUrls.orEmpty(),256 copiedField = copiedField,257 onCopy = { key, value -> vm.copy(key, value) },258 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },259 onAdd = vm::addAdditionalUrl,260 )261 if (e.hasPasskey()) {262 PasskeyDetailSection(e)263 }264 e.password?.takeIf { it.isNotBlank() }?.let { pw ->265 EditablePasswordRow(266 value = pw,267 copied = copiedField == "password",268 entryTitle = e.title,269 onCopy = { vm.copy("password", pw) },270 onSave = { vm.updateField("password", it) },271 onGenerate = { generatorOpen = true },272 )273 }274 val history = e.passwordHistory.orEmpty()275 if (history.isNotEmpty()) {276 PasswordHistoryToggle(277 count = history.size,278 expanded = historyExpanded,279 onToggle = { historyExpanded = !historyExpanded },280 )281 AnimatedVisibility(visible = historyExpanded) {282 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {283 PasswordHistoryList(284 history = history,285 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },286 onCopy = { oldPw -> vm.copy("password", oldPw) },287 )288 }289 }290 }291 if (e.hasTOTP()) {292 Spacer(Modifier.height(4.dp))293 TotpBandEditable(294 totpField = e.totp!!,295 copied = copiedField == "totp",296 onCopy = vm::copyTotp,297 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },298 )299 }300 }301 e.notes?.takeIf { it.isNotBlank() }?.let { v ->302 EditableFieldRow(303 label = "notes", field = "notes", value = v,304 copied = copiedField == "notes",305 onCopy = { vm.copy("notes", v) },306 onSave = { vm.updateField("notes", it) },307 mono = false, singleLine = false,308 )309 }310 DetailTagRow(311 tags = e.tags.orEmpty(),312 allTags = allTags,313 onAddTag = vm::addTag,314 onRemoveTag = vm::removeTag,315 )316 if (!e.isNote() && !e.isCard()) {317 318 319 320 321 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }322 if (advancedExpanded) {323 CanaryDetailSection(324 entryId = e.id,325 modifiedAt = e.modifiedAt,326 canary = e.canary,327 isPro = isPro,328 message = canaryMessage,329 testing = canaryTesting,330 onSave = vm::saveCanary,331 onTest = vm::testCanary,332 )333 } else {334 Text(335 "+ advanced",336 color = LocalPeachAccent.current,337 style = PeachType.labelMono,338 modifier = Modifier339 .clip(RoundedCornerShape(6.dp))340 .clickable { advancedExpanded = true }341 .padding(vertical = 6.dp),342 )343 }344 }345346 Spacer(Modifier.height(PeachSpacing.xxl))347 }348349 if (e != null) {350 EntryDetailSearchStrip(351 currentIndex = activeEntries.indexOfFirst { it.id == e.id },352 totalEntries = activeEntries.size,353 onOpenList = { onNavigate(PeachNav.VaultList) },354 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },355 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },356 modifier = Modifier.align(Alignment.BottomCenter),357 )358 }359360 if (generatorOpen) {361 GeneratorSheet(362 copied = copiedField == "genDraft",363 onCopy = { draft -> vm.copy("genDraft", draft) },364 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },365 onDismiss = { generatorOpen = false },366 )367 }368 }369 }370}371372@Composable373private fun EntryDetailTopBar(374 entry: com.peachpasswords.vault.VaultEntry?,375 confirmingDelete: Boolean,376 onBack: (() -> Unit)?,377 onLock: () -> Unit,378 onDelete: () -> Unit,379) {380 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }381 PeachTopBar(382 title = when {383 entry == null -> "entry"384 confirmingDelete -> "delete \"${entry.title}\"?"385 domain.isNotEmpty() -> domain386 else -> entry.title387 },388 leading = {389 if (onBack != null) {390 Icon(391 Icons.AutoMirrored.Rounded.ArrowBack,392 contentDescription = "back",393 tint = peachColors.T0,394 modifier = Modifier395 .size(40.dp)396 .clip(CircleShape)397 .clickable(onClick = onBack)398 .padding(PeachSpacing.sm),399 )400 } else {401 402 403 404 405 406 407 408 Icon(409 Icons.Outlined.Lock,410 contentDescription = "Lock vault",411 tint = peachColors.T0,412 modifier = Modifier413 .size(40.dp)414 .clip(CircleShape)415 .clickable(onClick = onLock)416 .padding(11.dp),417 )418 }419 if (entry != null && !confirmingDelete) {420 FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)421 }422 },423 actions = {424 if (entry != null) {425 Icon(426 Icons.Outlined.DeleteOutline,427 contentDescription = "delete",428 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,429 modifier = Modifier430 .size(40.dp)431 .clip(CircleShape)432 .clickable(onClick = onDelete)433 .padding(PeachSpacing.sm),434 )435 }436 },437 )438}439440@Composable441private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {442 val accent = LocalPeachAccent.current443 var editing by remember(title) { mutableStateOf(false) }444 var draft by remember(title) { mutableStateOf(title) }445 if (!editing) {446 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {447 CharEnterText(448 text = title,449 style = PeachType.entryNameHero.copy(color = peachColors.T0),450 )451 }452 return453 }454 Column(Modifier.fillMaxWidth()) {455 BasicTextField(456 value = draft,457 onValueChange = { draft = it },458 singleLine = true,459 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),460 cursorBrush = SolidColor(accent),461 modifier = Modifier.fillMaxWidth().drawBehind {462 drawLine(463 color = accent,464 start = Offset(0f, size.height),465 end = Offset(size.width, size.height),466 strokeWidth = 1.dp.toPx(),467 )468 },469 )470 Spacer(Modifier.height(8.dp))471 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {472 Text(473 "✓ save", color = accent, style = PeachType.labelMono,474 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {475 onSave(draft)476 editing = false477 }.padding(vertical = 4.dp),478 )479 Text(480 "✗", color = peachColors.T2, style = PeachType.labelMono,481 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {482 draft = title483 editing = false484 }.padding(horizontal = 6.dp, vertical = 4.dp),485 )486 }487 }488}489490@Composable491private fun HomographWarning() {492 Row(493 Modifier.fillMaxWidth()494 .padding(top = 8.dp)495 .clip(RoundedCornerShape(8.dp))496 .background(peachColors.BreachBg)497 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))498 .padding(horizontal = 12.dp, vertical = 9.dp),499 verticalAlignment = Alignment.CenterVertically,500 ) {501 Text("!", color = peachColors.Breach, style = PeachType.labelMono)502 Spacer(Modifier.width(10.dp))503 Text(504 "This URL contains non-ASCII or punycode characters.",505 color = peachColors.Breach,506 style = PeachType.labelMono,507 modifier = Modifier.weight(1f),508 )509 }510}511512@Composable513private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {514 val passkey = entry.passkey ?: return515 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {516 Column(Modifier.weight(1f)) {517 Text(518 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,519 color = peachColors.T0,520 style = PeachType.body,521 )522 Text(523 listOfNotNull(524 passkey.rpId,525 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,526 ).joinToString(" · "),527 color = peachColors.T2,528 style = PeachType.labelMono,529 )530 Text(531 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",532 color = peachColors.T2,533 style = PeachType.labelMono,534 modifier = Modifier.padding(top = 3.dp),535 )536 }537 }538}539540@Composable541private fun CanaryDetailSection(542 entryId: String,543 modifiedAt: Long,544 canary: com.peachpasswords.vault.Canary?,545 isPro: Boolean,546 message: String?,547 testing: Boolean,548 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,549 onTest: (webhookUrl: String) -> Unit,550) {551 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }552 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }553 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }554 val gated = !isPro && canary == null555 FieldShell("canary alert") {556 Column(Modifier.weight(1f)) {557 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {558 Text(559 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",560 color = peachColors.T0,561 style = PeachType.body,562 modifier = Modifier.weight(1f),563 )564 if (gated) {565 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)566 }567 Text(568 if (enabled) "on" else "off",569 color = if (enabled) LocalPeachAccent.current else peachColors.T2,570 style = PeachType.labelMono,571 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {572 if (gated) {573 onSave(true, webhookUrl, label)574 } else {575 val next = !enabled576 enabled = next577 if (!next) onSave(false, webhookUrl, label)578 }579 }.padding(horizontal = 6.dp, vertical = 4.dp),580 )581 }582 Text(583 "Fires a silent webhook when this entry's password or username is copied.",584 color = peachColors.T2,585 style = PeachType.labelMono,586 modifier = Modifier.padding(top = 3.dp),587 )588 if (enabled) {589 Spacer(Modifier.height(8.dp))590 PeachField(591 label = "webhook url",592 value = webhookUrl,593 onChange = { webhookUrl = it },594 )595 Text(596 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",597 color = peachColors.T2,598 style = PeachType.labelMono,599 )600 PeachField(601 label = "label",602 value = label,603 onChange = { label = it },604 mono = false,605 )606 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {607 Text(608 "save",609 color = LocalPeachAccent.current,610 style = PeachType.labelMono,611 modifier = Modifier.clip(RoundedCornerShape(6.dp))612 .clickable { onSave(true, webhookUrl, label) }613 .padding(vertical = 4.dp),614 )615 Text(616 if (testing) "sending..." else "test ping",617 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,618 style = PeachType.labelMono,619 modifier = Modifier.clip(RoundedCornerShape(6.dp))620 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }621 .padding(vertical = 4.dp),622 )623 }624 }625 message?.let {626 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))627 }628 if (canary?.lastPinged != null || canary?.pingCount != null) {629 Text(630 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),631 color = peachColors.T2,632 style = PeachType.labelMono,633 modifier = Modifier.padding(top = 3.dp),634 )635 }636 }637 }638}639640@Composable641private fun EntryDetailSearchStrip(642 currentIndex: Int,643 totalEntries: Int,644 onOpenList: () -> Unit,645 onPrevious: () -> Unit,646 onNext: () -> Unit,647 modifier: Modifier = Modifier,648) {649 val accent = LocalPeachAccent.current650 val hasEntries = totalEntries > 0 && currentIndex >= 0651 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"652653 Column(654 modifier655 .fillMaxWidth()656 .navigationBarsPadding()657 .padding(horizontal = 16.dp, vertical = 12.dp),658 ) {659 Row(660 Modifier661 .fillMaxWidth()662 .height(56.dp)663 .clip(RoundedCornerShape(16.dp))664 .background(peachColors.S1)665 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))666 .padding(horizontal = 12.dp),667 verticalAlignment = Alignment.CenterVertically,668 ) {669 Row(670 Modifier671 .weight(1f)672 .fillMaxHeight()673 .clickable(onClick = onOpenList)674 .pointerInput(onOpenList) {675 var opened = false676 detectVerticalDragGestures(677 onDragEnd = { opened = false },678 onDragCancel = { opened = false },679 ) { _, dragAmount ->680 if (!opened && dragAmount < -4f) {681 opened = true682 onOpenList()683 }684 }685 },686 verticalAlignment = Alignment.CenterVertically,687 ) {688 Icon(689 Icons.Rounded.Search,690 contentDescription = null,691 tint = accent,692 modifier = Modifier.size(14.dp),693 )694 Spacer(Modifier.width(8.dp))695 Text(696 "search vault...",697 color = peachColors.T2,698 style = PeachType.searchInput,699 modifier = Modifier.weight(1f),700 )701 Text(702 counter,703 color = peachColors.T2,704 style = PeachType.labelMono,705 )706 }707 Spacer(Modifier.width(8.dp))708 Icon(709 Icons.AutoMirrored.Rounded.ArrowBack,710 contentDescription = "Previous entry",711 tint = if (hasEntries) peachColors.T0 else peachColors.T2,712 modifier = Modifier713 .size(38.dp)714 .clip(CircleShape)715 .clickable(enabled = hasEntries, onClick = onPrevious)716 .padding(9.dp),717 )718 Icon(719 Icons.AutoMirrored.Rounded.ArrowForward,720 contentDescription = "Next entry",721 tint = if (hasEntries) peachColors.T0 else peachColors.T2,722 modifier = Modifier723 .size(38.dp)724 .clip(CircleShape)725 .clickable(enabled = hasEntries, onClick = onNext)726 .padding(9.dp),727 )728 }729 }730}731732733@Composable734internal fun FieldShell(735 label: String,736 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,737) {738 val hairline = peachColors.T3739 Column(740 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {741 drawRect(742 color = hairline,743 topLeft = Offset(0f, size.height - 1.dp.toPx()),744 size = Size(size.width, 1.dp.toPx()),745 )746 },747 ) {748 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))749 Spacer(Modifier.height(6.dp))750 Row(verticalAlignment = Alignment.CenterVertically, content = content)751 }752}753754755756757758internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }759760internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =761 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)762763internal fun shortCredential(credentialId: String): String {764 if (credentialId.length <= 12) return credentialId765 return credentialId.take(6) + "..." + credentialId.takeLast(4)766}767768internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {769 val count = pingCount ?: 0770 val countLabel = "$count total alert" + if (count == 1) "" else "s"771 if (lastPinged == null) return countLabel772 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)773 .format(java.util.Date(lastPinged))774 return "Last triggered: $date · $countLabel"775}776777private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {778 val raw = url779 if (!raw.isNullOrBlank()) {780 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')781 if (cleaned.isNotEmpty()) return cleaned782 }783 return ""784}