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.Clip,199 modifier = Modifier200 .align(Alignment.TopStart)201 .wrapContentHeight(unbounded = true),202 )203 }204 }205 Spacer(Modifier.height(PeachSpacing.sm))206207 EntryTagCards(e.tags.orEmpty())208209 if (e.isCard()) {210 CardDetailFields(211 entry = e,212 cardRevealed = cardRevealed,213 cvcRevealed = cvcRevealed,214 copiedField = copiedField,215 onToggleCardReveal = { cardRevealed = !cardRevealed },216 onRevealCvc = {217 val activity = context as? FragmentActivity218 if (activity != null && Biometrics.isAvailable(context)) {219 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)220 } else vm.revealCvc()221 },222 onMaskCvc = vm::maskCvc,223 onCopy = vm::copy,224 onSaveCard = vm::updateCard,225 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },226 )227 } else {228 val showUsernameField = !e.username.isNullOrBlank()229 val showEmailField = !e.email.isNullOrBlank()230 val hasPassword = !e.password.isNullOrBlank()231 if (!e.isNote()) {232 CredentialStack {233 EditableFieldRow(234 label = "url", field = "url", value = e.url.orEmpty(),235 copied = copiedField == "url",236 onCopy = { vm.copy("url", e.url) },237 onSave = { vm.updateField("url", it) },238 placeholder = "add url",239 onEditingChange = { urlEditing = it },240 stacked = true,241 divider = showUsernameField || showEmailField || hasPassword,242 )243 244 245 246 if (showUsernameField) {247 EditableFieldRow(248 label = "username", field = "username", value = e.username.orEmpty(),249 copied = copiedField == "username",250 onCopy = { vm.copy("username", e.username) },251 onSave = { vm.updateField("username", it) },252 suggestions = usernameSuggestions,253 placeholder = "add username",254 stacked = true,255 divider = showEmailField || hasPassword,256 )257 }258 if (showEmailField) {259 EditableFieldRow(260 label = "email", field = "email", value = e.email.orEmpty(),261 copied = copiedField == "email",262 onCopy = { vm.copy("email", e.email) },263 onSave = { vm.updateField("email", it) },264 suggestions = emailSuggestions,265 placeholder = "add email",266 stacked = true,267 divider = hasPassword,268 )269 }270 e.password?.takeIf { it.isNotBlank() }?.let { pw ->271 EditablePasswordRow(272 value = pw,273 copied = copiedField == "password",274 entryTitle = e.title,275 onCopy = { vm.copy("password", pw) },276 onSave = { vm.updateField("password", it) },277 onGenerate = { generatorOpen = true },278 stacked = true,279 )280 }281 }282 }283 AdditionalUrlRows(284 urls = e.additionalUrls.orEmpty(),285 copiedField = copiedField,286 onCopy = { key, value -> vm.copy(key, value) },287 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },288 onAdd = vm::addAdditionalUrl,289 showAdd = urlEditing,290 )291 if (e.hasPasskey()) {292 PasskeyDetailSection(e)293 }294 if (e.hasTOTP()) {295 Spacer(Modifier.height(4.dp))296 TotpBandEditable(297 totpField = e.totp!!,298 copied = copiedField == "totp",299 onCopy = vm::copyTotp,300 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },301 )302 }303 }304 e.notes?.takeIf { it.isNotBlank() }?.let { v ->305 306 307 val notesAreHtml = isNoteHtml(v)308 EditableFieldRow(309 label = "notes", field = "notes", value = v,310 copied = copiedField == "notes",311 onCopy = { vm.copy("notes", notePlainText(v)) },312 onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },313 mono = false, singleLine = false,314 displayText = noteRendered(v),315 draftText = noteEditorText(v),316 )317 }318 EntryDetailActions(319 confirmingDelete = confirmingDelete,320 onEdit = { onNavigate(PeachNav.EntryEdit(e.id)) },321 onDelete = {322 if (confirmingDelete) {323 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })324 } else {325 confirmingDelete = true326 }327 },328 )329 Spacer(Modifier.height(PeachSpacing.xxl))330 }331332 if (e != null) {333 EntryDetailBottomControls(334 currentIndex = activeEntries.indexOfFirst { it.id == e.id },335 totalEntries = activeEntries.size,336 onOpenList = { onNavigate(PeachNav.VaultList) },337 onSearch = { onNavigate(PeachNav.VaultSearch) },338 onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },339 onOpenNotes = { onNavigate(PeachNav.Notes) },340 onSettings = { onNavigate(PeachNav.Settings) },341 onLock = vm::lock,342 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },343 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },344 modifier = Modifier.align(Alignment.BottomCenter),345 )346 }347348 if (generatorOpen) {349 GeneratorSheet(350 copied = copiedField == "genDraft",351 onCopy = { draft -> vm.copy("genDraft", draft) },352 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },353 onDismiss = { generatorOpen = false },354 )355 }356 }357 }358}359360@Composable361private fun EntryDetailActions(362 confirmingDelete: Boolean,363 onEdit: () -> Unit,364 onDelete: () -> Unit,365) {366 val accent = LocalPeachAccent.current367 Row(368 Modifier.fillMaxWidth().padding(top = PeachSpacing.lg),369 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),370 ) {371 DetailActionButton(372 label = "edit",373 color = peachColors.Bg,374 background = accent,375 border = accent,376 onClick = onEdit,377 modifier = Modifier.weight(1f),378 )379 DetailActionButton(380 label = if (confirmingDelete) "confirm trash" else "move to trash",381 color = if (confirmingDelete) peachColors.Breach else accent,382 background = Color.Transparent,383 border = if (confirmingDelete) peachColors.BreachBorder else peachColors.T3,384 onClick = onDelete,385 modifier = Modifier.weight(1f),386 )387 }388}389390@Composable391private fun DetailActionButton(392 label: String,393 color: Color,394 background: Color,395 border: Color,396 onClick: () -> Unit,397 modifier: Modifier = Modifier,398) {399 Box(400 modifier.height(52.dp)401 .clip(RoundedCornerShape(PeachRadius.Button))402 .background(background)403 .border(1.dp, border, RoundedCornerShape(PeachRadius.Button))404 .clickable(onClick = onClick),405 contentAlignment = Alignment.Center,406 ) {407 Text(label, color = color, style = PeachType.bodyMono)408 }409}410411@Composable412private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {413 val accent = LocalPeachAccent.current414 var editing by remember(title) { mutableStateOf(false) }415 var draft by remember(title) { mutableStateOf(title) }416 if (!editing) {417 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {418 CharEnterText(419 text = title,420 style = PeachType.entryNameHero.copy(color = peachColors.T0),421 )422 }423 return424 }425 Column(Modifier.fillMaxWidth()) {426 BasicTextField(427 value = draft,428 onValueChange = { draft = it },429 singleLine = true,430 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),431 cursorBrush = SolidColor(accent),432 modifier = Modifier.fillMaxWidth().drawBehind {433 drawLine(434 color = accent,435 start = Offset(0f, size.height),436 end = Offset(size.width, size.height),437 strokeWidth = 1.dp.toPx(),438 )439 },440 )441 Spacer(Modifier.height(8.dp))442 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {443 Text(444 "✓ save", color = accent, style = PeachType.labelMono,445 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {446 onSave(draft)447 editing = false448 }.padding(vertical = 4.dp),449 )450 Text(451 "✗", color = peachColors.T2, style = PeachType.labelMono,452 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {453 draft = title454 editing = false455 }.padding(horizontal = 6.dp, vertical = 4.dp),456 )457 }458 }459}460461@Composable462private fun HomographWarning() {463 Row(464 Modifier.fillMaxWidth()465 .padding(top = 8.dp)466 .clip(RoundedCornerShape(8.dp))467 .background(peachColors.BreachBg)468 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))469 .padding(horizontal = 12.dp, vertical = 9.dp),470 verticalAlignment = Alignment.CenterVertically,471 ) {472 Text("!", color = peachColors.Breach, style = PeachType.labelMono)473 Spacer(Modifier.width(10.dp))474 Text(475 "This URL contains non-ASCII or punycode characters.",476 color = peachColors.Breach,477 style = PeachType.labelMono,478 modifier = Modifier.weight(1f),479 )480 }481}482483@Composable484private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {485 val passkey = entry.passkey ?: return486 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {487 Column(Modifier.weight(1f)) {488 Text(489 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,490 color = peachColors.T0,491 style = PeachType.body,492 )493 Text(494 listOfNotNull(495 passkey.rpId,496 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,497 ).joinToString(" · "),498 color = peachColors.T2,499 style = PeachType.labelMono,500 )501 Text(502 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",503 color = peachColors.T2,504 style = PeachType.labelMono,505 modifier = Modifier.padding(top = 3.dp),506 )507 }508 }509}510511@Composable512internal fun CanaryEditSection(513 entryId: String,514 modifiedAt: Long,515 canary: com.peachpasswords.vault.Canary?,516 isPro: Boolean,517 message: String?,518 testing: Boolean,519 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,520 onTest: (webhookUrl: String) -> Unit,521) {522 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }523 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }524 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }525 val gated = !isPro && canary == null526 FieldShell("canary alert") {527 Column(Modifier.weight(1f)) {528 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {529 Text(530 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",531 color = peachColors.T0,532 style = PeachType.body,533 modifier = Modifier.weight(1f),534 )535 if (gated) {536 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)537 }538 Text(539 if (enabled) "on" else "off",540 color = if (enabled) LocalPeachAccent.current else peachColors.T2,541 style = PeachType.labelMono,542 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {543 if (gated) {544 onSave(true, webhookUrl, label)545 } else {546 val next = !enabled547 enabled = next548 if (!next) onSave(false, webhookUrl, label)549 }550 }.padding(horizontal = 6.dp, vertical = 4.dp),551 )552 }553 Text(554 "Fires a silent webhook when this entry's password or username is copied.",555 color = peachColors.T2,556 style = PeachType.labelMono,557 modifier = Modifier.padding(top = 3.dp),558 )559 if (enabled) {560 Spacer(Modifier.height(8.dp))561 PeachField(562 label = "webhook url",563 value = webhookUrl,564 onChange = { webhookUrl = it },565 )566 Text(567 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",568 color = peachColors.T2,569 style = PeachType.labelMono,570 )571 PeachField(572 label = "label",573 value = label,574 onChange = { label = it },575 mono = false,576 )577 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {578 Text(579 "save",580 color = LocalPeachAccent.current,581 style = PeachType.labelMono,582 modifier = Modifier.clip(RoundedCornerShape(6.dp))583 .clickable { onSave(true, webhookUrl, label) }584 .padding(vertical = 4.dp),585 )586 Text(587 if (testing) "sending..." else "test ping",588 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,589 style = PeachType.labelMono,590 modifier = Modifier.clip(RoundedCornerShape(6.dp))591 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }592 .padding(vertical = 4.dp),593 )594 }595 }596 message?.let {597 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))598 }599 if (canary?.lastPinged != null || canary?.pingCount != null) {600 Text(601 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),602 color = peachColors.T2,603 style = PeachType.labelMono,604 modifier = Modifier.padding(top = 3.dp),605 )606 }607 }608 }609}610611@Composable612private fun EntryDetailBottomControls(613 currentIndex: Int,614 totalEntries: Int,615 onOpenList: () -> Unit,616 onSearch: () -> Unit,617 onAddEntry: () -> Unit,618 onOpenNotes: () -> Unit,619 onSettings: () -> Unit,620 onLock: () -> Unit,621 onPrevious: () -> Unit,622 onNext: () -> Unit,623 modifier: Modifier = Modifier,624) {625 val accent = LocalPeachAccent.current626 val hasEntries = totalEntries > 0 && currentIndex >= 0627 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"628629 Column(630 modifier631 .fillMaxWidth()632 .background(633 Brush.verticalGradient(634 listOf(Color.Transparent, peachColors.Bg.copy(alpha = 0.92f), peachColors.Bg),635 ),636 )637 ) {638 Box(639 Modifier.fillMaxWidth().height(84.dp),640 contentAlignment = Alignment.BottomCenter,641 ) {642 Row(643 Modifier.fillMaxWidth().height(44.dp),644 horizontalArrangement = Arrangement.Center,645 verticalAlignment = Alignment.CenterVertically,646 ) {647 Icon(648 Icons.AutoMirrored.Rounded.ArrowBack,649 contentDescription = "Previous entry",650 tint = if (hasEntries) peachColors.T0 else peachColors.T2,651 modifier = Modifier652 .size(36.dp)653 .clip(RoundedCornerShape(8.dp))654 .clickable(enabled = hasEntries, onClick = onPrevious)655 .padding(8.dp),656 )657 Spacer(Modifier.width(PeachSpacing.md))658 Text(counter, color = peachColors.T2, style = PeachType.labelMono)659 Spacer(Modifier.width(PeachSpacing.md))660 Spacer(Modifier.width(4.dp))661 Icon(662 Icons.AutoMirrored.Rounded.ArrowForward,663 contentDescription = "Next entry",664 tint = if (hasEntries) peachColors.T0 else peachColors.T2,665 modifier = Modifier666 .size(36.dp)667 .clip(RoundedCornerShape(8.dp))668 .clickable(enabled = hasEntries, onClick = onNext)669 .padding(8.dp),670 )671 }672 }673 Column(674 Modifier.fillMaxWidth()675 .navigationBarsPadding(),676 ) {677 PeachDeckBottomBar(678 onSearch = onSearch,679 onAdd = onAddEntry,680 onNotes = onOpenNotes,681 onSettings = onSettings,682 onLock = onLock,683 onSwipeUp = onOpenList,684 )685 }686 }687}688689690@Composable691internal fun FieldShell(692 label: String,693 modifier: Modifier = Modifier,694 stacked: Boolean = false,695 divider: Boolean = false,696 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,697) {698 if (stacked) {699 Column(modifier.fillMaxWidth()) {700 Column(Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md)) {701 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))702 Spacer(Modifier.height(6.dp))703 Row(verticalAlignment = Alignment.CenterVertically, content = content)704 }705 if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(peachColors.T3))706 }707 return708 }709 Column(710 modifier.fillMaxWidth()711 .padding(vertical = PeachSpacing.xs)712 .clip(RoundedCornerShape(PeachRadius.StackCard))713 .background(peachColors.SurfaceCard)714 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))715 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),716 ) {717 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))718 Spacer(Modifier.height(6.dp))719 Row(verticalAlignment = Alignment.CenterVertically, content = content)720 }721}722723@Composable724private fun CredentialStack(content: @Composable () -> Unit) {725 Column(726 Modifier.fillMaxWidth()727 .padding(vertical = PeachSpacing.xs)728 .clip(RoundedCornerShape(PeachRadius.StackCard))729 .background(peachColors.SurfaceCard)730 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard)),731 ) { content() }732}733734735736737738internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }739740internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =741 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)742743internal fun shortCredential(credentialId: String): String {744 if (credentialId.length <= 12) return credentialId745 return credentialId.take(6) + "..." + credentialId.takeLast(4)746}747748internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {749 val count = pingCount ?: 0750 val countLabel = "$count total alert" + if (count == 1) "" else "s"751 if (lastPinged == null) return countLabel752 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)753 .format(java.util.Date(lastPinged))754 return "Last triggered: $date · $countLabel"755}