1package com.peachpasswords.ui.vault23import androidx.compose.animation.core.LinearEasing4import androidx.compose.animation.core.RepeatMode5import androidx.compose.animation.core.animateFloat6import androidx.compose.animation.core.animateFloatAsState7import androidx.compose.animation.core.infiniteRepeatable8import androidx.compose.animation.core.rememberInfiniteTransition9import androidx.compose.animation.core.tween10import androidx.compose.foundation.background11import androidx.compose.foundation.border12import androidx.compose.foundation.clickable13import androidx.compose.foundation.gestures.detectHorizontalDragGestures14import androidx.compose.foundation.layout.Arrangement15import androidx.compose.foundation.layout.Box16import androidx.compose.foundation.layout.Column17import androidx.compose.foundation.layout.Row18import androidx.compose.foundation.layout.Spacer19import androidx.compose.foundation.layout.fillMaxSize20import androidx.compose.foundation.layout.fillMaxWidth21import androidx.compose.foundation.layout.height22import androidx.compose.foundation.layout.padding23import androidx.compose.foundation.layout.navigationBarsPadding24import androidx.compose.foundation.layout.size25import androidx.compose.foundation.layout.statusBarsPadding26import androidx.compose.foundation.layout.width27import androidx.compose.foundation.layout.wrapContentHeight28import androidx.compose.foundation.rememberScrollState29import androidx.compose.foundation.shape.RoundedCornerShape30import androidx.compose.foundation.text.BasicTextField31import androidx.compose.material.icons.rounded.Check32import androidx.compose.material.icons.rounded.ContentCopy33import androidx.compose.foundation.verticalScroll34import androidx.compose.material.icons.Icons35import androidx.compose.material.icons.automirrored.rounded.ArrowBack36import androidx.compose.material.icons.automirrored.rounded.ArrowForward37import androidx.compose.material3.Icon38import androidx.compose.material3.Text39import androidx.compose.runtime.Composable40import androidx.compose.runtime.getValue41import androidx.compose.runtime.mutableStateOf42import androidx.compose.runtime.produceState43import androidx.compose.runtime.remember44import androidx.compose.runtime.setValue45import androidx.compose.ui.Alignment46import androidx.compose.ui.Modifier47import androidx.compose.ui.draw.clip48import androidx.compose.ui.draw.drawBehind49import androidx.compose.ui.geometry.Offset50import androidx.compose.ui.geometry.Size51import androidx.compose.ui.graphics.Brush52import androidx.compose.ui.graphics.Color53import androidx.compose.ui.graphics.SolidColor54import androidx.compose.ui.graphics.graphicsLayer55import androidx.compose.ui.platform.LocalContext56import androidx.compose.ui.input.pointer.pointerInput57import androidx.compose.ui.text.TextStyle58import androidx.compose.ui.text.font.FontStyle59import androidx.compose.ui.text.style.TextOverflow60import androidx.compose.ui.unit.dp61import androidx.compose.ui.unit.em62import androidx.compose.ui.unit.sp63import androidx.compose.runtime.collectAsState64import androidx.fragment.app.FragmentActivity65import androidx.hilt.navigation.compose.hiltViewModel66import com.peachpasswords.shared.detectHomograph67import com.peachpasswords.ui.PeachNav68import com.peachpasswords.ui.components.CharEnterText69import com.peachpasswords.ui.components.CopyButton70import com.peachpasswords.ui.components.PeachEmptyOrEndState71import com.peachpasswords.ui.components.SecureFlagEffect72import com.peachpasswords.ui.lock.Biometrics73import com.peachpasswords.ui.theme.LocalPeachAccent74import com.peachpasswords.ui.theme.LocalReduceMotion75import com.peachpasswords.ui.theme.PeachSpacing76import com.peachpasswords.ui.theme.PeachRadius77import com.peachpasswords.ui.theme.peachColors78import com.peachpasswords.ui.theme.PeachType79import com.peachpasswords.ui.theme.ShellBackground80import com.peachpasswords.vault.TOTPGenerator81import com.peachpasswords.vault.hasTOTP82import com.peachpasswords.vault.hasPasskey83import com.peachpasswords.vault.isBreach84import com.peachpasswords.vault.isCard85import com.peachpasswords.vault.isNote86import com.peachpasswords.vault.isPasskeyOnly8788@Composable89fun EntryDetailScreen(90 id: String,91 onNavigate: (PeachNav) -> Unit,92 93 94 95 onBack: (() -> Unit)?,96 modifier: Modifier = Modifier,97 vm: EntryDetailViewModel = hiltViewModel(),98) {99 SecureFlagEffect()100101 val entry by vm.entry.collectAsState()102 val activeEntries by vm.activeEntries.collectAsState()103 val usernameSuggestions by vm.usernameSuggestions.collectAsState()104 val emailSuggestions by vm.emailSuggestions.collectAsState()105 val cvcRevealed by vm.cvcRevealed.collectAsState()106 var cardRevealed by remember { mutableStateOf(false) }107 val copiedField by vm.copiedField.collectAsState()108 val context = LocalContext.current109 val accent = LocalPeachAccent.current110 var confirmingDelete by remember { mutableStateOf(false) }111 var generatorOpen by remember { mutableStateOf(false) }112 var urlEditing by remember { mutableStateOf(false) }113114 val e = entry115 ShellBackground(modifier) {116 Box(117 Modifier.fillMaxSize().statusBarsPadding()118 119 120 121 122 .pointerInput(vm) {123 var dragAccum = 0f124 detectHorizontalDragGestures(125 onDragStart = { dragAccum = 0f },126 onDragEnd = {127 when {128 dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)129 dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)130 }131 dragAccum = 0f132 },133 onDragCancel = { dragAccum = 0f },134 ) { _, dragAmount -> dragAccum += dragAmount }135 },136 ) {137 Column(138 Modifier.fillMaxSize()139 .verticalScroll(rememberScrollState())140 .padding(horizontal = PeachSpacing.xl)141 .padding(bottom = 172.dp),142 ) {143 PeachVaultIdentity("vault") { onNavigate(PeachNav.VaultList) }144 Spacer(Modifier.height(PeachSpacing.sm))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 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {186 EditableTitleHero(187 title = e.title,188 onSave = { vm.updateField("title", it) },189 )190 Spacer(Modifier.height(PeachSpacing.sm))191 Box(Modifier.fillMaxWidth().height(100.dp)) {192 Text(193 text = ghostWatermarkText(e.title),194 style = PeachType.ghostMark.copy(fontSize = 150.sp),195 color = peachColors.T0.copy(alpha = 0.03f),196 maxLines = 1,197 softWrap = false,198 overflow = TextOverflow.Visible,199 modifier = Modifier200 .align(Alignment.TopStart)201 .wrapContentHeight(unbounded = true),202 )203 }204 }205 Spacer(Modifier.height(PeachSpacing.sm))206207 if (e.isCard()) {208 CardDetailFields(209 entry = e,210 cardRevealed = cardRevealed,211 cvcRevealed = cvcRevealed,212 copiedField = copiedField,213 onToggleCardReveal = { cardRevealed = !cardRevealed },214 onRevealCvc = {215 val activity = context as? FragmentActivity216 if (activity != null && Biometrics.isAvailable(context)) {217 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)218 } else vm.revealCvc()219 },220 onMaskCvc = vm::maskCvc,221 onCopy = vm::copy,222 onSaveCard = vm::updateCard,223 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },224 )225 } else {226 val showUsernameField = !e.username.isNullOrBlank()227 val showEmailField = !e.email.isNullOrBlank()228 val hasPassword = !e.password.isNullOrBlank()229 if (!e.isNote()) {230 CredentialStack {231 EditableFieldRow(232 label = "url", field = "url", value = e.url.orEmpty(),233 copied = copiedField == "url",234 onCopy = { vm.copy("url", e.url) },235 onSave = { vm.updateField("url", it) },236 placeholder = "add url",237 onEditingChange = { urlEditing = it },238 stacked = true,239 divider = showUsernameField || showEmailField || hasPassword,240 )241 242 243 244 if (showUsernameField) {245 EditableFieldRow(246 label = "username", field = "username", value = e.username.orEmpty(),247 copied = copiedField == "username",248 onCopy = { vm.copy("username", e.username) },249 onSave = { vm.updateField("username", it) },250 suggestions = usernameSuggestions,251 placeholder = "add username",252 stacked = true,253 divider = showEmailField || hasPassword,254 )255 }256 if (showEmailField) {257 EditableFieldRow(258 label = "email", field = "email", value = e.email.orEmpty(),259 copied = copiedField == "email",260 onCopy = { vm.copy("email", e.email) },261 onSave = { vm.updateField("email", it) },262 suggestions = emailSuggestions,263 placeholder = "add email",264 stacked = true,265 divider = hasPassword,266 )267 }268 e.password?.takeIf { it.isNotBlank() }?.let { pw ->269 EditablePasswordRow(270 value = pw,271 copied = copiedField == "password",272 entryTitle = e.title,273 onCopy = { vm.copy("password", pw) },274 onSave = { vm.updateField("password", it) },275 onGenerate = { generatorOpen = true },276 stacked = true,277 )278 }279 }280 }281 AdditionalUrlRows(282 urls = e.additionalUrls.orEmpty(),283 copiedField = copiedField,284 onCopy = { key, value -> vm.copy(key, value) },285 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },286 onAdd = vm::addAdditionalUrl,287 showAdd = urlEditing,288 )289 if (e.hasPasskey()) {290 PasskeyDetailSection(e)291 }292 if (e.hasTOTP()) {293 Spacer(Modifier.height(4.dp))294 TotpBandEditable(295 totpField = e.totp!!,296 copied = copiedField == "totp",297 onCopy = vm::copyTotp,298 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },299 )300 }301 }302 e.notes?.takeIf { it.isNotBlank() }?.let { v ->303 304 305 val notesAreHtml = isNoteHtml(v)306 EditableFieldRow(307 label = "notes", field = "notes", value = v,308 copied = copiedField == "notes",309 onCopy = { vm.copy("notes", notePlainText(v)) },310 onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },311 mono = false, singleLine = false,312 displayText = noteRendered(v),313 draftText = noteEditorText(v),314 )315 }316 EntryDetailActions(317 confirmingDelete = confirmingDelete,318 onEdit = { onNavigate(PeachNav.EntryEdit(e.id)) },319 onDelete = {320 if (confirmingDelete) {321 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })322 } else {323 confirmingDelete = true324 }325 },326 )327 Spacer(Modifier.height(PeachSpacing.xxl))328 }329330 if (e != null) {331 EntryDetailBottomControls(332 currentIndex = activeEntries.indexOfFirst { it.id == e.id },333 totalEntries = activeEntries.size,334 onOpenList = { onNavigate(PeachNav.VaultList) },335 onSearch = { onNavigate(PeachNav.VaultSearch) },336 onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },337 onOpenNotes = { onNavigate(PeachNav.Notes) },338 onSettings = { onNavigate(PeachNav.Settings) },339 onLock = vm::lock,340 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },341 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },342 modifier = Modifier.align(Alignment.BottomCenter),343 )344 }345346 if (generatorOpen) {347 GeneratorSheet(348 copied = copiedField == "genDraft",349 onCopy = { draft -> vm.copy("genDraft", draft) },350 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },351 onDismiss = { generatorOpen = false },352 )353 }354 }355 }356}357358@Composable359private fun EntryDetailActions(360 confirmingDelete: Boolean,361 onEdit: () -> Unit,362 onDelete: () -> Unit,363) {364 val accent = LocalPeachAccent.current365 Row(366 Modifier.fillMaxWidth().padding(top = PeachSpacing.lg),367 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),368 ) {369 DetailActionButton(370 label = "edit",371 color = peachColors.Bg,372 background = accent,373 border = accent,374 onClick = onEdit,375 modifier = Modifier.weight(1f),376 )377 DetailActionButton(378 label = if (confirmingDelete) "confirm trash" else "move to trash",379 color = if (confirmingDelete) peachColors.Breach else accent,380 background = Color.Transparent,381 border = if (confirmingDelete) peachColors.BreachBorder else peachColors.T3,382 onClick = onDelete,383 modifier = Modifier.weight(1f),384 )385 }386}387388@Composable389private fun DetailActionButton(390 label: String,391 color: Color,392 background: Color,393 border: Color,394 onClick: () -> Unit,395 modifier: Modifier = Modifier,396) {397 Box(398 modifier.height(52.dp)399 .clip(RoundedCornerShape(PeachRadius.Button))400 .background(background)401 .border(1.dp, border, RoundedCornerShape(PeachRadius.Button))402 .clickable(onClick = onClick),403 contentAlignment = Alignment.Center,404 ) {405 Text(label, color = color, style = PeachType.bodyMono)406 }407}408409@Composable410private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {411 val accent = LocalPeachAccent.current412 var editing by remember(title) { mutableStateOf(false) }413 var draft by remember(title) { mutableStateOf(title) }414 if (!editing) {415 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {416 CharEnterText(417 text = title,418 style = PeachType.entryNameHero.copy(color = peachColors.T0),419 )420 }421 return422 }423 Column(Modifier.fillMaxWidth()) {424 BasicTextField(425 value = draft,426 onValueChange = { draft = it },427 singleLine = true,428 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),429 cursorBrush = SolidColor(accent),430 modifier = Modifier.fillMaxWidth().drawBehind {431 drawLine(432 color = accent,433 start = Offset(0f, size.height),434 end = Offset(size.width, size.height),435 strokeWidth = 1.dp.toPx(),436 )437 },438 )439 Spacer(Modifier.height(8.dp))440 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {441 Text(442 "✓ save", color = accent, style = PeachType.labelMono,443 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {444 onSave(draft)445 editing = false446 }.padding(vertical = 4.dp),447 )448 Text(449 "✗", color = peachColors.T2, style = PeachType.labelMono,450 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {451 draft = title452 editing = false453 }.padding(horizontal = 6.dp, vertical = 4.dp),454 )455 }456 }457}458459@Composable460private fun HomographWarning() {461 Row(462 Modifier.fillMaxWidth()463 .padding(top = 8.dp)464 .clip(RoundedCornerShape(8.dp))465 .background(peachColors.BreachBg)466 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))467 .padding(horizontal = 12.dp, vertical = 9.dp),468 verticalAlignment = Alignment.CenterVertically,469 ) {470 Text("!", color = peachColors.Breach, style = PeachType.labelMono)471 Spacer(Modifier.width(10.dp))472 Text(473 "This URL contains non-ASCII or punycode characters.",474 color = peachColors.Breach,475 style = PeachType.labelMono,476 modifier = Modifier.weight(1f),477 )478 }479}480481@Composable482private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {483 val passkey = entry.passkey ?: return484 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {485 Column(Modifier.weight(1f)) {486 Text(487 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,488 color = peachColors.T0,489 style = PeachType.body,490 )491 Text(492 listOfNotNull(493 passkey.rpId,494 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,495 ).joinToString(" · "),496 color = peachColors.T2,497 style = PeachType.labelMono,498 )499 Text(500 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",501 color = peachColors.T2,502 style = PeachType.labelMono,503 modifier = Modifier.padding(top = 3.dp),504 )505 }506 }507}508509@Composable510internal fun CanaryEditSection(511 entryId: String,512 modifiedAt: Long,513 canary: com.peachpasswords.vault.Canary?,514 isPro: Boolean,515 message: String?,516 testing: Boolean,517 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,518 onTest: (webhookUrl: String) -> Unit,519) {520 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }521 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }522 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }523 val gated = !isPro && canary == null524 FieldShell("canary alert") {525 Column(Modifier.weight(1f)) {526 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {527 Text(528 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",529 color = peachColors.T0,530 style = PeachType.body,531 modifier = Modifier.weight(1f),532 )533 if (gated) {534 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)535 }536 Text(537 if (enabled) "on" else "off",538 color = if (enabled) LocalPeachAccent.current else peachColors.T2,539 style = PeachType.labelMono,540 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {541 if (gated) {542 onSave(true, webhookUrl, label)543 } else {544 val next = !enabled545 enabled = next546 if (!next) onSave(false, webhookUrl, label)547 }548 }.padding(horizontal = 6.dp, vertical = 4.dp),549 )550 }551 Text(552 "Fires a silent webhook when this entry's password or username is copied.",553 color = peachColors.T2,554 style = PeachType.labelMono,555 modifier = Modifier.padding(top = 3.dp),556 )557 if (enabled) {558 Spacer(Modifier.height(8.dp))559 PeachField(560 label = "webhook url",561 value = webhookUrl,562 onChange = { webhookUrl = it },563 )564 Text(565 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",566 color = peachColors.T2,567 style = PeachType.labelMono,568 )569 PeachField(570 label = "label",571 value = label,572 onChange = { label = it },573 mono = false,574 )575 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {576 Text(577 "save",578 color = LocalPeachAccent.current,579 style = PeachType.labelMono,580 modifier = Modifier.clip(RoundedCornerShape(6.dp))581 .clickable { onSave(true, webhookUrl, label) }582 .padding(vertical = 4.dp),583 )584 Text(585 if (testing) "sending..." else "test ping",586 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,587 style = PeachType.labelMono,588 modifier = Modifier.clip(RoundedCornerShape(6.dp))589 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }590 .padding(vertical = 4.dp),591 )592 }593 }594 message?.let {595 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))596 }597 if (canary?.lastPinged != null || canary?.pingCount != null) {598 Text(599 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),600 color = peachColors.T2,601 style = PeachType.labelMono,602 modifier = Modifier.padding(top = 3.dp),603 )604 }605 }606 }607}608609@Composable610private fun EntryDetailBottomControls(611 currentIndex: Int,612 totalEntries: Int,613 onOpenList: () -> Unit,614 onSearch: () -> Unit,615 onAddEntry: () -> Unit,616 onOpenNotes: () -> Unit,617 onSettings: () -> Unit,618 onLock: () -> 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 .background(631 Brush.verticalGradient(632 listOf(Color.Transparent, peachColors.Bg.copy(alpha = 0.92f), peachColors.Bg),633 ),634 )635 ) {636 Box(637 Modifier.fillMaxWidth().height(84.dp),638 contentAlignment = Alignment.BottomCenter,639 ) {640 Row(641 Modifier.fillMaxWidth().height(44.dp),642 horizontalArrangement = Arrangement.Center,643 verticalAlignment = Alignment.CenterVertically,644 ) {645 Icon(646 Icons.AutoMirrored.Rounded.ArrowBack,647 contentDescription = "Previous entry",648 tint = if (hasEntries) peachColors.T0 else peachColors.T2,649 modifier = Modifier650 .size(36.dp)651 .clip(RoundedCornerShape(8.dp))652 .clickable(enabled = hasEntries, onClick = onPrevious)653 .padding(8.dp),654 )655 Spacer(Modifier.width(PeachSpacing.md))656 Text(counter, color = peachColors.T2, style = PeachType.labelMono)657 Spacer(Modifier.width(PeachSpacing.md))658 Spacer(Modifier.width(4.dp))659 Icon(660 Icons.AutoMirrored.Rounded.ArrowForward,661 contentDescription = "Next entry",662 tint = if (hasEntries) peachColors.T0 else peachColors.T2,663 modifier = Modifier664 .size(36.dp)665 .clip(RoundedCornerShape(8.dp))666 .clickable(enabled = hasEntries, onClick = onNext)667 .padding(8.dp),668 )669 }670 }671 Column(672 Modifier.fillMaxWidth()673 .navigationBarsPadding(),674 ) {675 PeachDeckBottomBar(676 onSearch = onSearch,677 onAdd = onAddEntry,678 onNotes = onOpenNotes,679 onSettings = onSettings,680 onLock = onLock,681 onSwipeUp = onOpenList,682 )683 }684 }685}686687688@Composable689internal fun FieldShell(690 label: String,691 modifier: Modifier = Modifier,692 stacked: Boolean = false,693 divider: Boolean = false,694 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,695) {696 if (stacked) {697 Column(modifier.fillMaxWidth()) {698 Column(Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md)) {699 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))700 Spacer(Modifier.height(6.dp))701 Row(verticalAlignment = Alignment.CenterVertically, content = content)702 }703 if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(peachColors.T3))704 }705 return706 }707 Column(708 modifier.fillMaxWidth()709 .padding(vertical = PeachSpacing.xs)710 .clip(RoundedCornerShape(PeachRadius.StackCard))711 .background(peachColors.SurfaceCard)712 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))713 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),714 ) {715 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))716 Spacer(Modifier.height(6.dp))717 Row(verticalAlignment = Alignment.CenterVertically, content = content)718 }719}720721@Composable722private fun CredentialStack(content: @Composable () -> Unit) {723 Column(724 Modifier.fillMaxWidth()725 .padding(vertical = PeachSpacing.xs)726 .clip(RoundedCornerShape(PeachRadius.StackCard))727 .background(peachColors.SurfaceCard)728 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard)),729 ) { content() }730}731732733734735736internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }737738internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =739 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)740741internal fun shortCredential(credentialId: String): String {742 if (credentialId.length <= 12) return credentialId743 return credentialId.take(6) + "..." + credentialId.takeLast(4)744}745746internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {747 val count = pingCount ?: 0748 val countLabel = "$count total alert" + if (count == 1) "" else "s"749 if (lastPinged == null) return countLabel750 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)751 .format(java.util.Date(lastPinged))752 return "Last triggered: $date · $countLabel"753}